diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 4b5a0fb1404..6b610b94be1 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: API contract boundary audit run: bun run check:api-validation:strict + - name: OpenAPI spec validation + run: bun run check:openapi + - name: Desktop bridge contract audit run: bun run check:desktop-bridge diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx index 25c8cfdbf2e..7e94ab0d7bd 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d8a1fb142c6..f4a24c829e1 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,9 +11,17 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 8e2caa1abe8..d5e28d23d63 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -1,8 +1,5 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "exportWorkflow", @@ -10,6 +7,8 @@ "deployWorkflow", "undeployWorkflow", "rollbackWorkflow", - "getJobStatus" + "executeWorkflowV2", + "getWorkflowExecutionV2", + "cancelExecutionV2" ] } diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index c99ab8eb13f..f4a24c829e1 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,18 @@ "typescript", "---Endpoints---", "(generated)/workflows", - "(generated)/human-in-the-loop", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/knowledge-bases" + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/integrations/zoho_desk.mdx b/apps/docs/content/docs/en/integrations/zoho_desk.mdx index 2081c2a2a14..8f32c2d419a 100644 --- a/apps/docs/content/docs/en/integrations/zoho_desk.mdx +++ b/apps/docs/content/docs/en/integrations/zoho_desk.mdx @@ -5,7 +5,7 @@ description: Manage Zoho Desk tickets, comments, threads, and contacts import { BlockInfoCard } from "@/components/ui/block-info-card" - @@ -512,4 +512,3 @@ Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, conta | `orgId` | string | Zoho Desk organization ID | | `payload` | json | The full resource that changed \(ticket, comment, thread, etc.\). Comment and thread events gain a derived plain-text `contentText` alongside the raw `content` + `contentType`; ticket events gain `descriptionText` alongside `description`. | | `prevState` | json | Previous state of the resource \(update events only\) | - diff --git a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx index 9bcf9dfb0ed..b9d039c2a56 100644 --- a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx @@ -33,7 +33,7 @@ Audit logs are also accessible through the Sim API for integration with external ```http GET /api/v1/audit-logs -Authorization: Bearer +X-API-Key: ``` **Query parameters:** @@ -71,11 +71,18 @@ Authorization: Bearer "createdAt": "2026-04-20T21:16:00.000Z" } ], - "nextCursor": "eyJpZCI6ImFiYzEyMyJ9" + "nextCursor": "eyJpZCI6ImFiYzEyMyJ9", + "limits": { + "workflowExecutionRateLimit": { + "sync": { "requestsPerMinute": 60, "maxBurst": 10, "remaining": 59, "resetAt": "2026-04-20T21:17:00.000Z" }, + "async": { "requestsPerMinute": 30, "maxBurst": 5, "remaining": 30, "resetAt": "2026-04-20T21:17:00.000Z" } + }, + "usage": { "currentPeriodCost": 1.25, "limit": 50, "plan": "enterprise", "isExceeded": false } + } } ``` -Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. +Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. Each entry also includes `actorName`; `metadata` is an arbitrary per-action JSON object. The `limits` object reports your current rate-limit and usage status. The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota. diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index 64716f9f0f3..7233cb1bc70 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -237,6 +237,26 @@ AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME=og-images AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos ``` +Direct browser uploads require a Blob service CORS rule on the storage account. Allow your exact +Sim origin, `GET` and `PUT`, the `Content-Type` header, and the `x-ms-*` prefix used by signed blob +and metadata headers: + +```bash +az storage cors add \ + --services b \ + --methods GET PUT \ + --origins https://sim.yourdomain.com \ + --allowed-headers content-type 'x-ms-*' \ + --exposed-headers ETag \ + --max-age 3600 \ + --account-name mystorageaccount \ + --account-key '' +``` + +If you authenticate with a connection string, replace the last two options with +`--connection-string "$AZURE_CONNECTION_STRING"`. CORS is configured once for the account's Blob +service and applies to all of its containers. + A full Helm example lives at `helm/sim/examples/values-azure.yaml`. ## Set up Google Cloud Storage @@ -276,11 +296,13 @@ cat > /tmp/cors.json <<'EOF' "responseHeader": [ "Content-Type", "ETag", + "x-goog-meta-uploadid", "x-goog-meta-originalname", "x-goog-meta-uploadedat", "x-goog-meta-purpose", "x-goog-meta-userid", "x-goog-meta-workspaceid", + "x-goog-meta-knowledgebaseid", "x-goog-meta-folderid", "x-goog-meta-workflowid", "x-goog-meta-executionid" @@ -445,6 +467,27 @@ The same browser-reachability and CORS requirements apply. +## Configure temporary upload cleanup + +Sim stages every direct upload under the `upload-sessions/` prefix before promoting it to its final, +immutable object key. Apply the cleanup policy to **every** purpose-specific bucket or container +configured above: + +- On AWS S3 and Google Cloud Storage, expire objects under `upload-sessions/` after two days and + abort incomplete multipart uploads after two days. +- On Azure Blob, expire committed blobs under `upload-sessions/` after two days. Azure automatically + removes uncommitted blocks after seven days. +- For an S3-compatible provider, configure both rules when its lifecycle implementation supports + them. Check the provider's documentation because lifecycle feature support varies. + +The two-day window exceeds the 24-hour upload-token lifetime and leaves time to retry completion. +Do not apply this prefix rule to final objects outside `upload-sessions/`. + + + Configure both expiration and incomplete-multipart cleanup where available. Expiring staged + objects alone does not necessarily remove abandoned multipart parts. + + ## Verify it works After restarting with the new configuration: diff --git a/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx b/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx index 72152909258..d2dd3eb601a 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx @@ -49,4 +49,4 @@ Recommended for any production deployment. The requirement is **pgvector**. DATABASE_URL="postgresql://user:pass@host:5432/simstudio?sslmode=require" ``` -For the Helm chart, disable the bundled Postgres and use `externalDatabase` — see [Kubernetes](/platform/self-hosting/kubernetes#external-database). +For the Helm chart, disable the bundled Postgres and use `externalDatabase` — see [Kubernetes](/platform/self-hosting/kubernetes#external-database). diff --git a/apps/docs/content/docs/es/api-reference/getting-started.mdx b/apps/docs/content/docs/es/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index c96dc5d2edc..f4a24c829e1 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,17 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index c96dc5d2edc..f4a24c829e1 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,17 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index c96dc5d2edc..f4a24c829e1 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,17 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index c96dc5d2edc..f4a24c829e1 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,17 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/credentials", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index af5f7a2b4c8..c3dac25a837 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,18 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createOpenAPI } from 'fumadocs-openapi/server' +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +34,34 @@ function resolveRef(ref: string, spec: Record): unknown { return current } -function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown { - if (depth > 10) return obj +function resolveRefs( + obj: unknown, + spec: Record, + seen: Set = new Set(), + depth = 0 +): unknown { + // Generous backstop against pathological fan-out; real schemas nest far shallower. + if (depth > 50) return obj if (Array.isArray(obj)) { - return obj.map((item) => resolveRefs(item, spec, depth + 1)) + return obj.map((item) => resolveRefs(item, spec, seen, depth + 1)) } if (obj && typeof obj === 'object') { const record = obj as Record - if ('$ref' in record && typeof record.$ref === 'string') { - const resolved = resolveRef(record.$ref, spec) - return resolveRefs(resolved, spec, depth + 1) + if (typeof record.$ref === 'string') { + const ref = record.$ref + // Break reference cycles: if this $ref is already being expanded above us, + // leave it untouched instead of recursing forever. + if (seen.has(ref)) return record + const resolved = resolveRef(ref, spec) + if (resolved === undefined) return record + seen.add(ref) + const out = resolveRefs(resolved, spec, seen, depth + 1) + seen.delete(ref) + return out } const result: Record = {} for (const [key, value] of Object.entries(record)) { - result[key] = resolveRefs(value, spec, depth + 1) + result[key] = resolveRefs(value, spec, seen, depth + 1) } return result } @@ -48,14 +72,34 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -let cachedSpec: Record | null = null +let cachedSpecs: Record[] | null = null + +function getSpecs(): Record[] { + if (!cachedSpecs) { + cachedSpecs = SPEC_FILES.map( + (file) => + JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record + ) + } + return cachedSpecs +} -function getSpec(): Record { - if (!cachedSpec) { - const specPath = join(process.cwd(), 'openapi.json') - cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record +/** + * Locate an operation by path + method across every rendered spec, returning the + * operation together with the spec that owns it so `$ref`s resolve within the + * correct document (each spec carries its own `components`). + */ +function findOperation( + path: string, + method: string +): { operation: Record; spec: Record } | undefined { + const key = method.toLowerCase() + for (const spec of getSpecs()) { + const pathObj = (spec.paths as Record> | undefined)?.[path] + const operation = pathObj?.[key] as Record | undefined + if (operation) return { operation, spec } } - return cachedSpec + return undefined } export function getApiSpecContent( @@ -63,22 +107,19 @@ export function getApiSpecContent( description: string | undefined, operations: OpenAPIOperation[] ): string { - const spec = getSpec() - if (!operations || operations.length === 0) { return `# ${title}\n\n${description || ''}` } const op = operations[0] const method = op.method.toUpperCase() - const pathObj = (spec.paths as Record>)?.[op.path] - const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined + const found = findOperation(op.path, op.method) - if (!operation) { + if (!found) { return `# ${title}\n\n${description || ''}` } - const resolved = resolveRefs(operation, spec) as Record + const resolved = resolveRefs(found.operation, found.spec) as Record const lines: string[] = [] lines.push(`# ${title}`) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..d5d3cccd20e --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2201 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current usage spending and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current usage and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + }, + "/api/v2/billing/usage": { + "get": { + "operationId": "getUsageSummary", + "summary": "Get Usage Summary", + "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `copilot`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + } + ], + "responses": { + "200": { + "description": "The current billing period's usage summary.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "period", + "totalCredits", + "bySourceCredits", + "limitCredits", + "plan" + ], + "properties": { + "period": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "totalCredits": { + "type": "number" + }, + "bySourceCredits": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Credits consumed per usage source over the billing period." + }, + "limitCredits": { + "type": "number" + }, + "plan": { + "type": "string" + } + } + } + } + }, + "example": { + "data": { + "period": { + "start": "2026-07-01T00:00:00.000Z", + "end": "2026-08-01T00:00:00.000Z" + }, + "totalCredits": 512, + "bySourceCredits": { + "workflow": 380, + "copilot": 120, + "knowledge-base": 12 + }, + "limitCredits": 20000, + "plan": "pro" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/billing/usage/logs": { + "get": { + "operationId": "listUsageLogs", + "summary": "List Usage Logs", + "description": "Cursor-paged, credit-denominated ledger of the account's usage events. The per-source aggregate lives on `GET /api/v2/billing/usage`; this is the row-level detail. Page by passing `nextCursor` back as `cursor` and stop when it is null.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`)." + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "enum": ["1d", "7d", "30d", "custom", "all"], + "default": "30d" + }, + "description": "Relative window, `all`, or `custom` (requires `startDate`)." + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of a `custom` window. Any `Date`-parseable string." + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of a `custom` window; defaults to now." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque cursor from the previous page." + } + ], + "responses": { + "200": { + "description": "A page of usage events.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "createdAt", "source", "workflowName", "creditCost"], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string" + }, + "workflowName": { + "type": ["string", "null"], + "description": "Populated only when `source` is `workflow`." + }, + "creditCost": { + "type": "number", + "description": "Apportioned so page rows sum exactly to the rounded page total; can be 0 for a sub-credit event." + } + } + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "example": { + "data": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "copilot", + "workflowName": null, + "creditCost": 12 + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current usage and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + }, + "V2Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable code, e.g. `BAD_REQUEST`, `FORBIDDEN`, `RATE_LIMITED`." + }, + "message": { + "type": "string" + }, + "details": { + "description": "Optional structured context (e.g. per-field validation issues)." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + }, + "V2BadRequest": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Forbidden": { + "description": "The credential is not authorized for the requested resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2RateLimited": { + "description": "Rate limit exceeded; retry after the window resets.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..27f2b0f1c8e --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,2574 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Files & Audit Logs", + "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Files", + "description": "Upload, download, list, rename, delete, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." + }, + { + "name": "Audit Logs", + "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List Files", + "description": "List a workspace's files with opaque cursor pagination. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `folderPath` to return only files directly inside one canonical folder path; omit it to list files from every folder.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID&limit=100\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "name": "folderPath", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every file in the workspace.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the file `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "A page of workspace files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createFile", + "summary": "Create File", + "description": "Create an authored workspace file, either empty or with initial inline content. Use this endpoint for files whose bytes are already available as UTF-8 text or base64 and are at most 50 MiB after decoding. Use the upload-session endpoints for streamed or larger files. A live file with the same name in the same folder is rejected with `409`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"notes.md\"}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace in which to create the file." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File name, including its extension. Path separators and dot segments are rejected." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MIME type. When omitted, it is inferred from the file extension." + }, + "folderPath": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Canonical containing-folder path. `/` is the workspace root." + }, + "content": { + "type": "string", + "maxLength": 70000000, + "default": "", + "description": "Initial file content. Omit or send an empty string to create a zero-byte file." + }, + "encoding": { + "type": "string", + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "Encoding of `content`." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/uploads": { + "post": { + "operationId": "createFileUpload", + "summary": "Create File Upload", + "description": "Create an upload session and signed control token. Empty files and files up to and including 50 MiB receive a single signed PUT URL; larger files receive multipart transfer instructions. The maximum file size is 5 GB.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "The upload session.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/uploads/{uploadId}": { + "delete": { + "operationId": "abortFileUpload", + "summary": "Abort File Upload", + "description": "Abort an incomplete upload and discard its provider parts.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "responses": { + "200": { + "description": "The aborted upload session.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/uploads/{uploadId}/parts": { + "post": { + "operationId": "createFileUploadPartUrls", + "summary": "Create File Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for a bounded set of upload part numbers.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeFileUpload", + "summary": "Complete File Upload", + "description": "Verify the single PUT or assemble every multipart part, then atomically register the workspace file.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "The completed upload and registered file.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "headers": { + "Content-Type": { + "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", + "schema": { + "type": "string" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string" + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Delete a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The file could not be deleted because of a conflicting state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "renameFile", + "summary": "Rename File", + "description": "Rename a file. Renaming only — use `POST /api/v2/files/move` to change which folder a file lives in. A name already taken in the same folder is rejected with `409`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"renamed.csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The renamed file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/metadata": { + "get": { + "operationId": "getFile", + "summary": "Get File Metadata", + "description": "Return one workspace file's metadata without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/metadata?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type (e.g., file, workflow, workspace, member).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by a specific resource ID.", + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actorId", + "in": "query", + "required": false, + "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only return entries at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only return entries at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "When true, include entries from users who have left the organization. Defaults to false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + }, + "example": { + "data": [ + { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "actorId is not a member of your organization" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs/{id}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique audit log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "example": "audit_2c3d4e5f6g" + } + } + ], + "responses": { + "200": { + "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + }, + "example": { + "data": { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/move": { + "post": { + "operationId": "moveFileItems", + "summary": "Move Files and Folders", + "description": "Move files and/or folders into a folder. `targetFolderPath: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderPaths` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderPath\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "fileIds"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items." + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to move." + }, + "targetFolderPath": { + "type": "string", + "description": "Canonical destination folder path. Omit to use the workspace root." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The items were moved.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2MoveFileItemsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file's share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2GetFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "isActive"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isActive": { + "type": "boolean", + "description": "Whether the share should resolve. `false` disables without revoking." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." + }, + "allowedEmails": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." + } + } + }, + "examples": { + "publicLink": { + "summary": "Enable a public link", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "public" + } + }, + "passwordProtected": { + "summary": "Enable a password-protected link", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "password", + "password": "EXAMPLE_PASSWORD" + } + }, + "disable": { + "summary": "Disable (keeps the token and stored config)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The share after the update.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UpsertFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/content": { + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "content"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file." + }, + "content": { + "type": "string", + "description": "The file's new full contents, interpreted per `encoding`." + }, + "encoding": { + "type": "string", + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "How to decode `content` into bytes." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/bulk-delete": { + "post": { + "operationId": "bulkDeleteFiles", + "summary": "Delete Files", + "description": "Delete up to 1,000 files. Folder deletion is available at `/api/v2/files/folders`.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "fileIds"], + "properties": { + "workspaceId": { + "type": "string" + }, + "fileIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Deletion result.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedItems"], + "properties": { + "deletedItems": { + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/folders": { + "get": { + "operationId": "listFilesFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Files"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilesFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createFilesFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/FilesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateFilesFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/FilesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFilesFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Files"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "files"], + "properties": { + "folders": { + "type": "integer" + }, + "files": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": [ + "id", + "name", + "size", + "type", + "key", + "folderPath", + "folderPath", + "uploadedBy", + "uploadedAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the last write, content or metadata.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2DeleteFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful delete.", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the deleted file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful delete." + } + } + }, + "V2AuditLogEntry": { + "type": "object", + "description": "A public enterprise audit log entry. The ipAddress and userAgent fields are intentionally excluded for privacy.", + "required": [ + "id", + "workspaceId", + "actorId", + "actorName", + "actorEmail", + "action", + "resourceType", + "resourceId", + "resourceName", + "description", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace where the action occurred, or null for organization-level actions.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": ["string", "null"], + "description": "The user ID of the person who performed the action, or null when not attributable.", + "example": "user_abc123" + }, + "actorName": { + "type": ["string", "null"], + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": ["string", "null"], + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., file.uploaded, workflow.deployed).", + "example": "file.uploaded" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., file, workflow, workspace, member).", + "example": "file" + }, + "resourceId": { + "type": ["string", "null"], + "description": "The unique identifier of the affected resource.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "resourceName": { + "type": ["string", "null"], + "description": "Display name of the affected resource.", + "example": "data.csv" + }, + "description": { + "type": ["string", "null"], + "description": "Human-readable description of the action.", + "example": "Uploaded file \"data.csv\" via API" + }, + "metadata": { + "description": "Arbitrary per-action metadata as JSON. The shape varies by action type and may be null for some actions.", + "example": { + "fileSize": 1024, + "fileType": "text/csv" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2FileListResponse": { + "type": "object", + "description": "A page of files plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The files in this page.", + "items": { + "$ref": "#/components/schemas/V2File" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2FileResponse": { + "type": "object", + "description": "A single file resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2File" + } + } + }, + "V2DeleteFileResponse": { + "type": "object", + "description": "The result of archiving a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2DeleteFileResult" + } + } + }, + "V2AuditLogListResponse": { + "type": "object", + "description": "A page of audit log entries plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The audit log entries in this page.", + "items": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2AuditLogResponse": { + "type": "object", + "description": "A single audit log entry resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + } + }, + "V2Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code (e.g., BAD_REQUEST, NOT_FOUND, RATE_LIMITED)." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error context. For validation errors this is an array of field-level issues; for rate limiting it carries the reset timestamp." + } + } + } + } + }, + "V2FileShare": { + "type": "object", + "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", + "required": [ + "id", + "token", + "url", + "isActive", + "resourceType", + "resourceId", + "authType", + "hasPassword", + "allowedEmails" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique share identifier.", + "example": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb" + }, + "token": { + "type": "string", + "description": "The public token embedded in the share URL. Always server-generated.", + "example": "share-token-example" + }, + "url": { + "type": "string", + "format": "uri", + "description": "The public share URL.", + "example": "https://www.sim.ai/f/share-token-example" + }, + "isActive": { + "type": "boolean", + "description": "Whether the share currently resolves. Disabling does not revoke — see the endpoint description." + }, + "resourceType": { + "type": "string", + "enum": ["file", "folder"], + "description": "The kind of resource shared. Always `file` on this surface." + }, + "resourceId": { + "type": "string", + "description": "The shared resource id.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the share is gated." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored for this share." + }, + "allowedEmails": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allow-list of addresses or `@domain` patterns for `email`/`sso` shares. Empty otherwise." + } + } + }, + "V2MoveFileItemsResult": { + "type": "object", + "description": "What the move actually relocated.", + "required": ["movedItems"], + "properties": { + "movedItems": { + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "integer" + } + } + } + } + }, + "V2GetFileShareResult": { + "type": "object", + "description": "The file's share state, or null when the file has never been shared.", + "required": ["share"], + "properties": { + "share": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2FileShare" + }, + { + "type": "null" + } + ], + "description": "The share, or null when the file has never been shared." + } + } + }, + "V2UpsertFileShareResult": { + "type": "object", + "description": "The share after the upsert.", + "required": ["share"], + "properties": { + "share": { + "$ref": "#/components/schemas/V2FileShare" + } + } + }, + "V2MoveFileItemsResponse": { + "type": "object", + "description": "The result of a move.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2MoveFileItemsResult" + } + } + }, + "V2GetFileShareResponse": { + "type": "object", + "description": "The file's public share state.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2GetFileShareResult" + } + } + }, + "V2UpsertFileShareResponse": { + "type": "object", + "description": "The share after enabling or disabling it.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2UpsertFileShareResult" + } + } + }, + "FilesFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": ["workspaceId"], + "code": "invalid_type", + "message": "Required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Active enterprise subscription required" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found, or it does not belong to the authorized scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "File not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T11:00:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with existing state — most often a name already taken in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file named \"data.csv\" already exists in this workspace" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The body exceeds the per-request size limit, or accepting it would push the workspace past its storage quota.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..e3d094a24a3 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,2933 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Knowledge Bases", + "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/knowledge": { + "get": { + "operationId": "listKnowledgeBases", + "summary": "List Knowledge Bases", + "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderPath", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the knowledge base `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Knowledge bases for the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The knowledge bases in the workspace.", + "items": { + "$ref": "#/components/schemas/KnowledgeBase" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeBase", + "summary": "Create Knowledge Base", + "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The knowledge base to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "201": { + "description": "The knowledge base was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "get": { + "operationId": "getKnowledgeBase", + "summary": "Get Knowledge Base", + "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateKnowledgeBase", + "summary": "Update Knowledge Base", + "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The fields to update. At least one of name, description, or chunkingConfig is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeBase", + "summary": "Delete Knowledge Base", + "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The search request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchBody" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "crossModel": { + "summary": "Knowledge bases use different embedding models", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately." + } + } + }, + "multiKbTagFilter": { + "summary": "Tag filters across multiple knowledge bases", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Tag filters are only supported when searching a single knowledge base" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found or access denied" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of documents to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against document filenames.", + "schema": { + "type": "string" + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter documents by their enabled state.", + "schema": { + "type": "string", + "enum": ["all", "enabled", "disabled"], + "default": "all" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ], + "responses": { + "200": { + "description": "Documents in the knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The documents on this page.", + "items": { + "$ref": "#/components/schemas/DocumentSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\"" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The document file to upload (max 100 MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The document was accepted and queued for processing.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentSummaryEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (123.45MB)" + } + } + } + } + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + } + }, + "/api/v2/knowledge/{id}/documents/uploads": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "post": { + "operationId": "createKnowledgeDocumentUpload", + "summary": "Create Document Upload", + "description": "Create an upload session for a knowledge document. Files up to and including 50 MiB use a single signed PUT; larger files use multipart transfer. Write access, billing, usage, file type, file size, and workspace storage are checked before provider storage is allocated. The signed upload token binds the caller, workspace, knowledge base, filename, content type, byte size, provider, and knowledge-document purpose. Files may be up to 100 MB.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/uploads\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"guide.pdf\",\"contentType\":\"application/pdf\",\"size\":248913}'" + } + ], + "requestBody": { + "required": true, + "description": "Metadata for the document that will be uploaded through the returned transfer instructions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentUploadBody" + } + } + } + }, + "responses": { + "201": { + "description": "The terminal-safe upload session, signed control-plane token, and PUT or multipart transfer instructions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentUploadEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "delete": { + "operationId": "abortKnowledgeDocumentUpload", + "summary": "Abort Document Upload", + "description": "Abort an incomplete knowledge-document upload and discard its provider parts. Aborting an already aborted session is safe.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The aborted upload session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "post": { + "operationId": "createKnowledgeDocumentUploadPartUrls", + "summary": "Create Document Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for up to 100 part numbers. PUT each byte range directly to the returned URL with the returned headers.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePartUrlsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartUrlsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "post": { + "operationId": "completeKnowledgeDocumentUpload", + "summary": "Complete Document Upload", + "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteUploadBody" + } + } + } + }, + "responses": { + "200": { + "description": "The completed upload and queued knowledge document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + } + }, + "/api/v2/knowledge/folders": { + "get": { + "operationId": "listKnowledgeFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Knowledge Bases"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateKnowledgeFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Knowledge Bases"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "knowledgeBases"], + "properties": { + "folders": { + "type": "integer" + }, + "knowledgeBases": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "KnowledgeBaseId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "DocumentId": { + "name": "documentId", + "in": "path", + "required": true, + "description": "The unique identifier of the document.", + "schema": { + "type": "string", + "minLength": 1, + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + } + }, + "UploadId": { + "name": "uploadId", + "in": "path", + "required": true, + "description": "The upload session identifier returned when the upload was created.", + "schema": { + "type": "string", + "minLength": 1, + "example": "upload_01K0M9J4W6K4J3T73Q8W2NYR9P" + } + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned when this upload was created. It is bound to the caller and all upload metadata.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + } + }, + "schemas": { + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "ChunkingConfig": { + "type": "object", + "description": "How documents in this knowledge base are split into chunks before embedding.", + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": true, + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "example": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "example": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "example": 200 + }, + "strategy": { + "type": "string", + "description": "Chunking strategy applied during processing.", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + }, + "strategyOptions": { + "type": "object", + "additionalProperties": false, + "description": "Strategy-specific tuning. `pattern`/`strictBoundaries` apply to the `regex` strategy; `separators` to `text`; `recipe` to `recursive`.", + "properties": { + "pattern": { + "type": "string", + "maxLength": 500 + }, + "separators": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipe": { + "enum": ["plain", "markdown", "code"] + }, + "strictBoundaries": { + "type": "boolean" + } + } + } + } + }, + "ChunkingConfigInput": { + "type": "object", + "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.", + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "minimum": 100, + "maximum": 4000, + "default": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "minimum": 1, + "maximum": 2000, + "default": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "minimum": 0, + "maximum": 500, + "default": 200 + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base: a collection of documents indexed for vector and tag search.", + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt", + "folderPath" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the knowledge base. null when not set.", + "example": "All product docs and guides" + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens across all indexed documents.", + "example": 48213 + }, + "embeddingModel": { + "type": "string", + "description": "The embedding model used to index documents in this knowledge base.", + "example": "text-embedding-3-small" + }, + "embeddingDimension": { + "type": "integer", + "description": "The dimensionality of the embedding vectors.", + "example": 1536 + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base.", + "example": 12 + }, + "connectorTypes": { + "type": "array", + "description": "The set of external connector types that have synced documents into this knowledge base.", + "items": { + "type": "string" + }, + "example": ["notion", "google_drive"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "example": "2025-06-18T16:45:00Z" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + } + } + }, + "KnowledgeBaseEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["knowledgeBase"], + "properties": { + "knowledgeBase": { + "$ref": "#/components/schemas/KnowledgeBase" + } + } + } + } + }, + "CreateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for creating a knowledge base.", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "Optional description of the knowledge base.", + "example": "All product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + } + } + }, + "UpdateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for updating a knowledge base. At least one of name, description, or chunkingConfig must be provided.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New knowledge base name.", + "example": "Updated Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "New description of the knowledge base.", + "example": "Refreshed product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + } + } + }, + "CreateDocumentUploadBody": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "name", "contentType", "size"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Filename recorded on the knowledge document." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Supported MIME type for the document." + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 104857600, + "description": "Exact file size in bytes." + }, + "tag1": { + "type": "string", + "maxLength": 1000 + }, + "tag2": { + "type": "string", + "maxLength": 1000 + }, + "tag3": { + "type": "string", + "maxLength": 1000 + }, + "tag4": { + "type": "string", + "maxLength": 1000 + }, + "tag5": { + "type": "string", + "maxLength": 1000 + }, + "tag6": { + "type": "string", + "maxLength": 1000 + }, + "tag7": { + "type": "string", + "maxLength": 1000 + }, + "processingOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "recipe": { + "type": "string", + "maxLength": 255 + }, + "lang": { + "type": "string", + "maxLength": 35 + } + }, + "description": "Optional processing recipe and language, bound into the signed upload state." + } + } + }, + "DocumentUpload": { + "type": "object", + "required": [ + "id", + "knowledgeBaseId", + "status", + "name", + "contentType", + "size", + "expiresAt", + "error", + "document" + ], + "properties": { + "id": { + "type": "string", + "description": "Upload session identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base that will own the document." + }, + "status": { + "type": "string", + "enum": ["uploading", "finalizing", "completed", "failed", "aborted", "expired"] + }, + "name": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 1 + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "error": { + "type": ["string", "null"] + }, + "document": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentSummary" + }, + { + "type": "null" + } + ], + "description": "The queued document after completion; null while uploading or after abort." + } + } + }, + "DocumentUploadEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DocumentUpload" + } + } + }, + "PutUploadTransfer": { + "type": "object", + "additionalProperties": false, + "required": ["method", "url", "headers"], + "properties": { + "method": { + "type": "string", + "const": "put" + }, + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "MultipartUploadTransfer": { + "type": "object", + "additionalProperties": false, + "required": ["method", "partSize", "partCount"], + "properties": { + "method": { + "type": "string", + "const": "multipart" + }, + "partSize": { + "type": "integer", + "minimum": 1 + }, + "partCount": { + "type": "integer", + "minimum": 1, + "maximum": 640 + } + } + }, + "UploadTransfer": { + "oneOf": [ + { + "$ref": "#/components/schemas/PutUploadTransfer" + }, + { + "$ref": "#/components/schemas/MultipartUploadTransfer" + } + ], + "discriminator": { + "propertyName": "method" + } + }, + "CreateDocumentUploadEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["session", "uploadToken", "transfer"], + "properties": { + "session": { + "$ref": "#/components/schemas/DocumentUpload" + }, + "uploadToken": { + "type": "string", + "minLength": 1 + }, + "transfer": { + "$ref": "#/components/schemas/UploadTransfer" + } + } + } + } + }, + "CreatePartUrlsBody": { + "type": "object", + "additionalProperties": false, + "required": ["partNumbers"], + "properties": { + "partNumbers": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "UploadPartUrl": { + "type": "object", + "required": ["partNumber", "url", "headers", "expiresAt"], + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1 + }, + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PartUrlsEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["parts"], + "properties": { + "parts": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/UploadPartUrl" + } + } + } + } + } + }, + "CompleteUploadBody": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["parts"], + "properties": { + "parts": { + "type": "array", + "minItems": 1, + "maxItems": 640, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["partNumber"], + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1 + }, + "etag": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false + } + ] + }, + "DocumentSummary": { + "type": "object", + "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "DocumentSummaryEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/DocumentSummary" + } + } + } + } + }, + "Document": { + "type": "object", + "description": "Full document detail: the summary fields plus processing state and connector provenance.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt", + "processingError", + "processingStartedAt", + "processingCompletedAt", + "connectorId", + "connectorType", + "sourceUrl" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + }, + "processingError": { + "type": ["string", "null"], + "description": "Error message if processing failed, otherwise null.", + "example": null + }, + "processingStartedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing started, or null.", + "example": "2025-06-18T16:45:05Z" + }, + "processingCompletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing completed, or null.", + "example": "2025-06-18T16:45:42Z" + }, + "connectorId": { + "type": ["string", "null"], + "description": "Identifier of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "connectorType": { + "type": ["string", "null"], + "description": "Type of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document for connector-synced documents, or null.", + "example": null + } + } + }, + "DocumentEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/Document" + } + } + } + } + }, + "SearchTagFilter": { + "type": "object", + "description": "A structured tag filter applied to search. Tag filters are only supported when searching a single knowledge base.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "The display name of the tag to filter on.", + "example": "category" + }, + "fieldType": { + "type": "string", + "description": "The tag's field type.", + "enum": ["text", "number", "date", "boolean"] + }, + "operator": { + "type": "string", + "description": "Comparison operator. Valid operators depend on the field type.", + "default": "eq", + "example": "eq" + }, + "value": { + "description": "The value to compare against.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "example": "billing" + }, + "valueTo": { + "description": "Upper bound for the `between` operator (number or date).", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "SearchBody": { + "type": "object", + "description": "Request body for knowledge search. At least one of `query` or `tagFilters` must be provided.", + "required": ["workspaceId", "knowledgeBaseIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the knowledge bases.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "knowledgeBaseIds": { + "description": "A single knowledge base ID or an array of up to 20 IDs to search.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "A single knowledge base ID." + }, + { + "type": "array", + "description": "An array of knowledge base IDs.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "maxItems": 20 + } + ], + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "query": { + "type": "string", + "description": "The natural-language query for semantic vector search. Required if `tagFilters` is omitted.", + "example": "How do I reset my password?" + }, + "topK": { + "type": "integer", + "description": "Maximum number of results to return.", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "tagFilters": { + "type": "array", + "description": "Structured tag filters. Only supported when searching a single knowledge base. Required if `query` is omitted.", + "items": { + "$ref": "#/components/schemas/SearchTagFilter" + } + }, + "searchMode": { + "type": "string", + "enum": ["vector", "hybrid"], + "default": "vector", + "description": "Retrieval strategy. `vector` is semantic-only; `hybrid` additionally runs a full-text leg and fuses the two by reciprocal rank, which recovers exact tokens (error codes, ticket keys, identifiers) that embeddings rank poorly." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search hit (a matching document chunk).", + "required": [ + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the document the chunk belongs to.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "documentName": { + "type": ["string", "null"], + "description": "Filename of the source document, or null if unavailable.", + "example": "getting-started.pdf" + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document, or null for direct uploads.", + "example": null + }, + "content": { + "type": "string", + "description": "The matching chunk's text content.", + "example": "To reset your password, open Settings and choose \"Security\"." + }, + "chunkIndex": { + "type": "integer", + "description": "Zero-based index of the chunk within its document.", + "example": 3 + }, + "metadata": { + "type": "object", + "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.", + "additionalProperties": true, + "example": { + "category": "billing", + "priority": 2 + } + }, + "similarity": { + "type": "number", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", + "example": 0.8423 + } + } + }, + "SearchEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "properties": { + "results": { + "type": "array", + "description": "The matching chunks, ordered by relevance.", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "query": { + "type": "string", + "description": "The query that was executed (empty string for tag-only search).", + "example": "How do I reset my password?" + }, + "knowledgeBaseIds": { + "type": "array", + "description": "The knowledge base IDs that were searched.", + "items": { + "type": "string" + }, + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "topK": { + "type": "integer", + "description": "The maximum number of results requested.", + "example": 10 + }, + "totalResults": { + "type": "integer", + "description": "The number of results returned.", + "example": 4 + } + } + } + } + }, + "DeleteEnvelope": { + "type": "object", + "description": "Delete acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The id of the resource that was deleted.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "deleted": { + "type": "boolean", + "description": "Always true.", + "enum": [true], + "example": true + } + } + } + } + }, + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "USAGE_LIMIT_EXCEEDED", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + }, + "KnowledgeFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "workspaceId query parameter is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have access to the requested workspace or resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource (for example, a resource with the same name already exists).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Resource already exists" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits. Upgrade the plan to continue.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request payload exceeds the allowed size, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The uploaded file's MIME type or extension is not supported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Unsupported file type" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json new file mode 100644 index 00000000000..0fd9b86657c --- /dev/null +++ b/apps/docs/openapi-v2-logs.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Logs", + "description": "Version 2 of the Sim API for workflow execution logs. v2 standardizes every response on a single envelope: a single resource returns `{ data }`, a list returns `{ data, nextCursor }`, and an error returns `{ error: { code, message, details? } }`. Lists use opaque cursor pagination (`limit` + `cursor` in, `nextCursor` out). Rate-limit state is carried in the `X-RateLimit-*` response headers rather than the body. Authenticate every request with the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Logs", + "description": "Query workflow execution logs, retrieve a single log entry, and fetch the full execution state snapshot for a run." + } + ], + "paths": { + "/api/v2/logs": { + "get": { + "operationId": "listLogs", + "summary": "List Logs", + "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "workflowIds", + "in": "query", + "description": "Comma-separated list of workflow IDs to filter by. Only logs from these workflows are returned.", + "schema": { + "type": "string" + }, + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + { + "name": "folderPaths", + "in": "query", + "description": "Comma-separated list of folder paths. Returns logs for all workflows within these folders.", + "schema": { + "type": "string" + } + }, + { + "name": "triggers", + "in": "query", + "description": "Comma-separated trigger types to filter by (e.g. api, webhook, schedule, manual, chat).", + "schema": { + "type": "string" + }, + "example": "api,schedule" + }, + { + "name": "level", + "in": "query", + "description": "Filter logs by severity level. info for successful executions, error for failed ones.", + "schema": { + "type": "string", + "enum": ["info", "error"] + } + }, + { + "name": "startDate", + "in": "query", + "description": "Only return logs started at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "description": "Only return logs started at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "executionId", + "in": "query", + "description": "Filter by an exact execution ID. Useful for looking up a specific run.", + "schema": { + "type": "string" + } + }, + { + "name": "minDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at least this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "maxDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at most this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "minCost", + "in": "query", + "description": "Only return logs where execution cost was at least this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "maxCost", + "in": "query", + "description": "Only return logs where execution cost was at most this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "model", + "in": "query", + "description": "Filter by the AI model used during execution (e.g., gpt-4o, claude-sonnet-4-20250514).", + "schema": { + "type": "string" + } + }, + { + "name": "details", + "in": "query", + "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.", + "schema": { + "type": "string", + "enum": ["basic", "full"], + "default": "basic" + } + }, + { + "name": "includeTraceSpans", + "in": "query", + "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "includeFinalOutput", + "in": "query", + "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of log entries to return per page. Values are clamped to the range 1–1000.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque pagination cursor returned from a previous request's nextCursor field. Omit to fetch the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "description": "Sort order by execution start time. desc returns newest first.", + "schema": { + "type": "string", + "enum": ["desc", "asc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of execution logs matching the filter criteria.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Log entries for the current page.", + "items": { + "$ref": "#/components/schemas/LogListItem" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for fetching the next page. null when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + }, + "files": null + } + ], + "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwLjAwMFoiLCJpZCI6ImxvZ183eDh5OXowYTFiIn0=" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/{id}": { + "get": { + "operationId": "getLog", + "summary": "Get Log", + "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the log entry.", + "schema": { + "type": "string", + "example": "log_7x8y9z0a1b" + } + } + ], + "responses": { + "200": { + "description": "The requested log entry with full execution data and cost summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/LogDetail" + } + } + }, + "example": { + "data": { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "files": null, + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderPath": "/", + "userId": "usr_1a2b3c4d5e", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "createdAt": "2025-01-10T09:00:00.000Z", + "updatedAt": "2025-06-18T16:45:00.000Z", + "deleted": false + }, + "executionData": { + "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + } + }, + "cost": { + "total": 0.0032 + }, + "createdAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/executions/{executionId}": { + "get": { + "operationId": "getExecution", + "summary": "Get Execution", + "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique execution identifier.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "The full execution state snapshot with workflow state and metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Execution" + } + } + }, + "example": { + "data": { + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowState": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + }, + "executionMetadata": { + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace whose logs to query." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum number of requests allowed in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "Cost": { + "type": ["object", "null"], + "description": "Aggregate execution cost in USD. null when no cost was recorded for the run.", + "required": ["total"], + "properties": { + "total": { + "type": "number", + "description": "Total cost of the execution in USD.", + "example": 0.0032 + } + } + }, + "LogWorkflowSummary": { + "type": "object", + "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.", + "required": ["id", "name", "description", "deleted"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogWorkflowDetail": { + "type": "object", + "description": "Full workflow metadata captured at execution time.", + "required": [ + "id", + "name", + "description", + "folderPath", + "userId", + "workspaceId", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" + }, + "userId": { + "type": ["string", "null"], + "description": "The user that owns the workflow. null if the workflow is gone.", + "example": "usr_1a2b3c4d5e" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace the workflow belongs to. null if the workflow is gone.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created. null if the workflow is gone.", + "example": "2025-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified. null if the workflow is gone.", + "example": "2025-06-18T16:45:00.000Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogListItem": { + "type": "object", + "description": "Summary of a single workflow execution log entry returned by the list endpoint.", + "required": [ + "id", + "workflowId", + "executionId", + "deploymentVersionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "cost", + "files" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment.", + "example": "dep_2c4e6a8b0d1f" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "allOf": [ + { + "$ref": "#/components/schemas/LogWorkflowSummary" + } + ], + "description": "Workflow summary. Present only when details=full." + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true." + }, + "traceSpans": { + "type": "array", + "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.", + "required": [ + "id", + "workflowId", + "executionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "files", + "workflow", + "executionData", + "cost", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "$ref": "#/components/schemas/LogWorkflowDetail" + }, + "executionData": { + "type": "object", + "additionalProperties": true, + "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the log entry was recorded.", + "example": "2026-01-15T10:30:00.000Z" + } + } + }, + "Execution": { + "type": "object", + "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", + "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier for this execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "workflowState": { + "type": "object", + "additionalProperties": true, + "description": "Snapshot of the workflow configuration at the time of execution.", + "properties": { + "blocks": { + "type": "object", + "additionalProperties": true, + "description": "Map of block IDs to their configuration and state during execution." + }, + "edges": { + "type": "array", + "description": "Connections between blocks defining the execution flow.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "loops": { + "type": "object", + "additionalProperties": true, + "description": "Loop configurations defining iterative execution patterns." + }, + "parallels": { + "type": "object", + "additionalProperties": true, + "description": "Parallel execution group configurations." + } + } + }, + "executionMetadata": { + "type": "object", + "description": "Metadata about the execution including trigger, timing, and cost.", + "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], + "properties": { + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + } + } + } + } + }, + "Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code (e.g., BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR).", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "Log not found" + }, + "details": { + "description": "Optional structured details about the error (e.g., field-level validation issues or rate-limit reset info). Present only on some errors." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Inspect error.details for field-level validation issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "Forbidden": { + "description": "The API key is authenticated but not authorized for the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "API key is not authorized for this workspace" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. An authorization failure on a single resource is also reported as 404 so resource existence is not leaked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Log not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json new file mode 100644 index 00000000000..be371e32bcc --- /dev/null +++ b/apps/docs/openapi-v2-resources.json @@ -0,0 +1,3164 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workspace Resources", + "description": "The v2 Workspace Resources API covers the resources a workspace is provisioned with: MCP servers, skills, custom tools, folders, and credentials.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.\n- **Secrets are write-only** — Fields that carry secret material (MCP request headers, credential values) are accepted on write and never returned on read. Reads expose only whether a secret is configured, and for headers their names.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "MCP Servers", + "description": "Register and manage the Model Context Protocol servers a workspace connects to (v2 API)." + }, + { + "name": "Skills", + "description": "Create and manage the reusable instruction documents agents can be given (v2 API)." + }, + { + "name": "Custom Tools", + "description": "Create and manage the workspace's own code-backed tools that agents can call (v2 API)." + }, + { + "name": "Credentials", + "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/mcp-servers": { + "get": { + "operationId": "listMcpServers", + "summary": "List MCP Servers", + "description": "List the MCP servers registered in a workspace. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`; treat the response as a standard cursor list so pagination can be added later without a contract change.\n\nConfigured request header **values** are never returned — use `hasHeaders` and `headerNames` to see which headers are set.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "MCP servers registered in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The MCP servers registered in the workspace.", + "items": { + "$ref": "#/components/schemas/McpServer" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + }, + "example": { + "data": [ + { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2025-06-20T14:02:11.000Z", + "lastConnected": "2025-06-20T14:02:11.000Z", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createMcpServer", + "summary": "Create MCP Server", + "description": "Register a new MCP server in a workspace. Requires `write` permission on the workspace.\n\nA server's identity is derived from its URL, so registering a URL that is already registered returns `409 CONFLICT` rather than overwriting the existing server — use `PATCH /api/v2/mcp-servers/{id}` to change one.\n\nThe `url` must be an absolute `http`/`https` URL and may not contain `{{ENV_VAR}}` references: templated hostnames defer domain-allowlist and SSRF checks to call time, which is not safe to accept over an API key.\n\n`headers` and `oauthClientSecret` are write-only and are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/mcp-servers\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Docs server\",\n \"url\": \"https://mcp.example.com/sse\",\n \"headers\": { \"Authorization\": \"Bearer YOUR_TOKEN\" }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The MCP server to register.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMcpServerBody" + }, + "examples": { + "headerAuth": { + "summary": "Header-authenticated server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "description": "Internal documentation tools", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + }, + "timeout": 30000, + "retries": 3 + } + }, + "oauth": { + "summary": "OAuth server with pre-registered client credentials", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Partner server", + "url": "https://mcp.partner.example.com/mcp", + "authType": "oauth", + "oauthClientId": "sim-client", + "oauthClientSecret": "YOUR_CLIENT_SECRET" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The MCP server was registered.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/mcp-servers/{id}": { + "get": { + "operationId": "getMcpServer", + "summary": "Get MCP Server", + "description": "Fetch a single MCP server by id. Configured request header values and the OAuth client secret are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/McpServerId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The MCP server.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateMcpServer", + "summary": "Update MCP Server", + "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\n`url` is immutable: a server's id is derived from its URL, so re-pointing it would leave the id hashing an address the server no longer uses and allow two servers on one URL. Sending a different `url` returns `400` — delete the server and create one at the new address. Sending the URL it already has is accepted, so a full-object PATCH still works.\n\nChanging the auth type or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/McpServerId" + } + ], + "requestBody": { + "required": true, + "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMcpServerBody" + }, + "examples": { + "disable": { + "summary": "Disable a server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false + } + }, + "rotateHeaders": { + "summary": "Rotate the auth header", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "headers": { + "Authorization": "Bearer NEW_TOKEN" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated MCP server.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteMcpServer", + "summary": "Delete MCP Server", + "description": "Remove an MCP server from the workspace and revoke any OAuth tokens issued for it. Requires `write` permission on the workspace. Workflows that referenced the server's tools keep their blocks but can no longer call it.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/McpServerId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The MCP server was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "mcp-3f7a9c21", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/skills": { + "get": { + "operationId": "listSkills", + "summary": "List Skills", + "description": "List the skills available in a workspace. Built-in template skills that ship with Sim are included and are marked `readOnly: true`.\n\nSkill bodies can be up to 50 000 characters, so the list returns summaries only — fetch `GET /api/v2/skills/{id}` for a skill's `content`. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. Built-in skills have no stored timestamps and sort as if created at the Unix epoch.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Skills available in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The skills available in the workspace, without their bodies.", + "items": { + "$ref": "#/components/schemas/SkillSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + }, + "example": { + "data": [ + { + "id": "deploy-workflow", + "name": "deploy-workflow", + "description": "How to deploy a finished workflow", + "readOnly": true, + "createdAt": "1970-01-01T00:00:00.000Z", + "updatedAt": "1970-01-01T00:00:00.000Z" + }, + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createSkill", + "summary": "Create Skill", + "description": "Create a skill in a workspace. Requires `write` permission on the workspace, and the creator becomes an editor of the new skill.\n\n`name` must be kebab-case and unique in the workspace; names reserved by built-in skills are rejected. Unlike the internal endpoint this creates exactly one skill and answers with it, not with the whole workspace list.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/skills\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"refund-policy\",\n \"description\": \"How support should handle refund requests\",\n \"content\": \"# Refund policy\\n\\nAlways check the order date first.\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The skill to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSkillBody" + }, + "examples": { + "refundPolicy": { + "summary": "A support playbook", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The skill was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillData" + }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/skills/{id}": { + "get": { + "operationId": "getSkill", + "summary": "Get Skill", + "description": "Fetch a single skill by id, including its full `content`. Built-in template skills resolve here too and are marked `readOnly: true`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/SkillId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The skill.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillData" + }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateSkill", + "summary": "Update Skill", + "description": "Update a skill. Only the fields you send are changed, so a partial edit never clobbers a concurrent change to a field you did not send.\n\nRequires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/SkillId" + } + ], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `description`, or `content` is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSkillBody" + }, + "examples": { + "editDescription": { + "summary": "Change the description only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + }, + "replaceContent": { + "summary": "Replace the skill body", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "# Refund policy\n\nCheck the order date, then the payment method." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated skill.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillData" + }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteSkill", + "summary": "Delete Skill", + "description": "Delete a skill from the workspace. Requires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/SkillId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The skill was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/custom-tools": { + "get": { + "operationId": "listCustomTools", + "summary": "List Custom Tools", + "description": "List the custom tools defined in a workspace. Custom tools are code-backed functions agents can call, declared with an OpenAI-style function schema.\n\nOnly workspace tools are returned — legacy personal tools, which predate workspace scoping and belong to a single user, are not part of the public API. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["title", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Custom tools defined in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The custom tools defined in the workspace.", + "items": { + "$ref": "#/components/schemas/CustomTool" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + }, + "example": { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createCustomTool", + "summary": "Create Custom Tool", + "description": "Create a custom tool in a workspace. Requires `write` permission on the workspace.\n\n`title` must be unique within the workspace — tools resolve by title at call time, so a duplicate returns `409 CONFLICT`. `code` is the tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/custom-tools\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"title\": \"lookup_order\",\n \"schema\": {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"lookup_order\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": { \"orderId\": { \"type\": \"string\" } },\n \"required\": [\"orderId\"]\n }\n }\n },\n \"code\": \"return { ok: true }\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The custom tool to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomToolBody" + }, + "examples": { + "lookupOrder": { + "summary": "A tool that calls an internal API", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The custom tool was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/custom-tools/{id}": { + "get": { + "operationId": "getCustomTool", + "summary": "Get Custom Tool", + "description": "Fetch a single custom tool by id, scoped to the workspace.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/CustomToolId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The custom tool.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateCustomTool", + "summary": "Update Custom Tool", + "description": "Update a custom tool. Only the fields you send are changed; omitted fields keep their stored values. Requires `write` permission on the workspace.\n\nRenaming onto a title another tool already uses returns `409 CONFLICT`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/CustomToolId" + } + ], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCustomToolBody" + }, + "examples": { + "editCode": { + "summary": "Replace the implementation only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + }, + "rename": { + "summary": "Rename the tool", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "find_order" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated custom tool.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteCustomTool", + "summary": "Delete Custom Tool", + "description": "Delete a custom tool from the workspace. Requires `write` permission on the workspace. Agent blocks that referenced the tool keep their configuration but can no longer call it.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/CustomToolId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The custom tool was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/credentials": { + "get": { + "operationId": "listCredentials", + "summary": "List Credentials", + "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "type", + "in": "query", + "required": false, + "description": "Only return credentials of this kind.", + "schema": { + "type": "string", + "enum": ["oauth", "env_workspace", "env_personal", "service_account"] + } + }, + { + "name": "providerId", + "in": "query", + "required": false, + "description": "Only return credentials for this integration.", + "schema": { + "type": "string", + "minLength": 1, + "example": "slack" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["displayName", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Credentials visible to the caller in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The credentials visible to the caller.", + "items": { + "$ref": "#/components/schemas/Credential" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createCredential", + "summary": "Create Credential", + "description": "Create a workspace credential. Requires `write` permission on the workspace; the creator becomes an admin of the credential.\n\n`oauth` credentials **cannot** be created here — they are minted by the interactive OAuth connect flow and bound to an account you authorized in a browser. The creatable types are:\n\n- `env_workspace` — a secret stored under `envKey`, available to everyone in the workspace.\n- `env_personal` — the same, scoped to you.\n- `service_account` — a provider secret (`serviceAccountJson`, `apiToken` + `domain`, `clientId` + `clientSecret` + `orgId`, …). The secret is verified against the provider before it is stored.\n\nEvery secret field is write-only and is never returned. Creation is idempotent on the credential's source (the account, the env key, or the provider + name), so re-issuing the same create returns the existing credential rather than a duplicate.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/credentials\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"type\": \"env_workspace\",\n \"envKey\": \"STRIPE_API_KEY\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The credential to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialBody" + }, + "examples": { + "workspaceEnvVar": { + "summary": "A workspace-wide environment secret", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "env_workspace", + "envKey": "STRIPE_API_KEY" + } + }, + "clientCredentialServiceAccount": { + "summary": "A client-credentials service account", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The credential exists with this source. Returned whether it was inserted now or already present.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "env_workspace", + "displayName": "STRIPE_API_KEY", + "description": null, + "providerId": null, + "accountId": null, + "envKey": "STRIPE_API_KEY", + "hasServiceAccountKey": false, + "role": "admin", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/credentials/{id}": { + "get": { + "operationId": "getCredential", + "summary": "Get Credential", + "description": "Fetch a single credential. Secret material is never returned — `hasServiceAccountKey` tells you whether one is stored.\n\nA credential you have no grant on answers `404`, not `403`, so its existence is never disclosed to someone who cannot use it.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/CredentialId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The credential.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateCredential", + "summary": "Update Credential", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin — access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/CredentialId" + } + ], + "requestBody": { + "required": true, + "description": "The fields to change. At least one field besides `workspaceId` is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCredentialBody" + }, + "examples": { + "rename": { + "summary": "Rename a credential", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "displayName": "Zoom (production)" + } + }, + "rotateSecret": { + "summary": "Rotate an API token", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "apiToken": "YOUR_NEW_TOKEN" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated credential.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom (production)", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteCredential", + "summary": "Delete Credential", + "description": "Delete a credential. Requires credential admin — access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/CredentialId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The credential was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "McpServerId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the MCP server.", + "schema": { + "type": "string", + "minLength": 1, + "example": "mcp-3f7a9c21" + } + }, + "SkillId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the skill. Built-in skills use their name as their id.", + "schema": { + "type": "string", + "minLength": 1, + "example": "V1StGXR8Z5jdHi6BmyT" + } + }, + "CustomToolId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the custom tool.", + "schema": { + "type": "string", + "minLength": 1, + "example": "V1StGXR8Z5jdHi6BmyT" + } + }, + "FolderId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the folder.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "CredentialId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the credential.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Workspace ID is required", + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have the required permission on the workspace, or the URL was rejected by the server's MCP domain policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "MCP server not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the workspace — for example a resource with the same identity already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "An MCP server with this URL already exists in this workspace." + } + } + } + } + }, + "Locked": { + "description": "A mutation lock on the resource (or something inside it) blocks the change. Unlock it and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "This folder is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "An upstream provider could not be reached to verify the request. Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "The credential provider is unavailable. Try again." + } + } + } + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR", + "SERVICE_UNAVAILABLE" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + }, + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "DeleteAcknowledgement": { + "type": "object", + "description": "Acknowledgement that a resource was deleted.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The identifier of the resource that was deleted." + }, + "deleted": { + "type": "boolean", + "const": true + } + } + } + } + }, + "McpServer": { + "type": "object", + "description": "An MCP server registered in a workspace. Request header values and the OAuth client secret are write-only and never appear here.", + "required": [ + "id", + "name", + "transport", + "enabled", + "createdAt", + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" + ], + "properties": { + "id": { + "type": "string", + "description": "The server's unique identifier, derived from the workspace and the server URL." + }, + "name": { + "type": "string", + "description": "Display name of the server." + }, + "description": { + "type": "string", + "description": "Optional description." + }, + "transport": { + "type": "string", + "enum": ["streamable-http"], + "description": "Transport used to talk to the server." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"], + "description": "How Sim authenticates to the server." + }, + "url": { + "type": "string", + "description": "The server's endpoint URL." + }, + "timeout": { + "type": "number", + "description": "Per-request timeout in milliseconds." + }, + "retries": { + "type": "number", + "description": "Number of retries per request." + }, + "enabled": { + "type": "boolean", + "description": "Whether the server's tools are available to workflows." + }, + "connectionStatus": { + "type": "string", + "enum": ["connected", "disconnected", "error"], + "description": "Result of the most recent connection attempt." + }, + "lastError": { + "type": ["string", "null"], + "description": "Message from the most recent failed connection, if any." + }, + "toolCount": { + "type": "number", + "description": "Number of tools discovered on the server." + }, + "lastToolsRefresh": { + "type": "string", + "format": "date-time", + "description": "When the server's tool list was last refreshed." + }, + "lastConnected": { + "type": "string", + "format": "date-time", + "description": "When Sim last connected successfully." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "oauthClientId": { + "type": "string", + "description": "Pre-registered OAuth client id, when the server does not support dynamic client registration." + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured. Values are never returned." + }, + "headerNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of the configured request headers. Values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored for this server." + } + } + }, + "McpServerData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["mcpServer"], + "properties": { + "mcpServer": { + "$ref": "#/components/schemas/McpServer" + } + } + } + } + }, + "CreateMcpServerBody": { + "type": "object", + "description": "A new MCP server registration.", + "additionalProperties": false, + "required": ["workspaceId", "name", "url"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to register the server in." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name of the server." + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Optional description." + }, + "transport": { + "type": "string", + "enum": ["streamable-http"], + "description": "Transport used to talk to the server. Defaults to `streamable-http`." + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"], + "description": "How Sim should authenticate. Detected from the server when omitted." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Write-only. Request headers sent to the server, e.g. `Authorization`. Never returned on read." + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 300000, + "description": "Per-request timeout in milliseconds. Defaults to 30000." + }, + "retries": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "description": "Number of retries per request. Defaults to 3." + }, + "enabled": { + "type": "boolean", + "description": "Whether the server's tools are available to workflows. Defaults to true." + }, + "oauthClientId": { + "type": ["string", "null"], + "maxLength": 512, + "description": "Pre-registered OAuth client id for servers without dynamic client registration." + }, + "oauthClientSecret": { + "type": ["string", "null"], + "maxLength": 2048, + "description": "Write-only. Pre-registered OAuth client secret. Never returned on read." + } + } + }, + "UpdateMcpServerBody": { + "type": "object", + "description": "Fields to change on an existing MCP server. Omitted fields are left as they are.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the server." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "transport": { + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Write-only. Replaces the stored header map wholesale." + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "type": "boolean" + }, + "oauthClientId": { + "type": ["string", "null"], + "maxLength": 512 + }, + "oauthClientSecret": { + "type": ["string", "null"], + "maxLength": 2048, + "description": "Write-only. Never returned on read." + } + } + }, + "SkillSummary": { + "type": "object", + "description": "A skill without its body. Fetch the skill by id to read `content`.", + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "The skill's unique identifier." + }, + "name": { + "type": "string", + "description": "Kebab-case name, unique within the workspace. This is what agents reference." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "Skill": { + "type": "object", + "description": "A skill, including its full body.", + "required": ["id", "name", "description", "content", "readOnly", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "The skill's unique identifier." + }, + "name": { + "type": "string", + "description": "Kebab-case name, unique within the workspace. This is what agents reference." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "description": "The skill body — the instructions handed to the agent." + }, + "readOnly": { + "type": "boolean", + "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SkillData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["skill"], + "properties": { + "skill": { + "$ref": "#/components/schemas/Skill" + } + } + } + } + }, + "CreateSkillBody": { + "type": "object", + "description": "A new skill.", + "additionalProperties": false, + "required": ["workspaceId", "name", "description", "content"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the skill in." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace. Names reserved by built-in skills are rejected." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The skill body — the instructions handed to the agent." + } + } + }, + "UpdateSkillBody": { + "type": "object", + "description": "Fields to change on an existing skill. At least one of `name`, `description`, or `content` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the skill." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000 + } + } + }, + "CustomToolSchema": { + "type": "object", + "description": "OpenAI-style function declaration describing the tool's callable surface. The parameter properties are caller-defined, so the shape below the function level is open.", + "required": ["type", "function"], + "properties": { + "type": { + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "required": ["name", "parameters"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The function name the model calls." + }, + "description": { + "type": "string", + "description": "What the tool does, shown to the model." + }, + "parameters": { + "type": "object", + "description": "JSON Schema for the tool's arguments.", + "required": ["type", "properties"], + "properties": { + "type": { + "type": "string", + "description": "Usually `object`." + }, + "properties": { + "type": "object", + "additionalProperties": true, + "description": "Caller-defined argument schemas, keyed by argument name." + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of the required arguments." + } + } + } + } + } + } + }, + "CustomTool": { + "type": "object", + "description": "A code-backed tool defined in a workspace that agents can call.", + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "The tool's unique identifier." + }, + "title": { + "type": "string", + "description": "Display title, unique within the workspace. Tools also resolve by title at call time." + }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, + "code": { + "type": "string", + "description": "The tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CustomToolData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["customTool"], + "properties": { + "customTool": { + "$ref": "#/components/schemas/CustomTool" + } + } + } + } + }, + "CreateCustomToolBody": { + "type": "object", + "description": "A new custom tool.", + "additionalProperties": false, + "required": ["workspaceId", "title", "schema", "code"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the tool in." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "The tool body, executed in Sim's sandboxed function runtime." + } + } + }, + "UpdateCustomToolBody": { + "type": "object", + "description": "Fields to change on an existing custom tool. At least one of `title`, `schema`, or `code` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, + "code": { + "type": "string", + "maxLength": 100000 + } + } + }, + "Credential": { + "type": "object", + "description": "A stored credential. Secret material is write-only and never appears here.", + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "envKey", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "The credential's unique identifier." + }, + "type": { + "type": "string", + "enum": ["oauth", "env_workspace", "env_personal", "service_account"], + "description": "What kind of credential this is." + }, + "displayName": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": ["string", "null"] + }, + "providerId": { + "type": ["string", "null"], + "description": "The integration this credential authenticates against, when it has one." + }, + "accountId": { + "type": ["string", "null"], + "description": "The linked OAuth account, for `oauth` credentials." + }, + "envKey": { + "type": ["string", "null"], + "description": "The environment-variable name, for `env_workspace` / `env_personal` credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account secret is stored. The secret itself is never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "The caller's role on this credential. Only admins can update or delete it." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CredentialData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["credential"], + "properties": { + "credential": { + "$ref": "#/components/schemas/Credential" + } + } + } + } + }, + "CreateCredentialBody": { + "type": "object", + "description": "A new credential. Every secret field is write-only and is never returned.", + "additionalProperties": false, + "required": ["workspaceId", "type"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the credential in." + }, + "type": { + "type": "string", + "enum": ["env_workspace", "env_personal", "service_account"], + "description": "`oauth` is not creatable here — use the interactive OAuth connect flow." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name. Derived from the env key or the verified provider account when omitted." + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "providerId": { + "type": "string", + "minLength": 1, + "description": "Required for `service_account` — the integration the secret belongs to." + }, + "envKey": { + "type": "string", + "minLength": 1, + "description": "Required for env credentials. Letters, numbers, and underscores only; `{{NAME}}` is accepted and unwrapped." + }, + "serviceAccountJson": { + "type": "string", + "minLength": 1, + "description": "Write-only. Google-style service-account JSON key." + }, + "signingSecret": { + "type": "string", + "minLength": 1, + "description": "Write-only. Slack custom-bot signing secret." + }, + "botToken": { + "type": "string", + "minLength": 1, + "description": "Write-only. Slack custom-bot token." + }, + "apiToken": { + "type": "string", + "minLength": 1, + "description": "Write-only. Atlassian API token." + }, + "domain": { + "type": "string", + "minLength": 1, + "description": "Atlassian site domain, paired with `apiToken`." + }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Write-only. Client-credentials secret." + }, + "orgId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Optional provider region selector, such as a Zoho Desk data center." + } + } + }, + "UpdateCredentialBody": { + "type": "object", + "description": "Fields to change on an existing credential. At least one field besides `workspaceId` is required. Sending a secret field rotates that secret in place; secrets are never returned.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the credential." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": ["string", "null"], + "maxLength": 500, + "description": "Pass null to clear the description." + }, + "serviceAccountJson": { + "type": "string", + "minLength": 1, + "description": "Write-only. Replaces the stored service-account JSON key." + }, + "signingSecret": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "botToken": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "apiToken": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "domain": { + "type": "string", + "minLength": 1 + }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Write-only." + }, + "orgId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Optional provider region selector, such as a Zoho Desk data center." + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 15757b19f1e..5520ec9c1e1 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2,378 +2,6538 @@ "openapi": "3.1.0", "info": { "title": "Sim Tables API v2", - "version": "2.0.0-preview", - "description": "Read access to Sim tables with the typed predicate filter grammar and opaque cursor pagination. This surface is feature-gated (`tables-v2-api`): when the flag is off for the caller, every endpoint returns 404 as if it does not exist. Filters are predicate trees — `{\"all\": [...]}` (AND) or `{\"any\": [...]}` (OR) groups whose members are `{field, op, value}` conditions or nested groups. Built-in columns `id`, `createdAt`, and `updatedAt` (camelCase) are filterable and sortable alongside user columns." + "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always keyed by column name.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } }, - "servers": [{ "url": "https://www.sim.ai" }], - "security": [{ "apiKey": [] }], + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Tables", + "description": "Manage tables, columns, and rows for structured data storage (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], "paths": { "/api/v2/tables": { "get": { - "operationId": "v2ListTables", + "operationId": "listTables", "summary": "List Tables", - "description": "List every table in a workspace with its column schema and row count.", - "tags": ["Tables v2"], + "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], "parameters": [ { - "name": "workspaceId", + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderPath", "in": "query", - "required": true, - "schema": { "type": "string", "minLength": 1 } + "required": false, + "description": "Restrict the list to one folder. Omit to list every table in the workspace.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" } ], "responses": { "200": { - "description": "Tables in the workspace. Served with `Cache-Control: private, no-store`.", + "description": "The tables in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { - "type": "object", - "required": ["success", "data"], - "properties": { - "success": { "const": true }, - "data": { - "type": "object", - "required": ["tables", "totalCount"], - "properties": { - "tables": { - "type": "array", - "items": { "$ref": "#/components/schemas/TableSummary" } - }, - "totalCount": { "type": "integer" } - } - } - } + "$ref": "#/components/schemas/TableListEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTable", + "summary": "Create Table", + "description": "Create a new table with a typed column schema. The schema must contain between 1 and 50 columns.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"contacts\",\n \"description\": \"Customer contacts\",\n \"schema\": {\n \"columns\": [\n { \"name\": \"email\", \"type\": \"string\", \"required\": true, \"unique\": true },\n { \"name\": \"name\", \"type\": \"string\", \"required\": true },\n { \"name\": \"age\", \"type\": \"number\" }\n ]\n }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The table name, optional description, column schema, and target workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTableBody" + } + } + } + }, + "responses": { + "201": { + "description": "The table was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" } } } }, - "400": { "$ref": "#/components/responses/ValidationError" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFoundOrGated" }, - "429": { "$ref": "#/components/responses/RateLimited" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, - "/api/v2/tables/{tableId}/query": { - "post": { - "operationId": "v2QueryTableRows", - "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`. `totalCount` is computed on the first page only (requests with a `cursor` return `totalCount: null`).", - "tags": ["Tables v2"], + "/api/v2/tables/{tableId}": { + "get": { + "operationId": "getTable", + "summary": "Get Table", + "description": "Get a single table's metadata and column schema.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], "parameters": [ { - "name": "tableId", - "in": "path", - "required": true, - "schema": { "type": "string", "minLength": 1 } + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTable", + "summary": "Delete Table", + "description": "Delete a table. Returns the id of the deleted 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 deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTable", + "summary": "Update Table", + "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderPath`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table deleted mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderPath\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"customers\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" } ], "requestBody": { "required": true, - "description": "Bodies over 1 MB are rejected with 413.", "content": { "application/json": { "schema": { - "type": "object", - "required": ["workspaceId"], - "properties": { - "workspaceId": { "type": "string", "minLength": 1 }, - "predicate": { "$ref": "#/components/schemas/Predicate" }, - "sort": { - "type": "array", - "maxItems": 16, - "description": "Ordered sort spec, highest priority first.", - "items": { - "type": "object", - "required": ["field", "direction"], - "properties": { - "field": { "type": "string" }, - "direction": { "enum": ["asc", "desc"] } - } - } - }, - "limit": { - "type": "integer", - "minimum": 0, - "maximum": 1000, - "default": 100, - "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." - }, - "cursor": { - "type": "string", - "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`." - } - } + "$ref": "#/components/schemas/UpdateTableBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" }, - "examples": { - "filtered": { - "summary": "Multi-select membership + negated pattern", - "value": { - "workspaceId": "ws_123", - "predicate": { - "all": [ - { "field": "Color", "op": "contains", "value": "Purple" }, - { "field": "name", "op": "nlike", "value": "G*" } - ] - }, - "limit": 100 - } - }, - "builtinColumns": { - "summary": "Built-in column range (UTC, timezone-independent)", - "value": { - "workspaceId": "ws_123", - "predicate": { - "all": [ - { "field": "createdAt", "op": "gte", "value": "2026-07-24T03:00:00.000Z" }, - { "field": "createdAt", "op": "lte", "value": "2026-07-25T02:59:59.999Z" } - ] - } - } + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns": { + "post": { + "operationId": "addTableColumn", + "summary": "Add Column", + "description": "Add a column to the table schema. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"phone\",\n \"type\": \"string\",\n \"required\": false,\n \"unique\": false\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the column definition to add.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddColumnBody" + } + } } }, "responses": { "200": { - "description": "A page of rows. Served with `Cache-Control: private, no-store`.", + "description": "The column was added.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { - "type": "object", - "required": ["success", "data"], - "properties": { - "success": { "const": true }, - "data": { - "type": "object", - "required": ["rows", "rowCount", "nextCursor"], - "properties": { - "rows": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "data", "createdAt", "updatedAt"], - "properties": { - "id": { "type": "string" }, - "data": { - "type": "object", - "description": "Column-NAME-keyed cell values.", - "additionalProperties": true - }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } - } - } - }, - "rowCount": { "type": "integer", "description": "Rows in THIS page." }, - "totalCount": { - "type": ["integer", "null"], - "description": "Rows matching the predicate across all pages. First page only; null when a cursor was supplied." - }, - "limit": { "type": ["integer", "null"] }, - "nextCursor": { - "type": ["string", "null"], - "description": "Non-null ⇒ more rows exist. The ONLY termination signal is null." - } + "$ref": "#/components/schemas/ColumnsEnvelope" + }, + "example": { + "data": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true, + "unique": false + }, + { + "id": "col_x9y8z7", + "name": "phone", + "type": "string", + "required": false, + "unique": false } - } + ] } } } } }, - "400": { - "description": "Validation failure. Machine-readable `code` values include `INVALID_FILTER` (unknown column, operator/type mismatch, malformed tree), `INVALID_ORDER`, `INVALID_CURSOR`, `CURSOR_SORT_CONFLICT`, and `TABLE_QUERY_RESULT_TOO_LARGE` (unbounded result exceeded the 5 MB budget).", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ErrorBody" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableColumn", + "summary": "Update Column", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the current column name, and the fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableColumn", + "summary": "Delete Column", + "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the name of the column to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows": { + "get": { + "operationId": "listTableRows", + "summary": "List rows", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" + } + ], + "responses": { + "200": { + "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": "eyJvZmZzZXQiOjUwfQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableRows", + "summary": "Create Rows", + "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Either a single-row payload or a batch payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsBody" + }, + "examples": { + "single": { + "summary": "Insert a single row", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + } + } + }, + "batch": { + "summary": "Insert multiple rows", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rows": [ + { + "email": "a@example.com", + "name": "Ada" + }, + { + "email": "b@example.com", + "name": "Babbage" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The row(s) were inserted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsResponse" + }, + "examples": { + "single": { + "summary": "Single insert response", + "value": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + }, + "batch": { + "summary": "Batch insert response", + "value": { + "data": { + "rows": [ + { + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "email": "a@example.com", + "name": "Ada" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "insertedCount": 2 + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateTableRows", + "summary": "Update Rows by Filter", + "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsByFilterBody" + } + } + } + }, + "responses": { + "200": { + "description": "The matching rows were updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsEnvelope" + }, + "example": { + "data": { + "updatedCount": 3, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRows", + "summary": "Delete Rows", + "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and either a non-empty filter or an explicit list of row ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsBody" + }, + "examples": { + "byIds": { + "summary": "Delete specific rows by id", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + }, + "byFilter": { + "summary": "Delete rows matching a filter", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "filter": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "archived" + } + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The rows were deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsEnvelope" + }, + "examples": { + "byIds": { + "summary": "Id-based delete response", + "value": { + "data": { + "deletedCount": 2, + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ], + "requestedCount": 2, + "missingRowIds": [] + } + } + }, + "byFilter": { + "summary": "Filter-based delete response", + "value": { + "data": { + "deletedCount": 5, + "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}": { + "get": { + "operationId": "getTableRow", + "summary": "Get Row", + "description": "Get a single row by id.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested row.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableRow", + "summary": "Update Row", + "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the partial row data to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRow", + "summary": "Delete Row", + "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The row was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowEnvelope" + }, + "example": { + "data": { + "deletedCount": 1, + "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/upsert": { + "post": { + "operationId": "upsertTableRow", + "summary": "Upsert Row", + "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was inserted or updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowEnvelope" + }, + "example": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "John" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + "operation": "insert" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/query": { + "post": { + "operationId": "queryTableRows", + "summary": "Query Rows", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Bodies over 1 MB are rejected with 413.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "predicate": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { + "type": "string" + }, + "direction": { + "enum": ["asc", "desc"] + } + } + } + }, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 100, + "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + }, + "cursor": { + "type": "string", + "description": "Opaque token from a previous response's `nextCursor`. Pass back verbatim. Mutually exclusive with `sort`." + } + } + }, + "examples": { + "filtered": { + "summary": "Multi-select membership + negated pattern", + "value": { + "workspaceId": "ws_123", + "predicate": { + "all": [ + { + "field": "Color", + "op": "contains", + "value": "Purple" + }, + { + "field": "name", + "op": "nlike", + "value": "G*" + } + ] + }, + "limit": 100 + } + }, + "builtinColumns": { + "summary": "Built-in column range (UTC, timezone-independent)", + "value": { + "workspaceId": "ws_123", + "predicate": { + "all": [ + { + "field": "createdAt", + "op": "gte", + "value": "2026-07-24T03:00:00.000Z" + }, + { + "field": "createdAt", + "op": "lte", + "value": "2026-07-25T02:59:59.999Z" + } + ] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A page of matching rows. Served with `Cache-Control: private, no-store`.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "name": "Jane Doe", + "status": "active" + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "description": "Request body exceeds the 1 MB limit." + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/views": { + "get": { + "operationId": "listTableViews", + "summary": "List Views", + "description": "Every saved view on the table, oldest first. A table carries a bounded set of views, so this is a single full page and `nextCursor` is always null. References to columns that no longer exist are pruned from each config on read.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table’s saved views.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewListEnvelope" + }, + "example": { + "data": [ + { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableView", + "summary": "Create View", + "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary — rows it hides stay readable through the row and query endpoints.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"Active customers\",\"config\":{}}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateViewBody" + }, + "example": { + "workspaceId": "ws_123", + "name": "Active customers", + "config": { + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + } + } + } + } + }, + "responses": { + "201": { + "description": "The created view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/views/{viewId}": { + "get": { + "operationId": "getTableView", + "summary": "Get View", + "description": "One saved view, with references to deleted columns pruned from its config.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableView", + "summary": "Update View", + "description": "Rename a view, replace or merge its config, or promote it to the table’s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table’s existing default in the same transaction.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"isDefault\":true}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateViewBody" + }, + "examples": { + "promote": { + "summary": "Make this the table’s default view", + "value": { + "workspaceId": "ws_123", + "isDefault": true + } + }, + "replaceConfig": { + "summary": "Replace the saved filter", + "value": { + "workspaceId": "ws_123", + "config": { + "filter": { + "any": [ + { + "field": "col_a1b2c3", + "op": "isNotEmpty" + } + ] + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableView", + "summary": "Delete View", + "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The view was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteViewEnvelope" + }, + "example": { + "data": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/groups": { + "get": { + "operationId": "listTableWorkflowGroups", + "summary": "List Workflow Groups", + "description": "The table’s workflow and enrichment groups — the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table’s workflow groups.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupListEnvelope" + }, + "example": { + "data": [ + { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "dependencies": { + "columns": ["col_a1b2c3"] + }, + "outputs": [ + { + "blockId": "blk_agent1", + "path": "content", + "columnName": "summary" + } + ], + "deploymentMode": "deployed", + "autoRun": true + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "addTableWorkflowGroup", + "summary": "Add Workflow Group", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"group\": {\n \"workflowId\": \"wf_...\",\n \"outputs\": [{\"blockId\": \"blk_7f2a\", \"path\": \"output.revenue\", \"columnName\": \"revenue\"}]\n },\n \"outputColumns\": [{\"name\": \"revenue\", \"type\": \"currency\"}]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWorkflowGroupBody" + }, + "examples": { + "workflow": { + "summary": "Workflow-backed column", + "value": { + "workspaceId": "ws_123", + "group": { + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "currency" + } + ] + } + }, + "enrichment": { + "summary": "Registry enrichment filling two columns", + "value": { + "workspaceId": "ws_123", + "group": { + "type": "enrichment", + "enrichmentId": "company_lookup", + "outputs": [ + { + "outputId": "annual_revenue", + "columnName": "revenue" + }, + { + "outputId": "headquarters", + "columnName": "hq" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "currency" + }, + { + "name": "hq", + "type": "string" + } + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created group and the table's columns.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupEnvelope" + }, + "example": { + "data": { + "group": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ], + "deploymentMode": "deployed", + "autoRun": true + }, + "columns": [ + { + "id": "col_a1b2c3", + "name": "revenue", + "type": "currency", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableWorkflowGroup", + "summary": "Update Workflow Group", + "description": "Restructure a group: re-point it at a different workflow, add or remove outputs, or change how its runs are scheduled.\n\n**Removing an output deletes that column and its values.** There is currently no way to detach a column from its group while keeping the data.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\", \"name\": \"Renamed\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowGroupBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "name": "Renamed" + } + }, + "addOutput": { + "summary": "Add a second output column", + "value": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + }, + { + "blockId": "blk_7f2a", + "path": "output.hq", + "columnName": "hq" + } + ], + "newOutputColumns": [ + { + "name": "hq", + "type": "string" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated group and the table's columns.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupEnvelope" + }, + "example": { + "data": { + "group": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ], + "deploymentMode": "deployed", + "autoRun": true + }, + "columns": [ + { + "id": "col_a1b2c3", + "name": "revenue", + "type": "currency", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableWorkflowGroup", + "summary": "Delete Workflow Group", + "description": "Remove a group **and every column it fed**, along with their values. The surviving column list is returned so a caller does not have to re-read the table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowGroupBody" + }, + "example": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + } + } + } + }, + "responses": { + "200": { + "description": "The group was removed.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowGroupEnvelope" + }, + "example": { + "data": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "deleted": true, + "columns": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns/run": { + "post": { + "operationId": "runTableColumns", + "summary": "Run Column Groups", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) — never both. Omit both to run every row. Starting a run clears the target groups’ cells to pending, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns/run\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"groupIds\":[\"grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204\"]}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunColumnBody" + }, + "examples": { + "everyRow": { + "summary": "Run a group across the whole table", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] + } + }, + "backfillFiltered": { + "summary": "Backfill only unfinished rows matching a predicate, capped at 500", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"], + "runMode": "incomplete", + "filter": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "limit": { + "type": "rows", + "max": 500 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}": { + "post": { + "operationId": "runRowEnrichment", + "summary": "Run Enrichment For One Row", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** — the response acknowledges the dispatch; read the row back for the result.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/GroupId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" + }, + "example": { + "workspaceId": "ws_123" + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/find": { + "post": { + "operationId": "findTableRows", + "summary": "Find Rows", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row’s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor — when `truncated` is true, narrow the predicate rather than paging.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/find\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"q\":\"acme\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsBody" + }, + "examples": { + "wholeTable": { + "summary": "Search every cell", + "value": { + "workspaceId": "ws_123", + "q": "acme" + } + }, + "withinFilter": { + "summary": "Search inside a filtered, sorted view", + "value": { + "workspaceId": "ws_123", + "q": "acme", + "predicate": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "name", + "direction": "asc" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The matching cells.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsEnvelope" + }, + "example": { + "data": { + "matches": [ + { + "ordinal": 12, + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "column": "company" + } + ], + "truncated": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/jobs": { + "x-removed-get": { + "operationId": "listTableJobs", + "summary": "List Export Jobs", + "description": "Export jobs across a workspace — running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/jobs?workspaceId=YOUR_WORKSPACE_ID&type=export\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobTypeQuery" + } + ], + "responses": { + "200": { + "description": "The workspace’s export jobs.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableJobListEnvelope" + }, + "example": { + "data": [ + { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "tableName": "customers", + "status": "ready", + "rowsProcessed": 12043, + "format": "csv", + "hasResult": true, + "error": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/import-async": { + "x-removed-post": { + "operationId": "importTableCsvAsync", + "summary": "Import CSV (Background)", + "description": "Start a background import of a file already uploaded to workspace storage — the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace’s storage prefix. The table’s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"fileKey\":\"workspace/YOUR_WORKSPACE_ID/imports/contacts.csv\",\"fileName\":\"contacts.csv\",\"mode\":\"append\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "fileKey": "workspace/ws_123/imports/contacts.csv", + "fileName": "contacts.csv", + "mode": "append" + } + } + } + }, + "responses": { + "200": { + "description": "The import was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "importId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export-async": { + "x-removed-post": { + "operationId": "exportTableAsync", + "summary": "Export Table (Background)", + "description": "Start a background export. Export jobs are read-only, so they bypass the one-write-job-per-table gate and can run alongside an import or delete.\n\nReturns as soon as the job is queued. Poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download` once the job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"format\":\"csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "format": "csv" + } + } + } + }, + "responses": { + "200": { + "description": "The export was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export/download": { + "x-removed-get": { + "operationId": "downloadTableExport", + "summary": "Download Export", + "description": "Resolve a finished export job to a short-lived presigned download URL.\n\nThe failure modes are deliberately distinct: a job that is not an export of this table is 404, one still running is 409 (retry later), and one whose file has aged out of storage is 410 (start a new export). A caller polling to completion needs to tell \"not yet\" from \"never again\".", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export/download?workspaceId=YOUR_WORKSPACE_ID&jobId=YOUR_JOB_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobIdQuery" + } + ], + "responses": { + "200": { + "description": "The presigned download URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportDownloadEnvelope" + }, + "example": { + "data": { + "url": "https://storage.sim.ai/workspace/ws_123/exports/customers.csv?X-Amz-Signature=...", + "fileName": "customers.csv" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "410": { + "$ref": "#/components/responses/Gone" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/job/cancel": { + "x-removed-post": { + "operationId": "cancelTableJob", + "summary": "Cancel Job", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place — there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/job/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"jobId\":\"YOUR_JOB_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobBody" + }, + "example": { + "workspaceId": "ws_123", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + }, + "responses": { + "200": { + "description": "The cancel outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobEnvelope" + }, + "example": { + "data": { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "canceled": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/imports": { + "post": { + "operationId": "createTableImport", + "summary": "Create Table Import", + "description": "Create a table import. Upload sources return a signed control token plus single-PUT or multipart transfer instructions; workspace-file sources start immediately. Both use table jobs for processing state.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "The table import resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/imports/{importId}": { + "get": { + "operationId": "getTableImport", + "summary": "Get Table Import", + "description": "Read processing progress and terminal state from the table job using the same import id.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table import resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "cancelTableImport", + "summary": "Cancel Table Import", + "description": "Cancel an upload or processing import. Already committed row batches remain in the table.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/OptionalUploadTokenHeader" + } + ], + "responses": { + "200": { + "description": "The canceled import resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/imports/{importId}/parts": { + "post": { + "operationId": "createTableImportPartUrls", + "summary": "Create Table Import Part URLs", + "description": "Issue short-lived signed PUT URLs for a bounded set of import part numbers.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested import parts.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/imports/{importId}/complete": { + "post": { + "operationId": "completeTableImportUpload", + "summary": "Complete Table Import Upload", + "description": "Verify the single PUT or assemble the multipart CSV or TSV, then start processing with the same import id.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "The queued import resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/exports": { + "post": { + "operationId": "createTableExport", + "summary": "Create Table Export", + "description": "Create one export resource. The server completes small exports inline and queues larger exports without changing the API path.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "201": { + "description": "The completed or processing export resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/exports/{exportId}": { + "get": { + "operationId": "getTableExport", + "summary": "Get Table Export", + "description": "Read processing, progress, and terminal state for an export resource.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table export resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "cancelTableExport", + "summary": "Cancel Table Export", + "description": "Cancel an export that is still processing.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The canceled export resource.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/exports/{exportId}/download": { + "get": { + "operationId": "downloadTableExport", + "summary": "Download Table Export", + "description": "Return a short-lived download URL once an export has completed.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "A short-lived URL for the generated export file.", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/cancel-runs": { + "post": { + "operationId": "cancelTableRuns", + "summary": "Cancel Column Runs", + "description": "Stop in-flight and pending workflow or enrichment cell runs — the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row’s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/cancel-runs\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"scope\":\"all\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsBody" + }, + "examples": { + "everything": { + "summary": "Stop every run on the table", + "value": { + "workspaceId": "ws_123", + "scope": "all" + } + }, + "oneRow": { + "summary": "Stop one row’s runs", + "value": { + "workspaceId": "ws_123", + "scope": "row", + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "How many runs were stopped.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsEnvelope" + }, + "example": { + "data": { + "cancelled": 17 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/folders": { + "get": { + "operationId": "listTablesFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TablesFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTablesFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/TablesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateTablesFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/TablesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTablesFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "tables"], + "properties": { + "folders": { + "type": "integer" + }, + "tables": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned for an upload-backed table import.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "OptionalUploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": false, + "description": "Required when canceling before upload completion; omitted when canceling a running table job.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of items to return per page (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ViewId": { + "name": "viewId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "description": "The unique identifier of the saved view." + }, + "GroupId": { + "name": "groupId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + }, + "description": "The unique identifier of the workflow or enrichment group." + }, + "JobIdQuery": { + "name": "jobId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "example": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + }, + "description": "The export job to resolve." + }, + "ExportFormatQuery": { + "name": "format", + "in": "query", + "required": false, + "description": "Serialization for the exported file. Defaults to `csv`.", + "schema": { + "enum": ["csv", "json"], + "default": "csv" + } + }, + "JobTypeQuery": { + "name": "type", + "in": "query", + "required": true, + "description": "Job kind to list. Only `export` is supported today; the parameter is required so widening it later cannot silently change what an existing caller receives.", + "schema": { + "enum": ["export"] + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "id": { + "type": "string", + "description": "Stable column id. Server-assigned — normally omit." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "folderPath", + "locks", + "createdAt", + "updatedAt", + "job" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + }, + "locks": { + "$ref": "#/components/schemas/TableLocks" + }, + "job": { + "oneOf": [ + { + "$ref": "#/components/schemas/TableJobState" + }, + { + "type": "null" + } + ], + "description": "In-flight background job, or null when the table is idle." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] + } + } + } + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + ], + "description": "The column definition to add." + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was deleted.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the deleted 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." + } + } + } + } + }, + "Predicate": { + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "oneOf": [ + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/PredicateNode" + } + } + } + }, + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/PredicateNode" + } + } + } + } + ] + }, + "PredicateNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/Predicate" + }, + { + "$ref": "#/components/schemas/Condition" + } + ] + }, + "Condition": { + "type": "object", + "required": ["field", "op"], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "maxLength": 128, + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." + }, + "op": { + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" + ], + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + }, + "value": { + "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." + } + } + }, + "SelectOption": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "description": "Stable option id — the value stored in cells." + }, + "name": { + "type": "string", + "maxLength": 100, + "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." + } + } + }, + "TableLocks": { + "type": "object", + "description": "Per-table governance flags. Every flag is present. Changing them requires workspace admin.", + "required": ["schemaLocked", "insertLocked", "updateLocked", "deleteLocked"], + "properties": { + "schemaLocked": { + "type": "boolean", + "description": "Blocks column adds, edits, and deletes." + }, + "insertLocked": { + "type": "boolean", + "description": "Blocks new rows." + }, + "updateLocked": { + "type": "boolean", + "description": "Blocks cell writes to existing rows." + }, + "deleteLocked": { + "type": "boolean", + "description": "Blocks row deletes and archiving the table." + } + } + }, + "UpdateTableBody": { + "type": "object", + "description": "Rename and/or move a table. Every field beyond `workspaceId` is optional, but at least one must be present. Lock flags are read-only on this API and are not accepted here.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "New table name." + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + } + }, + "additionalProperties": false + }, + "WorkspaceScopedBody": { + "type": "object", + "description": "Endpoints whose only input is the workspace the table must belong to.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + } + } + }, + "SortSpec": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first. Fields are column names.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { + "type": "string" + }, + "direction": { + "enum": ["asc", "desc"] + } + } + } + }, + "ViewConfig": { + "type": "object", + "description": "A view’s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "properties": { + "columnWidths": { + "type": "object", + "description": "Pixel widths keyed by column id.", + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "columnOrder": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Left-to-right column order, as column ids." + }, + "pinnedColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids pinned while scrolling horizontally." + }, + "hiddenColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids hidden by the view. A deny-list — a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" + } + } + }, + "View": { + "type": "object", + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only — a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "required": [ + "id", + "tableId", + "name", + "config", + "isDefault", + "createdBy", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique view identifier.", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "tableId": { + "type": "string", + "description": "The table the view belongs to." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "config": { + "$ref": "#/components/schemas/ViewConfig" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this view is the table’s default. At most one view per table is." + }, + "createdBy": { + "type": ["string", "null"], + "description": "User who saved the view, or null when that user no longer exists." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateViewBody": { + "type": "object", + "description": "Save a filter/sort/layout preset as a named view.", + "required": ["workspaceId", "name", "config"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Display name for the view." + }, + "config": { + "$ref": "#/components/schemas/ViewConfig" + } + } + }, + "UpdateViewBody": { + "type": "object", + "description": "Change a saved view. At least one of `name`, `config`, `configPatch`, or `isDefault` is required; `config` and `configPatch` are mutually exclusive.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "New display name." + }, + "config": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Replaces the stored config wholesale. Use when dropping a removed filter must persist." + }, + "configPatch": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Shallow-merged into the stored config server-side, so two overlapping partial writes cannot clobber each other from stale snapshots." + }, + "isDefault": { + "type": "boolean", + "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction." + } + } + }, + "ViewEnvelope": { + "type": "object", + "description": "A single view wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["view"], + "properties": { + "view": { + "$ref": "#/components/schemas/View" + } + } + } + } + }, + "ViewListEnvelope": { + "type": "object", + "description": "Saved views wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/View" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null — a table carries a bounded set of views, so the list is a single full page." + } + } + }, + "DeleteViewEnvelope": { + "type": "object", + "description": "Delete confirmation carrying the id of the removed view.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The view that was deleted." + } + } + } + } + }, + "WorkflowGroup": { + "type": "object", + "description": "A workflow or enrichment group: a backing workflow (or registry enrichment) plus the output columns its runs populate. Authored in the workflow builder; exposed here so a caller can discover the group ids the run endpoints take.", + "required": ["id", "workflowId", "outputs"], + "properties": { + "id": { + "type": "string", + "description": "Group id — pass to the run endpoints." + }, + "workflowId": { + "type": "string", + "description": "Backing workflow id for manual groups; empty string for enrichment groups." + }, + "enrichmentId": { + "type": "string", + "description": "Registry enrichment id, present on enrichment groups." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "type": { + "enum": ["manual", "enrichment"], + "description": "Provenance of the group. Defaults to manual when absent." + }, + "dependencies": { + "type": "object", + "description": "Columns whose values must be present before the group is eligible to run.", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "outputs": { + "type": "array", + "description": "Which produced value flows into which column.", + "items": { + "type": "object", + "required": ["blockId", "path", "columnName"], + "properties": { + "blockId": { + "type": "string", + "description": "Source block in the workflow. Empty on enrichment outputs." + }, + "path": { + "type": "string", + "description": "Path into the block output. Empty on enrichment outputs." + }, + "outputId": { + "type": "string", + "description": "Enrichment output id, on enrichment groups." + }, + "columnName": { + "type": "string", + "description": "Column the value is written to." + } + } + } + }, + "inputMappings": { + "type": "array", + "description": "Which table column supplies each workflow Start-block input.", + "items": { + "type": "object", + "required": ["inputName", "columnName"], + "properties": { + "inputName": { + "type": "string" + }, + "columnName": { + "type": "string" + } + } + } + }, + "deploymentMode": { + "enum": ["live", "deployed"], + "description": "Which workflow state per-cell runs execute against. Defaults to live (the editable draft)." + }, + "autoRun": { + "type": "boolean", + "description": "When false the group never auto-fires; it runs only on an explicit request. Defaults to true." + } + } + }, + "WorkflowGroupListEnvelope": { + "type": "object", + "description": "Workflow groups wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroup" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null — groups are bounded per table, so the list is a single full page." + } + } + }, + "RunColumnBody": { + "type": "object", + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) — never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "required": ["workspaceId", "groupIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "groupIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Groups to run, from `GET /api/v2/tables/{tableId}/groups`." + }, + "runMode": { + "enum": ["all", "incomplete"], + "default": "all", + "description": "`all` re-runs every dep-satisfied row. `incomplete` restricts to rows whose group has never run or whose last run failed or aborted." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Run only these rows. Mutually exclusive with `filter`." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Rows to skip within the `filter` scope." + }, + "limit": { + "type": "object", + "description": "Cap the run to the first N eligible rows. Omit for an unbounded run.", + "required": ["type", "max"], + "properties": { + "type": { + "enum": ["rows"] + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + } + } + } + }, + "RunEnvelope": { + "type": "object", + "description": "Acknowledgement that a run was dispatched.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["dispatchId"], + "properties": { + "dispatchId": { + "type": ["string", "null"], + "description": "Identifies the dispatch the runner walks. Null where no background runner is configured and cells execute inline." + } + } + } + } + }, + "FindRowsBody": { + "type": "object", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`.", + "required": ["workspaceId", "q"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "q": { + "type": "string", + "minLength": 1, + "description": "Substring to search for." + }, + "predicate": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" + } + } + }, + "RowMatch": { + "type": "object", + "description": "One matching cell.", + "required": ["ordinal", "rowId", "column"], + "properties": { + "ordinal": { + "type": "integer", + "description": "The row’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — use it to page straight to the match." + }, + "rowId": { + "type": "string", + "description": "The row holding the matching cell." + }, + "column": { + "type": "string", + "description": "Name of the matching column." + } + } + }, + "FindRowsEnvelope": { + "type": "object", + "description": "Matching cells wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["matches", "truncated"], + "properties": { + "matches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RowMatch" + } + }, + "truncated": { + "type": "boolean", + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor — narrow the predicate instead of paging." + } + } + } + } + }, + "ImportAsyncEnvelope": { + "type": "object", + "description": "Background-import kickoff acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["tableId", "importId"], + "properties": { + "tableId": { + "type": "string" + }, + "importId": { + "type": "string", + "description": "Job id — pass to `POST /job/cancel` to stop the import." + } + } + } + } + }, + "ImportAsyncBody": { + "type": "object", + "description": "Starts a background import of a file already uploaded to workspace storage. The file is read by the worker, not from this request.", + "required": ["workspaceId", "fileKey", "fileName", "mode"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "fileKey": { + "type": "string", + "minLength": 1, + "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.", + "example": "workspace/ws_123/imports/contacts.csv" + }, + "fileName": { + "type": "string", + "minLength": 1, + "description": "Original filename. Its extension selects the separator (.csv or .tsv)." + }, + "mode": { + "enum": ["append", "replace"], + "description": "`append` adds rows; `replace` deletes every existing row first." + }, + "mapping": { + "type": "object", + "description": "CSV header → column name, or null to skip the header.", + "additionalProperties": { + "type": ["string", "null"] + } + }, + "createColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CSV headers to create as new columns before importing." + }, + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes.", + "example": "America/New_York" + } + } + }, + "ExportAsyncBody": { + "type": "object", + "description": "Starts a background export.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "format": { + "enum": ["csv", "json"], + "default": "csv", + "description": "Serialization to produce." + } + } + }, + "ExportAsyncEnvelope": { + "type": "object", + "description": "Background-export kickoff acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["tableId", "jobId"], + "properties": { + "tableId": { + "type": "string" + }, + "jobId": { + "type": "string", + "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." + } + } + } + } + }, + "ExportDownloadEnvelope": { + "type": "object", + "description": "A short-lived presigned download URL for a finished export.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["url", "fileName"], + "properties": { + "url": { + "type": "string", + "description": "Presigned URL. Expires shortly after issue — fetch it promptly." + }, + "fileName": { + "type": "string", + "description": "Suggested filename for the download." + } + } + } + } + }, + "TableJob": { + "type": "object", + "description": "One export job.", + "required": [ + "jobId", + "tableId", + "tableName", + "status", + "rowsProcessed", + "format", + "hasResult", + "error" + ], + "properties": { + "jobId": { + "type": "string" + }, + "tableId": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "Only `ready` jobs can be downloaded." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows written so far." + }, + "format": { + "enum": ["csv", "json"] + }, + "hasResult": { + "type": "boolean", + "description": "Whether a generated file is still available to download." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." + } + } + }, + "TableJobListEnvelope": { + "type": "object", + "description": "Export jobs wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TableJob" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null — the listing is bounded server-side to a single page." + } + } + }, + "CancelJobBody": { + "type": "object", + "description": "Stops an in-flight import or delete job.", + "required": ["workspaceId", "jobId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "jobId": { + "type": "string", + "minLength": 1, + "description": "The job to stop." + } + } + }, + "CancelJobEnvelope": { + "type": "object", + "description": "Cancel outcome.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["jobId", "canceled"], + "properties": { + "jobId": { + "type": "string" + }, + "canceled": { + "type": "boolean", + "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." } } + } + } + }, + "CancelRunsBody": { + "type": "object", + "description": "Stops in-flight and pending cell runs. `filter` and `excludeRowIds` apply only to `scope: \"all\"`; `rowId` is required for `scope: \"row\"`.", + "required": ["workspaceId", "scope"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFoundOrGated" }, - "413": { - "description": "Request body exceeded the 1 MB cap.", - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } - } + "scope": { + "enum": ["all", "row"], + "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." + }, + "rowId": { + "type": "string", + "minLength": 1, + "description": "Required when `scope` is `row`." }, - "429": { "$ref": "#/components/responses/RateLimited" } + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Rows to leave running within the `filter` scope." + } } - } - } - }, - "components": { - "securitySchemes": { - "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } - }, - "schemas": { - "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", - "oneOf": [ - { + }, + "CancelRunsEnvelope": { + "type": "object", + "description": "How many in-flight cell runs were stopped.", + "required": ["data"], + "properties": { + "data": { "type": "object", - "required": ["all"], - "additionalProperties": false, + "required": ["cancelled"], "properties": { - "all": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { "$ref": "#/components/schemas/PredicateNode" } + "cancelled": { + "type": "integer" } } + } + } + }, + "TableJobState": { + "type": "object", + "description": "The latest write job derived onto the table. Durable imports also expose their full lifecycle at `GET /api/v2/tables/imports/{importId}`. Exports are read-only resources and do not replace this field.", + "required": ["id", "type", "status", "rowsProcessed", "error"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "Job id. For durable imports this is also the import resource id." }, - { + "type": { + "enum": ["import", "delete", "export", "backfill", "update", null], + "description": "Which kind of job is running." + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "`running` is in-flight; the rest are terminal." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows handled so far — progress for a running job." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." + } + } + }, + "WorkflowGroupOutputColumnInput": { + "type": "object", + "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Column name. Must match one of `group.outputs[].columnName`.", + "example": "revenue" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"] + }, + "required": { + "type": "boolean" + }, + "unique": { + "type": "boolean" + } + } + }, + "AddWorkflowGroupBody": { + "type": "object", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.", + "required": ["workspaceId", "group", "outputColumns"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "group": { "type": "object", - "required": ["any"], - "additionalProperties": false, + "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.", + "required": ["outputs"], "properties": { - "any": { + "id": { + "type": "string", + "minLength": 1, + "description": "Optional. Omit to have the server generate one." + }, + "workflowId": { + "type": "string", + "description": "Required for `manual` groups." + }, + "enrichmentId": { + "type": "string", + "minLength": 1, + "description": "Required for `enrichment` groups." + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["manual", "enrichment"], + "default": "manual", + "description": "`manual` means workflow-backed — not hand-entered." + }, + "dependencies": { + "type": "object", + "description": "Columns that must be populated before this group runs." + }, + "outputs": { "type": "array", "minItems": 1, - "maxItems": 100, - "items": { "$ref": "#/components/schemas/PredicateNode" } + "description": "Where each value comes from. Workflow outputs carry `blockId`/`path`; enrichment outputs carry `outputId`.", + "items": { + "type": "object" + } + }, + "inputMappings": { + "type": "array", + "description": "Workflow Start-block inputs fed from table columns.", + "items": { + "type": "object" + } + }, + "deploymentMode": { + "type": "string", + "enum": ["live", "deployed"] + }, + "autoRun": { + "type": "boolean", + "description": "Whether the group auto-fires from the scheduler." } } + }, + "outputColumns": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput" + } + }, + "autoRun": { + "type": "boolean", + "default": false, + "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run." } - ] - }, - "PredicateNode": { - "oneOf": [ - { "$ref": "#/components/schemas/Predicate" }, - { "$ref": "#/components/schemas/Condition" } - ] + } }, - "Condition": { + "UpdateWorkflowGroupBody": { "type": "object", - "required": ["field", "op"], + "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.", + "required": ["workspaceId", "groupId"], "additionalProperties": false, "properties": { - "field": { + "workspaceId": { "type": "string", - "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." + "minLength": 1 }, - "op": { - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + "groupId": { + "type": "string", + "minLength": 1 }, - "value": { - "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." + "workflowId": { + "type": "string", + "minLength": 1, + "description": "Re-point the group. Re-checked against the workspace." + }, + "name": { + "type": "string" + }, + "dependencies": { + "type": "object" + }, + "outputs": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Full replacement set. Entries dropped here delete their columns." + }, + "newOutputColumns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput" + } + }, + "mappingUpdates": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Re-point a column to a different workflow output, keeping the column." + }, + "inputMappings": { + "type": "array", + "items": { + "type": "object" + } + }, + "deploymentMode": { + "type": "string", + "enum": ["live", "deployed"] + }, + "type": { + "type": "string", + "enum": ["manual", "enrichment"] + }, + "autoRun": { + "type": "boolean" } } }, - "TableSummary": { + "DeleteWorkflowGroupBody": { "type": "object", - "required": ["id", "name", "schema", "rowCount", "maxRows", "createdAt", "updatedAt"], + "description": "Remove a group and every column it fed.", + "required": ["workspaceId", "groupId"], + "additionalProperties": false, "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "type": ["string", "null"] }, - "schema": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "groupId": { + "type": "string", + "minLength": 1 + } + } + }, + "WorkflowGroupEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { "type": "object", + "required": ["group", "columns"], "properties": { + "group": { + "$ref": "#/components/schemas/WorkflowGroup" + }, "columns": { "type": "array", "items": { - "type": "object", - "required": ["name", "type"], - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "type": { "enum": ["string", "number", "boolean", "date", "json", "select"] }, - "required": { "type": "boolean" }, - "unique": { "type": "boolean" }, - "options": { - "type": "array", - "description": "Declared choices on a `select` column.", - "items": { - "type": "object", - "properties": { "id": { "type": "string" }, "name": { "type": "string" } } - } - }, - "multiple": { "type": "boolean" } - } + "$ref": "#/components/schemas/Column" } } } - }, - "rowCount": { "type": "integer" }, - "maxRows": { "type": "integer" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + } } }, - "ErrorBody": { + "DeleteWorkflowGroupEnvelope": { "type": "object", - "required": ["error"], + "required": ["data"], "properties": { - "error": { + "data": { + "type": "object", + "required": ["id", "deleted", "columns"], + "properties": { + "id": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "enum": [true] + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "TablesFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { "type": "string", - "description": "Human-readable message naming the failing field/operator." + "description": "Canonical folder path. This is the public folder identifier." }, - "code": { + "parentPath": { "type": "string", - "description": "Machine-readable code, present on domain validation failures." + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } } }, "responses": { - "ValidationError": { - "description": "Malformed request.", + "BadRequest": { + "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } } }, "Unauthorized": { - "description": "Missing or invalid API key.", + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } } }, "Forbidden": { - "description": "The key's workspace scope does not cover this workspace, or the caller lacks read access.", + "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } } }, - "NotFoundOrGated": { - "description": "Table not found — or the `tables-v2-api` feature flag is off for this caller, in which case the entire surface answers 404. The gate is evaluated after authorization, so a 404 never distinguishes rollout cohort from missing resource for callers without access.", + "NotFound": { + "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } } }, "RateLimited": { - "description": "Rate limit exceeded for this key.", + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource — for example a rename to a name another table in the workspace already uses.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A table named \"contacts\" already exists" + } + } + } + } + }, + "Locked": { + "description": "The table has a lock that forbids this operation. Clear the relevant lock with `PATCH /api/v2/tables/{tableId}` (workspace admin only) and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Schema changes are locked for this table" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The import source exceeds the 5 GB resource limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "CSV import file exceeds maximum size" + } + } + } + } + }, + "Gone": { + "description": "The generated export file has aged out of storage. Start a new export rather than retrying this download.", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Export file is no longer available" + } + } + } } } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json new file mode 100644 index 00000000000..59faa644642 --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,2958 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for managing workflows (create, list, inspect, update, delete), their deployment versions, and deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Workflows", + "description": "Create, list, inspect, update, and delete workflows, enumerate their deployment versions, and manage deployments (deploy, undeploy, rollback) on the v2 API." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "List Workflows", + "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "folderPath", + "in": "query", + "required": false, + "description": "Filter results to only include workflows within this folder.", + "schema": { + "type": "string" + } + }, + { + "name": "deployedOnly", + "in": "query", + "required": false, + "description": "When true, only return workflows that are currently deployed. Useful for listing workflows available for API execution.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of workflows to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. `position` is the workspace's own manual arrangement of its workflows, which is the default order. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt", "runCount"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "A page of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Workflows for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createWorkflowV2", + "summary": "Create Workflow", + "description": "Create an empty workflow in a workspace. The workflow is created with a default start block and no deployment, so it must be edited and deployed before it can be executed. Names must be unique within the target folder — a collision is reported as 409 rather than silently renamed.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Customer Support Agent\"\n }'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowBody" + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowListItem" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A workflow with the same name already exists in the target folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateWorkflowV2", + "summary": "Update Workflow", + "description": "Rename a workflow, change its description, or move it between folders. Omitted fields keep their stored values, and at least one field must be supplied. Editing the workflow's graph is not part of this endpoint — use import/export for that. Returns 404 when the workflow does not exist or you do not have write access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Customer Support Agent v2\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowListItem" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "A workflow with the target name already exists in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteWorkflowV2", + "summary": "Delete Workflow", + "description": "Delete a workflow. It stops being returned by list and detail endpoints while its execution logs remain attributable. The last remaining workflow in a workspace cannot be deleted (400).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The workflow was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeleteWorkflowResult" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/versions": { + "get": { + "operationId": "listWorkflowVersionsV2", + "summary": "List Workflow Versions", + "description": "List a workflow's deployment versions, newest first. Every successful deploy appends a version; these are the version numbers `POST /api/v2/workflows/{id}/rollback` accepts. Results are cursor-paginated — follow `nextCursor` and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of versions to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of deployment versions, newest first.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Deployment versions for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowVersion" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": "active" + }, + { + "id": "b70e2c81-4d93-4a17-8f52-93a1c7e0d6b8", + "version": 2, + "name": null, + "description": null, + "isActive": false, + "createdAt": "2026-05-02T09:04:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/versions/{version}": { + "get": { + "operationId": "getWorkflowVersionV2", + "summary": "Get Workflow Version", + "description": "Fetch one deployment version and the workflow state it pins. Use this to inspect or diff a version before activating it with `POST /api/v2/workflows/{id}/rollback`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions/{version}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "$ref": "#/components/parameters/VersionNumber" + } + ], + "responses": { + "200": { + "description": "The requested deployment version.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowVersionDetail" + } + } + }, + "example": { + "data": { + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 4, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UndeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/RollbackResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 3, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/export": { + "get": { + "operationId": "exportWorkflow", + "summary": "Export a workflow", + "description": "Exports a workflow as a portable JSON envelope that `POST /api/v2/workflows/import` accepts verbatim. See the payload schema for what sanitization clears.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The export payload.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowExportPayload" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/import": { + "post": { + "operationId": "importWorkflow", + "summary": "Import a workflow", + "description": "Creates a new workflow in the target workspace from an export payload produced by `GET /api/v2/workflows/{id}/export`. Block, edge, loop and parallel ids are regenerated on import, so the same payload can be imported repeatedly and alongside its source workflow without collisions. Bodies over 10 MB are rejected with 413.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWorkflowBody" + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ImportedWorkflow" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "A workflow with the same name already exists and deduplication failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflowV2", + "summary": "Execute a workflow", + "description": "Executes a deployed workflow. Auth: `X-API-Key`, or no key at all for workflows deployed with public API access (sync/stream only). Modes are body-selected — there are no mode headers on v2: `\"async\": true` queues the run and returns a 202 receipt whose `statusUrl` is the executions resource; `\"stream\": true` returns Server-Sent Events (no `{data}` envelope on frames; `includeThinking`/`includeToolCalls` additionally require the `X-Sim-Stream-Protocol: agent-events-v1` header). Sync runs return the execution resource: a failed run is HTTP 200 with `status: \"failed\"` and the structured error (the sync timeout is `status:\"failed\"` + `error.code:\"TIMEOUT\"`). A Response block's declared payload stays inside `output` — workflow authors never control response status or headers. Optional `X-Execution-Id` request header (keyed callers only) makes the run idempotent; a reused id returns 409. Rate limiting uses the workflow execution buckets (async runs debit the larger async bucket) and 429s carry `Retry-After`; execute responses do not carry `X-RateLimit-*` headers.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Bodies over 10 MB are rejected with 413. Unknown keys are rejected (strict schema).", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "input": { + "type": "object", + "additionalProperties": true, + "description": "Workflow input, keyed by the deployed API trigger's input fields." + }, + "async": { + "type": "boolean", + "default": false, + "description": "Queue the run; poll the returned statusUrl. Not combinable with stream/output options; requires an API key." + }, + "stream": { + "type": "boolean", + "default": false, + "description": "Stream block outputs as Server-Sent Events." + }, + "selectedOutputs": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Restrict streamed outputs to specific `BlockName.path` refs." + }, + "includeThinking": { + "type": "boolean", + "default": false + }, + "includeToolCalls": { + "type": "boolean", + "default": false + }, + "includeFileBase64": { + "type": "boolean" + }, + "base64MaxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + } + } + }, + "example": { + "input": { + "key": "value" + } + }, + "examples": { + "sync": { + "summary": "Synchronous run", + "value": { + "input": { + "key": "value" + } + } + }, + "async": { + "summary": "Queued run", + "value": { + "input": { + "key": "value" + }, + "async": true + } + }, + "stream": { + "summary": "SSE stream", + "value": { + "input": { + "key": "value" + }, + "stream": true, + "selectedOutputs": ["Agent.content"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The execution resource. Served with `Cache-Control: private, no-store` and the `X-Execution-Id` header.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ExecutionResource" + } + } + }, + "example": { + "data": { + "executionId": "8f14e45f-ceea-467f-a", + "workflowId": "wf_123", + "status": "completed", + "output": { + "result": "done" + }, + "error": null, + "startedAt": "2026-07-31T00:00:00.000Z", + "endedAt": "2026-07-31T00:00:01.000Z", + "durationMs": 1000 + } + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "data": { + "executionId": "exec_1", + "workflowId": "wf_123", + "status": "completed", + "output": { + "result": "done" + }, + "error": null, + "durationMs": 1000 + } + } + }, + "failed": { + "summary": "Failed run (still HTTP 200)", + "value": { + "data": { + "executionId": "exec_2", + "workflowId": "wf_123", + "status": "failed", + "output": { + "partial": true + }, + "error": { + "message": "Invalid credentials", + "code": "BLOCK_EXECUTION_FAILED", + "blockId": "b_9", + "blockName": "Send Email", + "blockType": "gmail" + }, + "durationMs": 310 + } + } + } + } + } + } + }, + "202": { + "description": "Queued (async). Poll `statusUrl` until `status` is terminal.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["executionId", "statusUrl"], + "properties": { + "executionId": { + "type": "string" + }, + "statusUrl": { + "type": "string" + } + } + } + } + }, + "example": { + "data": { + "executionId": "exec_1", + "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/exec_1" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The `X-Execution-Id` was already used.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the 10 MB limit." + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "description": "Execution infrastructure temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v2/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecutionV2", + "summary": "Get execution status", + "description": "The single status URL for sync and async runs. Freshly queued async runs report `queued` (backfilled from the job queue before the durable record exists), then `running`, then a terminal status. Failed runs carry the structured error. `includeOutput=true` adds the final output on completed runs; `selectedOutputs` extracts specific block outputs.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "executionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "schema": { + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated `blockId.path` selectors." + } + ], + "responses": { + "200": { + "description": "The execution status resource.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "executionId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "paused", + "cost", + "error", + "output", + "blockOutputs" + ], + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "enum": [ + "queued", + "pending", + "running", + "completed", + "failed", + "cancelled", + "paused" + ] + }, + "trigger": { + "type": ["string", "null"] + }, + "startedAt": { + "type": ["string", "null"] + }, + "endedAt": { + "type": ["string", "null"] + }, + "durationMs": { + "type": ["number", "null"] + }, + "paused": { + "type": ["object", "null"], + "description": "Pause detail for human-in-the-loop runs." + }, + "cost": { + "type": ["object", "null"], + "properties": { + "total": { + "type": "number" + } + } + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/ExecutionError" + }, + { + "type": "null" + } + ] + }, + "output": { + "description": "Final output; only with `includeOutput=true` on completed runs." + }, + "blockOutputs": { + "type": ["object", "null"] + } + } + } + } + }, + "example": { + "data": { + "executionId": "exec_1", + "workflowId": "wf_123", + "status": "completed", + "trigger": "api", + "startedAt": "2026-07-31T00:00:00.000Z", + "endedAt": "2026-07-31T00:00:01.000Z", + "durationMs": 1000, + "paused": null, + "cost": { + "total": 0.02 + }, + "error": null, + "output": null, + "blockOutputs": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecutionV2", + "summary": "Cancel an execution", + "description": "Cancels a running or paused execution. `reason` explains how the cancellation was recorded.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "executionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Cancellation outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "success", + "executionId", + "redisAvailable", + "durablyRecorded", + "locallyAborted", + "pausedCancelled" + ], + "properties": { + "success": { + "type": "boolean" + }, + "executionId": { + "type": "string" + }, + "redisAvailable": { + "type": "boolean" + }, + "durablyRecorded": { + "type": "boolean" + }, + "locallyAborted": { + "type": "boolean" + }, + "pausedCancelled": { + "type": "boolean" + }, + "reason": { + "enum": [ + "recorded", + "redis_unavailable", + "redis_write_failed", + "paused_event_publish_failed", + "paused_database_cancel_failed" + ] + } + } + } + } + }, + "example": { + "data": { + "success": true, + "executionId": "exec_1", + "redisAvailable": true, + "durablyRecorded": true, + "locallyAborted": false, + "pausedCancelled": false, + "reason": "recorded" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/folders": { + "get": { + "operationId": "listWorkflowsFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createWorkflowsFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateWorkflowsFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteWorkflowsFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "workflows"], + "properties": { + "folders": { + "type": "integer" + }, + "workflows": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace to list workflows from.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "WorkflowId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + "VersionNumber": { + "name": "version", + "in": "path", + "required": true, + "description": "The deployment version number, as returned by the version list.", + "schema": { + "type": "integer", + "minimum": 1, + "example": 3 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-06-29T21:50:00.000Z" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "USAGE_LIMIT_EXCEEDED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of what went wrong." + }, + "details": { + "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add." + } + } + } + } + }, + "WorkflowListItem": { + "type": "object", + "description": "Summary representation of a workflow returned by the list endpoint.", + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does. `null` when unset.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. `null` when never deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. `null` when never run.", + "example": "2026-06-20T14:15:22.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2026-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2026-06-18T16:45:00.000Z" + } + } + }, + "WorkflowInputField": { + "type": "object", + "description": "A single trigger input field extracted from the workflow's input-definition block. Use these to construct the `input` object when executing the workflow.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Field name as referenced by the workflow.", + "example": "ticketBody" + }, + "type": { + "type": "string", + "description": "Declared field type (e.g. `string`, `number`, `boolean`, `object`).", + "example": "string" + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the field.", + "example": "The raw text of the incoming support ticket." + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation: every list field plus workflow-level variables and trigger input field definitions.", + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "variables", + "inputs", + "createdAt", + "updatedAt" + ], + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowListItem" + }, + { + "type": "object", + "required": ["variables", "inputs"], + "properties": { + "variables": { + "type": "object", + "description": "Workflow-scoped variables keyed by variable id. Each value is a structured variable object (`{ id, name, type, value, ... }`); only the inner `value` is user-defined. Empty object when the workflow defines no variables.", + "additionalProperties": true, + "example": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + } + }, + "inputs": { + "type": "array", + "description": "The workflow's trigger input field definitions.", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + } + } + } + } + ] + }, + "DeploymentState": { + "type": "object", + "description": "Base deployment state shared by deploy, undeploy, and rollback results.", + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation." + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "warnings": { + "type": "array", + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.", + "items": { + "type": "string" + } + }, + "activeDeployment": { + "type": ["object", "null"], + "description": "Summary of the currently live deployment version, or null when none is active." + }, + "latestDeploymentAttempt": { + "type": ["object", "null"], + "description": "Lifecycle status of the most recent deploy attempt (preparing/activating/active/failed/superseded) — poll this to a terminal state; deploys admit asynchronously, so HTTP success only means the attempt was accepted." + } + } + }, + "DeployResult": { + "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that is now active. May be omitted when the version number is unavailable.", + "example": 4 + } + } + } + ] + }, + "UndeployResult": { + "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + } + ] + }, + "RollbackResult": { + "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that was re-activated.", + "example": 3 + } + } + } + ] + }, + "WorkflowExportPayload": { + "type": "object", + "required": ["version", "exportedAt", "workflow", "state"], + "description": "Portable workflow export envelope. Secret-sanitized: password fields are cleared unless the value is a whole `{{ENV_VAR}}` reference (preserved so the import resolves it in the target workspace), OAuth credentials are cleared, and workspace-scoped bindings (knowledge-base/file/channel/project selectors and id-keyed fields) are cleared rather than carried across as dangling ids — so a re-import is not a byte-for-byte clone: those bindings must be re-selected. Workflow variables are emitted as stored.", + "properties": { + "version": { + "const": "1.0" + }, + "exportedAt": { + "type": "string", + "format": "date-time" + }, + "workflow": { + "type": "object", + "required": ["id", "name", "description", "workspaceId", "folderPath"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "workspaceId": { + "type": ["string", "null"] + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + } + } + }, + "state": { + "type": "object", + "description": "Sanitized workflow graph: `blocks`, `edges`, `loops`, `parallels`, optional `metadata` and `variables`.", + "additionalProperties": true + } + } + }, + "ImportWorkflowBody": { + "type": "object", + "required": ["workspaceId", "workflow"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "folderPath": { + "type": "string", + "minLength": 1, + "description": "Canonical containing-folder path. `/` is the workspace root." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Overrides the payload's own metadata name." + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Overrides the payload's own metadata description." + }, + "workflow": { + "description": "The export envelope emitted by `GET /api/v2/workflows/{id}/export` (its `data` value or the whole response body), a bare workflow state (`{ blocks, edges, ... }`), or a JSON string of either.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "minProperties": 1 + } + ] + } + } + }, + "ImportedWorkflow": { + "type": "object", + "required": [ + "id", + "name", + "description", + "workspaceId", + "folderPath", + "createdAt", + "updatedAt" + ], + "description": "The created workflow — the subset of the workflow resource knowable at import time.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "workspaceId": { + "type": "string" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ExecutionError": { + "type": "object", + "required": ["message", "code"], + "description": "Structured execution error. Route on `code` (append-only enum) instead of matching message text. Block fields identify the failing block when attributable — with the executionId they form the reproducible handle to hand a shared workflow's provider.", + "properties": { + "message": { + "type": "string" + }, + "code": { + "enum": [ + "TIMEOUT", + "CANCELLED", + "USAGE_LIMIT_EXCEEDED", + "INVALID_INPUT", + "BLOCK_EXECUTION_FAILED", + "CHILD_WORKFLOW_FAILED", + "OUTPUT_TOO_LARGE", + "EXECUTION_FAILED" + ] + }, + "blockId": { + "type": "string" + }, + "blockName": { + "type": "string" + }, + "blockType": { + "type": "string" + } + } + }, + "ExecutionResource": { + "type": "object", + "required": ["executionId", "workflowId", "status", "output", "error"], + "description": "The execution result resource. An executionId always means 200/202 with data; only pre-execution failures use the error envelope. In-band run failures are status 'failed' with the structured error — never an HTTP error status.", + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "enum": ["completed", "failed", "paused", "cancelled"] + }, + "output": { + "description": "Workflow output (partial output is preserved on failures)." + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/ExecutionError" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": "string", + "format": "date-time" + }, + "durationMs": { + "type": "number" + } + } + }, + "CreateWorkflowBody": { + "type": "object", + "description": "Request body for creating a workflow.", + "required": ["workspaceId", "name"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the workflow in. Requires write access.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name. Must be unique within the target folder.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderPath": { + "type": "string", + "minLength": 1, + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" + } + } + }, + "UpdateWorkflowBody": { + "type": "object", + "description": "Request body for updating a workflow's metadata. Omitted fields keep their stored values; at least one field is required.", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New workflow name. Must be unique within the destination folder.", + "example": "Customer Support Agent v2" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "New description. Send `null` to clear it.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderPath": { + "type": "string", + "minLength": 1, + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" + } + } + }, + "DeleteWorkflowResult": { + "type": "object", + "description": "Acknowledgement that a workflow was deleted.", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The deleted workflow's identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "deleted": { + "type": "boolean", + "enum": [true], + "description": "Always `true` on a successful delete." + } + } + }, + "WorkflowVersion": { + "type": "object", + "description": "A deployment version of a workflow, as returned by the version list.", + "required": ["id", "version", "isActive", "createdAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the deployment version record.", + "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24" + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number. Pass this to the rollback endpoint.", + "example": 3 + }, + "name": { + "type": "string", + "nullable": true, + "description": "Optional label given to the version at deploy time. `null` when unset.", + "example": "Adds escalation branch" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional release note for the version. `null` when unset.", + "example": "Routes P1 tickets straight to on-call" + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is the one currently serving executions.", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the version was created.", + "example": "2026-06-12T10:30:00.000Z" + }, + "deployedBy": { + "type": "string", + "nullable": true, + "description": "Display name of the user who deployed the version. `null` when the deployer is no longer resolvable.", + "example": "Ada Lovelace" + }, + "latestOperationStatus": { + "type": "string", + "nullable": true, + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Lifecycle status of the workflow's current deploy attempt, present only on the version that attempt targets. `null` on every other version — a superseded attempt is history, not live state.", + "example": "active" + } + } + }, + "WorkflowVersionDetail": { + "type": "object", + "description": "A deployment version together with the workflow state it pins.", + "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the deployment version record.", + "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24" + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number. Pass this to the rollback endpoint.", + "example": 3 + }, + "name": { + "type": "string", + "nullable": true, + "description": "Optional label given to the version at deploy time. `null` when unset.", + "example": "Adds escalation branch" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional release note for the version. `null` when unset.", + "example": "Routes P1 tickets straight to on-call" + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is the one currently serving executions.", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the version was created.", + "example": "2026-06-12T10:30:00.000Z" + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "The deployed workflow graph snapshot (blocks, edges, loops, parallels). This is the state that executes while the version is active, and the state a rollback restores." + } + } + }, + "WorkflowsFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "locked", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "locked": { + "type": "boolean", + "description": "Whether this workflow folder is locked." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "workspaceId is required", + "details": [ + { + "path": ["workspaceId"], + "message": "workspaceId is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body exceeds the maximum allowed size.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-06-29T21:50:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 87a16bf9a46..2a252735781 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -5,7 +5,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') @@ -102,7 +106,9 @@ export const PUT = withRouteHandler( ? 409 : // A provider outage during reconnect is infra, not a bad // request — mirror the create route and runtime token route. - result.providerErrorCode === 'provider_unavailable' + // Every provider family names its own outage code, so this + // asks the shared predicate rather than matching one literal. + isProviderOutageCode(result.providerErrorCode) ? 502 : result.errorCode === 'validation' ? 400 diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index b8484be2dac..c8b1a7c540f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,149 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credential, credentialMember } from '@sim/db/schema' +import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, credentialsListGetQuerySchema, - normalizeCredentialEnvKey, } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getCredentialActorContext, - isSharedCredentialType, - SHARED_CREDENTIAL_TYPES, -} from '@/lib/credentials/access' -import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' -import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - ServiceAccountSecretError, - verifyAndBuildServiceAccountSecret, -} from '@/lib/credentials/service-account-secret' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { getServiceConfigByProviderId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { captureServerEvent } from '@/lib/posthog/server' + performCreateCredential, + statusForCredentialOrchestrationError, +} from '@/lib/credentials/orchestration/credential-create' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialsAPI') -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - return findExistingCredentialBySourceWith(db, params) -} - -async function findExistingCredentialBySourceTx( - tx: Parameters[0]>[0], - params: ExistingCredentialSourceParams -) { - return findExistingCredentialBySourceWith(tx, params) -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -222,59 +99,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) } - const whereClauses = [eq(credential.workspaceId, workspaceId)] - - if (type) { - whereClauses.push(eq(credential.type, type)) - } - if (providerId) { - whereClauses.push(eq(credential.providerId, providerId)) - } - - const isWorkspaceAdmin = workspaceAccess.canAdmin - const accessClause = isWorkspaceAdmin - ? or( - isNotNull(credentialMember.id), - inArray(credential.type, SHARED_CREDENTIAL_TYPES), - eq(credential.envOwnerUserId, session.user.id) - ) - : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, session.user.id)) - - const rows = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - description: credential.description, - providerId: credential.providerId, - accountId: credential.accountId, - envKey: credential.envKey, - envOwnerUserId: credential.envOwnerUserId, - createdBy: credential.createdBy, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - memberRole: credentialMember.role, - }) - .from(credential) - .leftJoin( - credentialMember, - and( - eq(credentialMember.credentialId, credential.id), - eq(credentialMember.userId, session.user.id), - eq(credentialMember.status, 'active') - ) - ) - .where(and(...whereClauses, accessClause)) - - const credentials = rows.map(({ memberRole, ...rest }) => ({ - ...rest, - role: - (rest.type === 'env_personal' && rest.envOwnerUserId === session.user.id) || - (isWorkspaceAdmin && isSharedCredentialType(rest.type)) - ? 'admin' - : (memberRole ?? 'member'), - })) + const visible = await listVisibleWorkspaceCredentials({ + workspaceId, + userId: session.user.id, + workspaceAccess, + type, + providerId, + }) + const credentials = visible.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) return NextResponse.json({ credentials }) } catch (error) { @@ -291,438 +123,44 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - try { - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { - workspaceId, - type, - displayName, - description, - providerId, - accountId, - envKey, - envOwnerUserId, - serviceAccountJson, - apiToken, - domain, - id: clientCredentialId, - signingSecret, - botToken, - clientId, - clientSecret, - orgId, - dataCenter, - } = parsed.data.body - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - let resolvedDisplayName = displayName?.trim() ?? '' - const resolvedDescription = description?.trim() || null - let resolvedProviderId: string | null = providerId ?? null - let resolvedAccountId: string | null = accountId ?? null - const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null - let resolvedEnvOwnerUserId: string | null = null - let resolvedEncryptedServiceAccountKey: string | null = null - const extraAuditMetadata: Record = {} - - if (type === 'oauth') { - const [accountRow] = await db - .select({ - id: account.id, - userId: account.userId, - providerId: account.providerId, - accountId: account.accountId, - }) - .from(account) - .where(eq(account.id, accountId!)) - .limit(1) - - if (!accountRow) { - return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 }) - } - - if (accountRow.userId !== session.user.id) { - return NextResponse.json( - { error: 'Only account owners can create oauth credentials for an account' }, - { status: 403 } - ) - } - - if (providerId !== accountRow.providerId) { - return NextResponse.json( - { error: 'providerId does not match the selected OAuth account' }, - { status: 400 } - ) - } - if (!resolvedDisplayName) { - resolvedDisplayName = - getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId - } - } else if (type === 'service_account') { - try { - const secret = await verifyAndBuildServiceAccountSecret(providerId ?? '', { - signingSecret, - botToken, - apiToken, - domain, - serviceAccountJson, - clientId, - clientSecret, - orgId, - dataCenter, - }) - resolvedProviderId = secret.providerId - resolvedAccountId = null - resolvedEnvOwnerUserId = null - if (!resolvedDisplayName) { - resolvedDisplayName = secret.displayName - } - resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey - Object.assign(extraAuditMetadata, secret.auditMetadata) - } catch (error) { - if (error instanceof ServiceAccountSecretError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (type === 'env_personal') { - resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id - if (resolvedEnvOwnerUserId !== session.user.id) { - return NextResponse.json( - { error: 'Only the current user can create personal env credentials for themselves' }, - { status: 403 } - ) - } - resolvedProviderId = null - resolvedAccountId = null - resolvedDisplayName = resolvedEnvKey || '' - } else { - resolvedProviderId = null - resolvedAccountId = null - resolvedEnvOwnerUserId = null - resolvedDisplayName = resolvedEnvKey || '' - } - - if (!resolvedDisplayName) { - return NextResponse.json({ error: 'Display name is required' }, { status: 400 }) - } - - const existingCredential = await findExistingCredentialBySource({ - workspaceId, - type, - accountId: resolvedAccountId, - envKey: resolvedEnvKey, - envOwnerUserId: resolvedEnvOwnerUserId, - displayName: resolvedDisplayName, - providerId: resolvedProviderId, + const parsed = await parseRequest( + createWorkspaceCredentialContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + + const result = await performCreateCredential({ + ...parsed.data.body, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + + if (!result.success) { + logger.warn(`[${requestId}] Credential create rejected`, { + errorCode: result.errorCode, + providerErrorCode: result.providerErrorCode, }) - - if (existingCredential) { - // A retried custom-bot create with the SAME pre-generated id is an - // idempotent replay and falls through to the normal existing-credential - // path. Any other name collision must fail loudly: returning the existing - // row as success would orphan the new id already embedded in the user's - // Slack Request URL (Slack would post to a URL no credential resolves). - if ( - resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && - clientCredentialId && - existingCredential.id !== clientCredentialId - ) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, - }, - { status: 409 } - ) - } - - // Token service-account creates always carry a fresh token that must be - // stored — falling through to the existing-credential path would return - // the old credential as success and silently drop the submitted token. - if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, - }, - { status: 409 } - ) - } - - const access = await getCredentialActorContext(existingCredential.id, session.user.id, { - workspaceAccess, - }) - - if (!access.member && !access.isAdmin) { - return NextResponse.json( - { error: 'A credential with this source already exists in this workspace' }, - { status: 409 } - ) - } - - const canUpdateExistingCredential = access.isAdmin - const shouldUpdateDisplayName = - type === 'oauth' && - resolvedDisplayName && - resolvedDisplayName !== existingCredential.displayName - const shouldUpdateDescription = - typeof description !== 'undefined' && - (existingCredential.description ?? null) !== resolvedDescription - - if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) { - await db - .update(credential) - .set({ - ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}), - ...(shouldUpdateDescription ? { description: resolvedDescription } : {}), - updatedAt: new Date(), - }) - .where(eq(credential.id, existingCredential.id)) - - const [updatedCredential] = await db - .select() - .from(credential) - .where(eq(credential.id, existingCredential.id)) - .limit(1) - - return NextResponse.json( - { credential: updatedCredential ?? existingCredential }, - { status: 200 } - ) - } - - return NextResponse.json({ credential: existingCredential }, { status: 200 }) - } - - const now = new Date() - // Honor a client-supplied id only for custom Slack bots — the setup modal - // shows the ingest URL `/api/webhooks/slack/custom/{id}` before secrets exist. - const credentialId = - resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId - ? clientCredentialId - : generateId() - - const creationResult = await db.transaction(async (tx) => { - /** - * Discover the organization lock scope inside this transaction, then - * acquire the same organization → user → membership locks as org - * removal/transfer and re-authorize from the transaction before writing. - * - * If this insert wins, transfer sees the new source-owned personal - * credential and blocks. If transfer wins, its permission/member cleanup - * is visible to the authoritative re-read below and the insert is - * refused. - */ - const plannedContext = await getCredentialCreationWorkspaceContext({ - executor: tx, - workspaceId, - userId: session.user.id, - }) - if (!plannedContext) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - - await acquireOrganizationUserMutationLocks(tx, { - userId: session.user.id, - organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], - }) - - const currentContext = await getCredentialCreationWorkspaceContext({ - executor: tx, - workspaceId, - userId: session.user.id, - forUpdate: true, - }) - if (!currentContext) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - if (currentContext.organizationId !== plannedContext.organizationId) { - return { - success: false as const, - status: 409 as const, - error: 'Workspace organization changed while creating the credential. Please retry.', - } - } - if (!currentContext.canWrite) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - - // service_account has no DB-level unique index on (workspaceId, providerId, - // displayName), so we re-check inside the tx. OAuth/env_* are guarded by - // partial unique indexes and fall through to the 23505 handler below. - if (type === 'service_account') { - const innerExisting = await findExistingCredentialBySourceTx(tx, { - workspaceId, - type, - displayName: resolvedDisplayName, - providerId: resolvedProviderId, - }) - if (innerExisting) throw new DuplicateCredentialError() - } - - await tx.insert(credential).values({ - id: credentialId, - workspaceId, - type, - displayName: resolvedDisplayName, - description: resolvedDescription, - providerId: resolvedProviderId, - accountId: resolvedAccountId, - envKey: resolvedEnvKey, - envOwnerUserId: resolvedEnvOwnerUserId, - encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, - createdBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { - if (currentContext.memberUserIds.length > 0) { - for (const memberUserId of currentContext.memberUserIds) { - const isAdmin = memberUserId === session.user.id - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: memberUserId, - role: isAdmin ? 'admin' : 'member', - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - } - } - } else { - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: session.user.id, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - } - - return { success: true as const } + const status = statusForCredentialOrchestrationError(result.errorCode, { + providerUnavailable: result.providerUnavailable, }) - if (!creationResult.success) { - return NextResponse.json({ error: creationResult.error }, { status: creationResult.status }) - } - - const [created] = await db - .select() - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - captureServerEvent( - session.user.id, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } + return NextResponse.json( + result.providerErrorCode + ? { code: result.providerErrorCode, error: result.error } + : { error: result.error }, + { status } ) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this route's own keys stay - // authoritative and can never be shadowed, matching the update path in - // `lib/credentials/orchestration`. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request, - }) - - return NextResponse.json({ credential: created }, { status: 201 }) - } catch (error: unknown) { - if (error instanceof AtlassianValidationError) { - logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, { - code: error.code, - upstreamStatus: error.status, - ...error.logDetail, - }) - return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) - } - if (error instanceof TokenServiceAccountValidationError) { - logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, { - code: error.code, - upstreamStatus: error.status, - ...error.logDetail, - }) - // A provider outage is an infra failure, not a bad request — mirror the - // runtime token route so monitoring sees a 502, not a 400. - const status = error.code === 'provider_unavailable' ? 502 : 400 - return NextResponse.json({ code: error.code, error: error.code }, { status }) - } - if (error instanceof DuplicateCredentialError) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: 'A credential with that name already exists in this workspace.', - }, - { status: 409 } - ) - } - const pgCode = getPostgresErrorCode(error) - if (pgCode === '23505') { - return NextResponse.json( - { error: 'A credential with this source already exists' }, - { status: 409 } - ) - } - if (pgCode === '23503') { - return NextResponse.json( - { error: 'Invalid credential reference or membership target' }, - { status: 400 } - ) - } - if (pgCode === '23514') { - return NextResponse.json( - { error: 'Credential source data failed validation checks' }, - { status: 400 } - ) - } - const errAsRecord = - typeof error === 'object' && error !== null ? (error as Record) : {} - logger.error(`[${requestId}] Credential create failure details`, { - code: pgCode, - detail: errAsRecord.detail, - constraint: errAsRecord.constraint, - table: errAsRecord.table, - message: errAsRecord.message, - }) - logger.error(`[${requestId}] Failed to create credential`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + // An existing credential matched the source: an idempotent replay, not a create. + return NextResponse.json( + { credential: result.credential }, + { status: result.created ? 201 : 200 } + ) }) diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts deleted file mode 100644 index a1200ec18c9..00000000000 --- a/apps/sim/app/api/files/multipart/route.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * @vitest-environment node - */ -import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsUsingCloudStorage, - mockGetStorageProvider, - mockGetStorageConfig, - mockCompleteS3MultipartUpload, - mockCompleteBlobMultipartUpload, - mockDeriveBlobBlockId, - mockVerifyUploadToken, - mockSignUploadToken, -} = vi.hoisted(() => ({ - mockIsUsingCloudStorage: vi.fn(), - mockGetStorageProvider: vi.fn(), - mockGetStorageConfig: vi.fn(), - mockCompleteS3MultipartUpload: vi.fn(), - mockCompleteBlobMultipartUpload: vi.fn(), - mockDeriveBlobBlockId: vi.fn(), - mockVerifyUploadToken: vi.fn(), - mockSignUploadToken: vi.fn(), -})) - -vi.mock('@/lib/uploads', () => ({ - isUsingCloudStorage: mockIsUsingCloudStorage, - getStorageProvider: mockGetStorageProvider, - getStorageConfig: mockGetStorageConfig, -})) - -vi.mock('@/lib/uploads/core/upload-token', () => ({ - signUploadToken: mockSignUploadToken, - verifyUploadToken: mockVerifyUploadToken, -})) - -vi.mock('@/lib/uploads/providers/s3/client', () => ({ - completeS3MultipartUpload: mockCompleteS3MultipartUpload, - initiateS3MultipartUpload: mockInitiateS3MultipartUpload, - getS3MultipartPartUrls: vi.fn(), - abortS3MultipartUpload: vi.fn(), -})) - -vi.mock('@/lib/uploads/providers/blob/client', () => ({ - completeMultipartUpload: mockCompleteBlobMultipartUpload, - deriveBlobBlockId: mockDeriveBlobBlockId, - initiateMultipartUpload: vi.fn(), - getMultipartPartUrls: vi.fn(), - abortMultipartUpload: vi.fn(), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const { mockCheckStorageQuota, mockInitiateS3MultipartUpload, mockResolveStorageBillingContext } = - vi.hoisted(() => ({ - mockCheckStorageQuota: vi.fn(), - mockInitiateS3MultipartUpload: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), - })) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuota, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -import { POST } from '@/app/api/files/multipart/route' - -const STORAGE_CONTEXT = { - workspaceId: 'ws-1', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -const tokenPayload = { - uploadId: 'upload-1', - key: 'workspace/ws-1/123-abc-file.bin', - userId: 'user-1', - workspaceId: 'ws-1', - context: 'workspace' as const, -} - -const makeRequest = (action: string, body: unknown) => - new NextRequest(`http://localhost/api/files/multipart?action=${action}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -describe('POST /api/files/multipart action=complete', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockIsUsingCloudStorage.mockReturnValue(true) - mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' }) - mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload }) - mockSignUploadToken.mockReturnValue('signed-token') - mockCompleteS3MultipartUpload.mockResolvedValue({ - location: 'loc', - path: '/api/files/serve/...', - key: tokenPayload.key, - }) - mockCompleteBlobMultipartUpload.mockResolvedValue({ - location: 'loc', - path: '/api/files/serve/...', - key: tokenPayload.key, - }) - mockDeriveBlobBlockId.mockImplementation( - (n: number) => `block-${n.toString().padStart(6, '0')}` - ) - }) - - it('rejects parts without partNumber', async () => { - mockGetStorageProvider.mockReturnValue('s3') - const res = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [{ etag: 'abc' }], - }) - ) - expect(res.status).toBe(400) - expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled() - }) - - it('S3 path requires etag and forwards { ETag, PartNumber }', async () => { - mockGetStorageProvider.mockReturnValue('s3') - - const missingEtag = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [{ partNumber: 1 }], - }) - ) - expect(missingEtag.status).toBe(500) - - mockCompleteS3MultipartUpload.mockClear() - - const ok = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [ - { partNumber: 1, etag: 'aaa' }, - { partNumber: 2, etag: 'bbb' }, - ], - }) - ) - expect(ok.status).toBe(200) - expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith( - tokenPayload.key, - tokenPayload.uploadId, - [ - { ETag: 'aaa', PartNumber: 1 }, - { ETag: 'bbb', PartNumber: 2 }, - ], - expect.any(Object) - ) - }) - - it('Blob path derives blockId from partNumber and ignores etag', async () => { - mockGetStorageProvider.mockReturnValue('blob') - mockGetStorageConfig.mockReturnValue({ - containerName: 'c', - accountName: 'a', - accountKey: 'k', - }) - - const res = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }], - }) - ) - - expect(res.status).toBe(200) - expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1) - expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2) - expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith( - tokenPayload.key, - [ - { partNumber: 1, blockId: 'block-000001' }, - { partNumber: 2, blockId: 'block-000002' }, - ], - expect.objectContaining({ containerName: 'c' }) - ) - }) - - it('returns 403 when token is invalid', async () => { - mockGetStorageProvider.mockReturnValue('s3') - mockVerifyUploadToken.mockReturnValueOnce({ valid: false }) - const res = await POST( - makeRequest('complete', { - uploadToken: 'bad', - parts: [{ partNumber: 1, etag: 'a' }], - }) - ) - expect(res.status).toBe(403) - }) - - it('batch complete normalizes per upload', async () => { - mockGetStorageProvider.mockReturnValue('s3') - const res = await POST( - makeRequest('complete', { - uploads: [ - { - uploadToken: 'tok-a', - parts: [{ partNumber: 1, etag: 'aaa' }], - }, - { - uploadToken: 'tok-b', - parts: [{ partNumber: 1, etag: 'bbb' }], - }, - ], - }) - ) - expect(res.status).toBe(200) - expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2) - }) -}) - -describe('POST /api/files/multipart action=initiate quota enforcement', () => { - const makeInitiateRequest = (body: unknown) => - new NextRequest('http://localhost/api/files/multipart?action=initiate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockIsUsingCloudStorage.mockReturnValue(true) - mockGetStorageProvider.mockReturnValue('s3') - mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' }) - mockSignUploadToken.mockReturnValue('signed-token') - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - mockCheckStorageQuota.mockResolvedValue({ allowed: true }) - mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' }) - }) - - it('blocks upload when fileSize: 0 exceeds quota', async () => { - mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' }) - - const res = await makeInitiateRequest({ - fileName: 'file.bin', - contentType: 'application/octet-stream', - fileSize: 0, - workspaceId: 'ws-1', - context: 'knowledge-base', - }) - - const response = await POST(res) - expect(response.status).toBe(413) - const body = await response.json() - expect(body.error).toContain('Storage limit exceeded') - }) - - it('allows quota-enforced contexts that pass the quota check', async () => { - const res = await makeInitiateRequest({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 99999, - workspaceId: 'ws-1', - context: 'knowledge-base', - }) - - const response = await POST(res) - expect(response.status).toBe(200) - expect(mockResolveStorageBillingContext).toHaveBeenCalledWith('ws-1') - expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, 99999) - expect(mockInitiateS3MultipartUpload).toHaveBeenCalled() - }) - - it('keeps mothership chat uploads outside workspace storage quotas', async () => { - mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' }) - - const res = await makeInitiateRequest({ - fileName: 'conversation.bin', - contentType: 'application/octet-stream', - fileSize: 99999, - workspaceId: 'ws-1', - context: 'mothership', - }) - - const response = await POST(res) - expect(response.status).toBe(200) - expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mockCheckStorageQuota).not.toHaveBeenCalled() - expect(mockInitiateS3MultipartUpload).toHaveBeenCalled() - }) - - it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])( - 'rejects quota-exempt context %s — not allowed via the multipart endpoint', - async (context) => { - const res = await makeInitiateRequest({ - fileName: 'asset.png', - contentType: 'image/png', - fileSize: 100 * 1024 * 1024 * 1024, - workspaceId: 'ws-1', - context, - }) - - const response = await POST(res) - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toMatch(/invalid storage context/i) - expect(mockCheckStorageQuota).not.toHaveBeenCalled() - expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled() - } - ) -}) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts deleted file mode 100644 index 07fbac67361..00000000000 --- a/apps/sim/app/api/files/multipart/route.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { - abortMultipartUploadContract, - type CompleteMultipartBody, - completeMultipartUploadContract, - getMultipartPartUrlsContract, - initiateMultipartUploadContract, - multipartActionSchema, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getStorageConfig, - getStorageProvider, - isUsingCloudStorage, - type StorageContext, -} from '@/lib/uploads' -import { deleteFile } from '@/lib/uploads/core/storage-service' -import { - signUploadToken, - type UploadTokenPayload, - verifyUploadToken, -} from '@/lib/uploads/core/upload-token' -import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('MultipartUploadAPI') - -/** - * Contexts the multipart endpoint accepts. Small public assets and internal logs - * are excluded because they have no large-file flow. Mothership remains - * available for large chat attachments but is quota-exempt because chat uploads - * do not count as durable workspace-file storage. Every other accepted context - * is quota-enforced below. - */ -const ALLOWED_UPLOAD_CONTEXTS = new Set([ - 'knowledge-base', - 'chat', - 'copilot', - 'mothership', - 'execution', - 'workspace', -]) - -/** - * Unified part identity sent by the client when completing a multipart upload. - * `etag` is required for S3 and GCS (CompleteMultipartUpload). For Azure the - * server derives the block id from `partNumber` via {@link deriveBlobBlockId}. - */ -interface ClientCompletedPart { - partNumber: number - etag?: string -} - -const isClientCompletedParts = (value: unknown): value is ClientCompletedPart[] => - Array.isArray(value) && - value.every( - (p) => - p !== null && - typeof p === 'object' && - typeof (p as ClientCompletedPart).partNumber === 'number' && - ((p as ClientCompletedPart).etag === undefined || - typeof (p as ClientCompletedPart).etag === 'string') - ) - -const buildS3CustomConfig = (config: StorageConfig) => - config.bucket && config.region ? { bucket: config.bucket, region: config.region } : undefined - -const buildBlobCustomConfig = (config: StorageConfig) => ({ - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, -}) - -const buildGcsCustomConfig = (config: StorageConfig) => - config.bucket ? { bucket: config.bucket } : undefined - -const verifyTokenForUser = (token: string | undefined, userId: string) => { - if (!token || typeof token !== 'string') { - return null - } - const result = verifyUploadToken(token) - if (!result.valid || result.payload.userId !== userId) { - return null - } - return result.payload -} - -/** - * Record a trusted storage-key -> workspace ownership binding for completed - * knowledge-base uploads. KB file authorization resolves the owning workspace - * from this binding, so every KB object must have one. No-op for other contexts. - */ -const recordKnowledgeBaseOwnership = async ( - payload: UploadTokenPayload, - key: string -): Promise => { - if (payload.context !== 'knowledge-base' || !payload.workspaceId) { - return - } - await recordKnowledgeBaseFileOwnership({ - key, - userId: payload.userId, - workspaceId: payload.workspaceId, - originalName: payload.fileName ?? key.split('/').pop() ?? key, - contentType: payload.contentType ?? 'application/octet-stream', - size: typeof payload.fileSize === 'number' ? payload.fileSize : 0, - }) -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - const actionParam = request.nextUrl.searchParams.get('action') - const actionResult = multipartActionSchema.safeParse(actionParam) - const action = actionResult.success ? actionResult.data : null - - if (!isUsingCloudStorage()) { - return NextResponse.json( - { - error: - 'Multipart upload is only available with cloud storage (S3, Azure Blob, or Google Cloud Storage)', - }, - { status: 400 } - ) - } - - const storageProvider = getStorageProvider() - - switch (action) { - case 'initiate': { - const parsed = await parseRequest( - initiateMultipartUploadContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data = parsed.data.body - const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data - - if (!workspaceId || typeof workspaceId !== 'string') { - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - - if (!ALLOWED_UPLOAD_CONTEXTS.has(context as StorageContext)) { - return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 }) - } - const storageContext = context as StorageContext - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const config = getStorageConfig(storageContext) - - if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) { - const { checkStorageQuotaForBillingContext, resolveStorageBillingContext } = await import( - '@/lib/billing/storage' - ) - const storageBillingContext = await resolveStorageBillingContext(workspaceId) - const quotaCheck = await checkStorageQuotaForBillingContext( - storageBillingContext, - fileSize ?? 0 - ) - if (!quotaCheck.allowed) { - return NextResponse.json( - { error: quotaCheck.error || 'Storage limit exceeded' }, - { status: 413 } - ) - } - } - - let customKey: string | undefined - if (context === 'workspace' || context === 'mothership') { - const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types') - if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) { - return NextResponse.json( - { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, - { status: 413 } - ) - } - - const { generateWorkspaceFileKey } = await import( - '@/lib/uploads/contexts/workspace/workspace-file-manager' - ) - customKey = generateWorkspaceFileKey(workspaceId, fileName) - } else if (context === 'execution') { - const workflowId = (data as { workflowId?: unknown }).workflowId - const executionId = (data as { executionId?: unknown }).executionId - if (typeof workflowId !== 'string' || !workflowId.trim()) { - return NextResponse.json( - { error: 'workflowId is required for execution uploads' }, - { status: 400 } - ) - } - if (typeof executionId !== 'string' || !executionId.trim()) { - return NextResponse.json( - { error: 'executionId is required for execution uploads' }, - { status: 400 } - ) - } - const { generateExecutionFileKey } = await import( - '@/lib/uploads/contexts/execution/utils' - ) - customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) - } - - let uploadId: string - let key: string - - if (storageProvider === 's3') { - const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - const result = await initiateS3MultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: buildS3CustomConfig(config), - customKey, - purpose: context, - }) - uploadId = result.uploadId - key = result.key - } else if (storageProvider === 'blob') { - const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - const result = await initiateMultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: buildBlobCustomConfig(config), - customKey, - }) - uploadId = result.uploadId - key = result.key - } else if (storageProvider === 'gcs') { - const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - const result = await initiateGcsMultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: buildGcsCustomConfig(config), - customKey, - purpose: context, - }) - uploadId = result.uploadId - key = result.key - } else { - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } - ) - } - - const uploadToken = signUploadToken({ - uploadId, - key, - userId, - workspaceId, - context: storageContext, - fileName, - contentType, - ...(typeof fileSize === 'number' ? { fileSize } : {}), - }) - - logger.info( - `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}` - ) - - return NextResponse.json({ uploadId, key, uploadToken }) - } - - case 'get-part-urls': { - const parsed = await parseRequest( - getMultipartPartUrlsContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data = parsed.data.body - const { partNumbers } = data - - const tokenPayload = verifyTokenForUser(data.uploadToken, userId) - if (!tokenPayload) { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) - } - - const { uploadId, key, context } = tokenPayload - const config = getStorageConfig(context) - - if (storageProvider === 's3') { - const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') - const presignedUrls = await getS3MultipartPartUrls( - key, - uploadId, - partNumbers, - buildS3CustomConfig(config) - ) - return NextResponse.json({ presignedUrls }) - } - if (storageProvider === 'blob') { - const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') - const presignedUrls = await getMultipartPartUrls( - key, - partNumbers, - buildBlobCustomConfig(config) - ) - return NextResponse.json({ presignedUrls }) - } - if (storageProvider === 'gcs') { - const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') - const presignedUrls = await getGcsMultipartPartUrls( - key, - uploadId, - partNumbers, - buildGcsCustomConfig(config) - ) - return NextResponse.json({ presignedUrls }) - } - - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } - ) - } - - case 'complete': { - const parsed = await parseRequest( - completeMultipartUploadContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data: CompleteMultipartBody = parsed.data.body - - const s3Module = - storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null - const blobModule = - storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null - const gcsModule = - storageProvider === 'gcs' ? await import('@/lib/uploads/providers/gcs/client') : null - - const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => { - const { uploadId, key, context } = payload - const config = getStorageConfig(context) - - let completed: { location: string; path: string; key: string } - if (storageProvider === 's3' && s3Module) { - const { completeS3MultipartUpload } = s3Module - const s3Parts = parts.map((p) => { - if (!p.etag) { - throw new Error(`Missing etag for S3 part ${p.partNumber}`) - } - return { ETag: p.etag, PartNumber: p.partNumber } - }) - completed = await completeS3MultipartUpload( - key, - uploadId, - s3Parts, - buildS3CustomConfig(config) - ) - } else if (storageProvider === 'blob' && blobModule) { - const { completeMultipartUpload, deriveBlobBlockId } = blobModule - const blobParts = parts.map((p) => ({ - partNumber: p.partNumber, - blockId: deriveBlobBlockId(p.partNumber), - })) - completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config)) - } else if (storageProvider === 'gcs' && gcsModule) { - const { completeGcsMultipartUpload } = gcsModule - const gcsParts = parts.map((p) => { - if (!p.etag) { - throw new Error(`Missing etag for GCS part ${p.partNumber}`) - } - return { ETag: p.etag, PartNumber: p.partNumber } - }) - completed = await completeGcsMultipartUpload( - key, - uploadId, - gcsParts, - buildGcsCustomConfig(config) - ) - } else { - throw new Error(`Unsupported storage provider: ${storageProvider}`) - } - - try { - await recordKnowledgeBaseOwnership(payload, completed.key) - } catch (error) { - // The object is committed, but without an ownership binding a KB file - // is unreadable and undeletable via the KB paths. Remove the orphan - // best-effort and surface a retryable error so the client re-uploads. - if (payload.context === 'knowledge-base') { - await deleteFile({ key: completed.key, context: 'knowledge-base' }).catch(() => {}) - } - throw error - } - - return { - success: true as const, - location: completed.location, - path: completed.path, - key: completed.key, - } - } - - if ('uploads' in data && Array.isArray(data.uploads)) { - const verified: Array<{ payload: UploadTokenPayload; parts: ClientCompletedPart[] }> = [] - for (const upload of data.uploads) { - const payload = verifyTokenForUser(upload.uploadToken, userId) - if (!payload) { - return NextResponse.json( - { error: 'Invalid or expired upload token' }, - { status: 403 } - ) - } - if (!isClientCompletedParts(upload.parts)) { - return NextResponse.json( - { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, - { status: 400 } - ) - } - verified.push({ payload, parts: upload.parts }) - } - - const results = await Promise.all( - verified.map(({ payload, parts }) => completeOne(payload, parts)) - ) - - logger.info(`Completed ${verified.length} multipart uploads`) - return NextResponse.json({ results }) - } - - const single = data - const tokenPayload = verifyTokenForUser(single.uploadToken, userId) - if (!tokenPayload) { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) - } - if (!isClientCompletedParts(single.parts)) { - return NextResponse.json( - { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, - { status: 400 } - ) - } - - const result = await completeOne(tokenPayload, single.parts) - logger.info( - `Completed ${storageProvider} multipart upload for key ${tokenPayload.key} (context: ${tokenPayload.context})` - ) - return NextResponse.json(result) - } - - case 'abort': { - const parsed = await parseRequest( - abortMultipartUploadContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data = parsed.data.body - const tokenPayload = verifyTokenForUser(data.uploadToken, userId) - if (!tokenPayload) { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) - } - - const { uploadId, key, context } = tokenPayload - const config = getStorageConfig(context) - - if (storageProvider === 's3') { - const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - await abortS3MultipartUpload(key, uploadId, buildS3CustomConfig(config)) - logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`) - } else if (storageProvider === 'blob') { - const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - await abortMultipartUpload(key, buildBlobCustomConfig(config)) - logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`) - } else if (storageProvider === 'gcs') { - const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - await abortGcsMultipartUpload(key, uploadId, buildGcsCustomConfig(config)) - logger.info(`Aborted GCS multipart upload for key ${key} (context: ${context})`) - } else { - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } - ) - } - - return NextResponse.json({ success: true }) - } - - default: - return NextResponse.json( - { error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' }, - { status: 400 } - ) - } - } catch (error) { - logger.error('Multipart upload error:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Multipart upload failed') }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/files/presigned/batch/route.test.ts b/apps/sim/app/api/files/presigned/batch/route.test.ts deleted file mode 100644 index 988fae9cce4..00000000000 --- a/apps/sim/app/api/files/presigned/batch/route.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Tests for the batch presigned upload API route - * - * @vitest-environment node - */ - -import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockValidateFileType, - mockGetUserEntityPermissions, - mockRecordKnowledgeBaseFileOwnershipMany, -} = vi.hoisted(() => ({ - mockValidateFileType: vi.fn().mockReturnValue(null), - mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'), - mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock('@/lib/uploads/config', () => ({ - getServeStoragePrefix: () => 's3', -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/utils/validation', () => ({ - validateFileType: mockValidateFileType, - SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) - -vi.mock('@/lib/uploads/server/metadata', () => ({ - recordKnowledgeBaseFileOwnershipMany: mockRecordKnowledgeBaseFileOwnershipMany, -})) - -import { POST } from '@/app/api/files/presigned/batch/route' - -const KB_QUERY = 'type=knowledge-base&workspaceId=ws-1' - -const buildRequest = (query: string, files?: unknown) => - new NextRequest(`http://localhost:3000/api/files/presigned/batch?${query}`, { - method: 'POST', - body: JSON.stringify({ - files: files ?? [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }], - }), - }) - -describe('/api/files/presigned/batch', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockValidateFileType.mockReturnValue(null) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined) - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) - storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation( - async (files: Array<{ fileName: string }>, context: string) => - files.map((file) => ({ - url: `https://example.com/${context}/${file.fileName}`, - key: `${context}/${file.fileName}`, - })) - ) - }) - - it('returns 401 when the caller has no session', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const response = await POST(buildRequest(KB_QUERY)) - - expect(response.status).toBe(401) - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it.each([ - 'workspace-logos', - 'profile-pictures', - 'execution', - 'mothership', - 'chat', - 'copilot', - 'workspace', - ])('refuses to presign the %s context', async (type) => { - const response = await POST(buildRequest(`type=${type}&workspaceId=ws-1`)) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('Invalid type parameter') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it('returns 400 when type is missing', async () => { - const response = await POST(buildRequest('workspaceId=ws-1')) - - expect(response.status).toBe(400) - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it('returns 400 when workspaceId is missing', async () => { - const response = await POST(buildRequest('type=knowledge-base')) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('workspaceId') - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it.each([['read'], [null]])( - 'returns 403 when the caller has %s access to the workspace', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - - const response = await POST(buildRequest(KB_QUERY)) - - expect(response.status).toBe(403) - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - } - ) - - it('authorizes the workspace before returning the local-storage fallback', async () => { - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) - mockGetUserEntityPermissions.mockResolvedValue('read') - - const response = await POST(buildRequest(KB_QUERY)) - - expect(response.status).toBe(403) - }) - - it('rejects unsupported file types before minting any URL', async () => { - mockValidateFileType.mockReturnValue({ - code: 'UNSUPPORTED_FILE_TYPE', - message: 'Unsupported file type: html.', - supportedTypes: ['pdf'], - }) - - const response = await POST( - buildRequest(KB_QUERY, [{ fileName: 'poc.html', contentType: 'text/html', fileSize: 41 }]) - ) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.code).toBe('UNSUPPORTED_FILE_TYPE') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it('mints knowledge-base URLs and records workspace ownership for a permitted caller', async () => { - const response = await POST(buildRequest(KB_QUERY)) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).toHaveBeenCalledWith( - [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }], - 'knowledge-base', - 'user-1', - 3600 - ) - expect(data.files).toHaveLength(1) - expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf') - expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base') - expect(data.directUploadSupported).toBe(true) - expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([ - { - key: 'knowledge-base/doc.pdf', - userId: 'user-1', - workspaceId: 'ws-1', - originalName: 'doc.pdf', - contentType: 'application/pdf', - size: 1024, - }, - ]) - }) - - it('returns the fallback response when cloud storage is not configured', async () => { - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) - - const response = await POST(buildRequest(KB_QUERY)) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.directUploadSupported).toBe(false) - expect(data.files[0].presignedUrl).toBe('') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts deleted file mode 100644 index 226fdc9ed87..00000000000 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - batchPresignedUploadBodyContract, - batchPresignedUploadTypeSchema, - batchPresignedUploadTypes, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getServeStoragePrefix } from '@/lib/uploads/config' -import { - generateBatchPresignedUploadUrls, - hasCloudStorage, -} from '@/lib/uploads/core/storage-service' -import { recordKnowledgeBaseFileOwnershipMany } from '@/lib/uploads/server/metadata' -import { validateFileType } from '@/lib/uploads/utils/validation' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { createErrorResponse } from '@/app/api/files/utils' - -const logger = createLogger('BatchPresignedUploadAPI') - -/** - * Mints presigned upload URLs for knowledge-base ingest, the only context this - * endpoint can authorize. Every request must name a workspace the caller has - * write access to; other storage contexts are rejected rather than presigned, - * because a presigned PUT is a write grant into a bucket served from a trusted - * origin. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - batchPresignedUploadBodyContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid request data') }, - { status: 400 } - ), - invalidJsonResponse: () => - NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { files } = parsed.data.body - - const uploadTypeParam = request.nextUrl.searchParams.get('type') - if (!uploadTypeParam) { - return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 }) - } - - const uploadTypeResult = batchPresignedUploadTypeSchema.safeParse(uploadTypeParam) - if (!uploadTypeResult.success) { - return NextResponse.json( - { - error: `Invalid type parameter. Must be one of: ${batchPresignedUploadTypes.join(', ')}`, - }, - { status: 400 } - ) - } - - const uploadType = uploadTypeResult.data - const sessionUserId = session.user.id - - for (const file of files) { - const fileValidationError = validateFileType(file.fileName, file.contentType) - if (fileValidationError) { - return NextResponse.json( - { - error: fileValidationError.message, - code: fileValidationError.code, - supportedTypes: fileValidationError.supportedTypes, - }, - { status: 400 } - ) - } - } - - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - return NextResponse.json( - { error: 'workspaceId query parameter is required for knowledge-base uploads' }, - { status: 400 } - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } - - if (!hasCloudStorage()) { - logger.info( - `Local storage detected - batch presigned URLs not available, client will use API fallback` - ) - return NextResponse.json({ - files: files.map((file) => ({ - fileName: file.fileName, - presignedUrl: '', // Empty URL signals fallback to API upload - fileInfo: { - path: '', - key: '', - name: file.fileName, - size: file.fileSize, - type: file.contentType, - }, - directUploadSupported: false, - })), - directUploadSupported: false, - }) - } - - logger.info(`Generating batch ${uploadType} presigned URLs for ${files.length} files`) - - const startTime = Date.now() - - const presignedUrls = await generateBatchPresignedUploadUrls( - files.map((file) => ({ - fileName: file.fileName, - contentType: file.contentType, - fileSize: file.fileSize, - })), - uploadType, - sessionUserId, - 3600 // 1 hour - ) - - const duration = Date.now() - startTime - logger.info( - `Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)` - ) - - await recordKnowledgeBaseFileOwnershipMany( - presignedUrls.map((urlResponse, index) => ({ - key: urlResponse.key, - userId: sessionUserId, - workspaceId, - originalName: files[index].fileName, - contentType: files[index].contentType, - size: files[index].fileSize, - })) - ) - - const storagePrefix = getServeStoragePrefix() - - return NextResponse.json({ - files: presignedUrls.map((urlResponse, index) => { - const finalPath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(urlResponse.key)}?context=${uploadType}` - const file = files[index] - - return { - fileName: file.fileName, - presignedUrl: urlResponse.url, - fileInfo: { - path: finalPath, - key: urlResponse.key, - name: file.fileName, - size: file.fileSize, - type: file.contentType, - }, - uploadHeaders: urlResponse.uploadHeaders, - directUploadSupported: true, - } - }), - directUploadSupported: true, - }) - } catch (error) { - logger.error('Error generating batch presigned URLs:', error) - return createErrorResponse( - error instanceof Error ? error : new Error('Failed to generate batch presigned URLs') - ) - } -}) diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts deleted file mode 100644 index 3674ac70b76..00000000000 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * Tests for file presigned API route - * - * @vitest-environment node - */ - -import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockVerifyFileAccess, - mockVerifyWorkspaceFileAccess, - mockUseBlobStorage, - mockUseS3Storage, - mockGetStorageConfig, - mockIsUsingCloudStorage, - mockGetStorageProvider, - mockValidateFileType, - mockValidateAttachmentFileType, - mockGenerateCopilotUploadUrl, - mockIsImageFileType, - mockGetStorageProviderUploads, - mockIsUsingCloudStorageUploads, - mockGetUserEntityPermissions, - mockGenerateWorkspaceFileKey, - mockGenerateExecutionFileKey, - mockInsertFileMetadata, - mockCheckStorageQuotaForBillingContext, - mockDecrementStorageUsageForBillingContext, - mockIncrementStorageUsageForBillingContext, - mockResolveStorageBillingContext, -} = vi.hoisted(() => ({ - mockVerifyFileAccess: vi.fn().mockResolvedValue(true), - mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - mockUseBlobStorage: { value: false }, - mockUseS3Storage: { value: true }, - mockGetStorageConfig: vi.fn(), - mockIsUsingCloudStorage: vi.fn(), - mockGetStorageProvider: vi.fn(), - mockValidateFileType: vi.fn().mockReturnValue(null), - mockValidateAttachmentFileType: vi.fn().mockReturnValue(null), - mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({ - url: 'https://example.com/presigned-url', - key: 'copilot/test-key.txt', - }), - mockIsImageFileType: vi.fn().mockReturnValue(true), - mockGetStorageProviderUploads: vi.fn(), - mockIsUsingCloudStorageUploads: vi.fn(), - mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'), - mockGenerateWorkspaceFileKey: vi.fn( - (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` - ), - mockGenerateExecutionFileKey: vi.fn( - (ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) => - `execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}` - ), - mockInsertFileMetadata: vi.fn().mockResolvedValue({ id: 'wf_test' }), - mockCheckStorageQuotaForBillingContext: vi.fn(), - mockDecrementStorageUsageForBillingContext: vi.fn(), - mockIncrementStorageUsageForBillingContext: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), -})) - -vi.mock('@/app/api/files/authorization', () => ({ - verifyFileAccess: mockVerifyFileAccess, - verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess, -})) - -vi.mock('@/lib/uploads/config', () => ({ - get USE_BLOB_STORAGE() { - return mockUseBlobStorage.value - }, - get USE_S3_STORAGE() { - return mockUseS3Storage.value - }, - UPLOAD_DIR: '/uploads', - getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'), - getStorageConfig: mockGetStorageConfig, - isUsingCloudStorage: mockIsUsingCloudStorage, - getStorageProvider: mockGetStorageProvider, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, - decrementStorageUsageForBillingContext: mockDecrementStorageUsageForBillingContext, - incrementStorageUsageForBillingContext: mockIncrementStorageUsageForBillingContext, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/utils/validation', () => ({ - validateFileType: mockValidateFileType, - validateAttachmentFileType: mockValidateAttachmentFileType, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, -})) - -vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ - generateExecutionFileKey: mockGenerateExecutionFileKey, -})) - -vi.mock('@/lib/uploads/server/metadata', () => ({ - insertFileMetadata: mockInsertFileMetadata, - recordKnowledgeBaseFileOwnership: (ownership: Record) => - mockInsertFileMetadata({ ...ownership, context: 'knowledge-base' }), -})) - -vi.mock('@/lib/uploads/utils/file-utils', () => ({ - isImageFileType: mockIsImageFileType, -})) - -vi.mock('@/lib/uploads', () => ({ - CopilotFiles: { - generateCopilotUploadUrl: mockGenerateCopilotUploadUrl, - }, - getStorageProvider: mockGetStorageProviderUploads, - isUsingCloudStorage: mockIsUsingCloudStorageUploads, -})) - -import { POST } from '@/app/api/files/presigned/route' - -const defaultMockUser = { - id: 'test-user-id', - name: 'Test User', - email: 'test@example.com', -} - -function setupFileApiMocks( - options: { - authenticated?: boolean - storageProvider?: 's3' | 'blob' | 'local' - cloudEnabled?: boolean - } = {} -) { - const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - - if (authenticated) { - authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser }) - } else { - authMockFns.mockGetSession.mockResolvedValue(null) - } - - const useBlobStorage = storageProvider === 'blob' && cloudEnabled - const useS3Storage = storageProvider === 's3' && cloudEnabled - - mockUseBlobStorage.value = useBlobStorage - mockUseS3Storage.value = useS3Storage - - mockGetStorageConfig.mockReturnValue( - useBlobStorage - ? { - accountName: 'testaccount', - accountKey: 'testkey', - connectionString: 'testconnection', - containerName: 'testcontainer', - } - : { - bucket: 'test-bucket', - region: 'us-east-1', - } - ) - mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) - mockGetStorageProvider.mockReturnValue( - storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' - ) - - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockImplementation( - async (opts: { fileName: string; context: string; customKey?: string }) => { - const timestamp = Date.now() - const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') - const key = opts.customKey ?? `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}` - return { - url: 'https://example.com/presigned-url', - key, - } - } - ) - storageServiceMockFns.mockGeneratePresignedDownloadUrl.mockResolvedValue( - 'https://example.com/presigned-url' - ) - - mockValidateFileType.mockReturnValue(null) - mockValidateAttachmentFileType.mockReturnValue(null) - mockGetUserEntityPermissions.mockResolvedValue('admin') - - mockGetStorageProviderUploads.mockReturnValue( - storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' - ) - mockIsUsingCloudStorageUploads.mockReturnValue(cloudEnabled) -} - -describe('/api/files/presigned', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - vi.setSystemTime(new Date('2024-01-01T00:00:00Z')) - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('POST', () => { - it('should return graceful fallback response when cloud storage is not enabled', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.directUploadSupported).toBe(false) - expect(data.presignedUrl).toBe('') - expect(data.fileName).toBe('avatar.png') - expect(data.fileInfo).toBeDefined() - expect(data.fileInfo.name).toBe('avatar.png') - expect(data.fileInfo.size).toBe(1024) - expect(data.fileInfo.type).toBe('image/png') - }) - - it('should return error when fileName is missing', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - contentType: 'text/plain', - fileSize: 1024, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('fileName is required and cannot be empty') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should return error when contentType is missing', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - fileSize: 1024, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('contentType is required and cannot be empty') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should return error when fileSize is invalid', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - contentType: 'text/plain', - fileSize: 0, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('fileSize must be a positive number') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should return error when file size exceeds limit', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit) - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - fileName: 'large-file.txt', - contentType: 'text/plain', - fileSize: largeFileSize, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('exceeds maximum allowed size') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should generate S3 presigned URL successfully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'test avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.presignedUrl).toBe('https://example.com/presigned-url') - expect(data.fileInfo).toMatchObject({ - path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/), - key: expect.stringMatching(/.*test.avatar\.png$/), - name: 'test avatar.png', - size: 1024, - type: 'image/png', - }) - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate knowledge-base S3 presigned URL with kb prefix', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'knowledge-doc.pdf', - contentType: 'application/pdf', - fileSize: 2048, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/) - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate profile-pictures S3 presigned URL with its prefix and direct path', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/) - expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/) - expect(data.presignedUrl).toBeTruthy() - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate Azure Blob presigned URL successfully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'test avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.presignedUrl).toBeTruthy() - expect(typeof data.presignedUrl).toBe('string') - expect(data.fileInfo).toMatchObject({ - key: expect.stringMatching(/.*test.avatar\.png$/), - name: 'test avatar.png', - size: 1024, - type: 'image/png', - }) - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate profile-pictures Azure Blob presigned URL with its prefix and direct path', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/) - expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=profile-pictures$/) - expect(data.presignedUrl).toBeTruthy() - expect(data.directUploadSupported).toBe(true) - }) - - it('should return error for unknown storage provider', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( - new Error('Unknown storage provider: unknown') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBeTruthy() - expect(typeof data.error).toBe('string') - }) - - it('should handle S3 errors gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( - new Error('S3 service unavailable') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBeTruthy() - expect(typeof data.error).toBe('string') - }) - - it('should handle Azure Blob errors gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) - - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( - new Error('Azure service unavailable') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBeTruthy() - expect(typeof data.error).toBe('string') - }) - - it('should handle malformed JSON gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: 'invalid json', - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError) - expect(data.error).toBe('Invalid JSON in request body') // Updated error message - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('rejects the unauthorizable chat context without minting a URL', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'poc.html', - contentType: 'text/html', - fileSize: 41, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('Invalid type parameter') - expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled() - }) - }) - - describe('mothership uploads', () => { - it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'screenshot.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(200) - expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png', { - allowArchives: true, - }) - expect(mockValidateFileType).not.toHaveBeenCalled() - }) - - it('rejects unsupported types when validator returns an error', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockValidateAttachmentFileType.mockReturnValue({ - code: 'UNSUPPORTED_FILE_TYPE', - message: 'Unsupported file type: exe.', - supportedTypes: [], - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'virus.exe', - contentType: 'application/octet-stream', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - expect(response.status).toBe(400) - expect(data.code).toBe('VALIDATION_ERROR') - expect(data.error).toContain('exe') - }) - - it('returns 403 when user lacks workspace write permission', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockGetUserEntityPermissions.mockResolvedValue('read') - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(403) - }) - - it('issues an unbilled pending mothership upload binding', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'screenshot.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) - expect(mockInsertFileMetadata).toHaveBeenCalledWith({ - key: data.fileInfo.key, - userId: 'test-user-id', - workspaceId: 'ws-1', - context: 'mothership', - originalName: 'screenshot.png', - contentType: 'image/png', - size: 4096, - }) - expect(mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled() - expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled() - expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() - }) - - it('returns 500 when insertFileMetadata fails so callers do not get an unauthorizable URL', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockInsertFileMetadata.mockRejectedValueOnce(new Error('DB connection lost')) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'screenshot.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(500) - }) - }) - - describe('execution uploads', () => { - it('uses validateAttachmentFileType — accepts video', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'output.mp4', - contentType: 'video/mp4', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(200) - expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4') - expect(mockValidateFileType).not.toHaveBeenCalled() - }) - - it('rejects when validator returns an error', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockValidateAttachmentFileType.mockReturnValue({ - code: 'UNSUPPORTED_FILE_TYPE', - message: 'Unsupported file type: bin.', - supportedTypes: [], - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'blob.bin', - contentType: 'application/octet-stream', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - expect(response.status).toBe(400) - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('returns 400 when missing workflowId/executionId', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'output.mp4', - contentType: 'video/mp4', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(400) - }) - - it('inserts a workspaceFiles row with context=execution so previews authorize', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'output.mp4', - contentType: 'video/mp4', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) - expect(mockInsertFileMetadata).toHaveBeenCalledWith({ - key: data.fileInfo.key, - userId: 'test-user-id', - workspaceId: 'ws-1', - context: 'execution', - originalName: 'output.mp4', - contentType: 'video/mp4', - size: 4096, - }) - }) - }) - - describe('workspace-logos uploads', () => { - it('inserts a workspaceFiles row with context=workspace-logos so logos authorize', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=workspace-logos&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'logo.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) - expect(mockInsertFileMetadata).toHaveBeenCalledWith({ - key: data.fileInfo.key, - userId: 'test-user-id', - workspaceId: 'ws-1', - context: 'workspace-logos', - originalName: 'logo.png', - contentType: 'image/png', - size: 4096, - }) - }) - }) - - describe('knowledge-base uploads', () => { - it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(200) - expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf') - expect(mockValidateAttachmentFileType).not.toHaveBeenCalled() - }) - - it('requires workspaceId for knowledge-base uploads', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(400) - }) - - it('returns 403 when the user lacks write access to the workspace', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockGetUserEntityPermissions.mockResolvedValue('read') - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(403) - }) - }) -}) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts deleted file mode 100644 index 49bec3aab16..00000000000 --- a/apps/sim/app/api/files/presigned/route.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { - presignedUploadBodyContract, - presignedUploadTypeSchema, - presignedUploadTypes, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { CopilotFiles } from '@/lib/uploads' -import { getServeStoragePrefix } from '@/lib/uploads/config' -import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' -import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { insertFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' -import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { createErrorResponse } from '@/app/api/files/utils' - -const logger = createLogger('PresignedUploadAPI') - -class PresignedUrlError extends Error { - constructor( - message: string, - public code: string, - public statusCode = 400 - ) { - super(message) - this.name = 'PresignedUrlError' - } -} - -class ValidationError extends PresignedUrlError { - constructor(message: string) { - super(message, 'VALIDATION_ERROR', 400) - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - presignedUploadBodyContract, - request, - {}, - { - validationErrorResponse: (error) => { - throw new ValidationError(getValidationErrorMessage(error, 'Invalid request data')) - }, - invalidJsonResponse: () => { - throw new ValidationError('Invalid JSON in request body') - }, - } - ) - if (!parsed.success) return parsed.response - - const { fileName, contentType, fileSize } = parsed.data.body - - const uploadTypeParam = request.nextUrl.searchParams.get('type') - if (!uploadTypeParam) { - throw new ValidationError('type query parameter is required') - } - - const uploadTypeResult = presignedUploadTypeSchema.safeParse(uploadTypeParam) - if (!uploadTypeResult.success) { - throw new ValidationError( - `Invalid type parameter. Must be one of: ${presignedUploadTypes.join(', ')}` - ) - } - - const uploadType = uploadTypeResult.data - - if (uploadType === 'knowledge-base') { - const fileValidationError = validateFileType(fileName, contentType) - if (fileValidationError) { - throw new ValidationError(`${fileValidationError.message}`) - } - } - - const sessionUserId = session.user.id - - if (!hasCloudStorage()) { - logger.info( - `Local storage detected - presigned URL not available for ${fileName}, client will use API fallback` - ) - return NextResponse.json({ - fileName, - presignedUrl: '', // Empty URL signals fallback to API upload - fileInfo: { - path: '', - key: '', - name: fileName, - size: fileSize, - type: contentType, - }, - directUploadSupported: false, - }) - } - - logger.info(`Generating ${uploadType} presigned URL for ${fileName}`) - - let presignedUrlResponse - - if (uploadType === 'copilot') { - try { - presignedUrlResponse = await CopilotFiles.generateCopilotUploadUrl({ - fileName, - contentType, - fileSize, - userId: sessionUserId, - expirationSeconds: 3600, - }) - } catch (error) { - throw new ValidationError(getErrorMessage(error, 'Chat validation failed')) - } - } else if (uploadType === 'mothership') { - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - throw new ValidationError('workspaceId query parameter is required for chat uploads') - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for chat uploads' }, - { status: 403 } - ) - } - - const fileValidationError = validateAttachmentFileType(fileName, { allowArchives: true }) - if (fileValidationError) { - throw new ValidationError(fileValidationError.message) - } - - const customKey = generateWorkspaceFileKey(workspaceId, fileName) - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'mothership', - userId: sessionUserId, - customKey, - expirationSeconds: 3600, - metadata: { workspaceId }, - }) - - await insertFileMetadata({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - context: 'mothership', - originalName: fileName, - contentType, - size: fileSize, - }) - } else if (uploadType === 'execution') { - const workflowId = request.nextUrl.searchParams.get('workflowId') - const executionId = request.nextUrl.searchParams.get('executionId') - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) { - throw new ValidationError( - 'workflowId, executionId, and workspaceId query parameters are required for execution uploads' - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for execution uploads' }, - { status: 403 } - ) - } - - const fileValidationError = validateAttachmentFileType(fileName) - if (fileValidationError) { - throw new ValidationError(fileValidationError.message) - } - - const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'execution', - userId: sessionUserId, - customKey, - expirationSeconds: 3600, - metadata: { workspaceId, workflowId, executionId }, - }) - - await insertFileMetadata({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - context: 'execution', - originalName: fileName, - contentType, - size: fileSize, - }) - } else if (uploadType === 'workspace-logos') { - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - throw new ValidationError( - 'workspaceId query parameter is required for workspace-logos uploads' - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json( - { error: 'Admin access required for workspace logo uploads' }, - { status: 403 } - ) - } - - if (!isImageFileType(contentType)) { - throw new ValidationError( - 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads' - ) - } - - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'workspace-logos', - userId: sessionUserId, - expirationSeconds: 3600, - metadata: { workspaceId }, - }) - - await insertFileMetadata({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - context: 'workspace-logos', - originalName: fileName, - contentType, - size: fileSize, - }) - } else if (uploadType === 'knowledge-base') { - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - throw new ValidationError( - 'workspaceId query parameter is required for knowledge-base uploads' - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } - - const customKey = generateKnowledgeBaseFileKey(fileName) - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'knowledge-base', - userId: sessionUserId, - customKey, - expirationSeconds: 3600, - metadata: { workspaceId }, - }) - - await recordKnowledgeBaseFileOwnership({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - originalName: fileName, - contentType, - size: fileSize, - }) - } else { - if (!isImageFileType(contentType)) { - throw new ValidationError( - 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads' - ) - } - - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: uploadType, - userId: sessionUserId, - expirationSeconds: 3600, // 1 hour - }) - } - - const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}` - - return NextResponse.json({ - fileName, - presignedUrl: presignedUrlResponse.url, - fileInfo: { - path: finalPath, - key: presignedUrlResponse.key, - name: fileName, - size: fileSize, - type: contentType, - }, - uploadHeaders: presignedUrlResponse.uploadHeaders, - directUploadSupported: true, - }) - } catch (error) { - logger.error('Error generating presigned URL:', error) - - if (error instanceof PresignedUrlError) { - return NextResponse.json( - { - error: error.message, - code: error.code, - directUploadSupported: false, - }, - { status: error.statusCode } - ) - } - - return createErrorResponse( - error instanceof Error ? error : new Error('Failed to generate presigned URL') - ) - } -}) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts deleted file mode 100644 index 034efb5eb70..00000000000 --- a/apps/sim/app/api/files/upload/route.test.ts +++ /dev/null @@ -1,814 +0,0 @@ -/** - * Tests for file upload API route - * - * @vitest-environment node - */ -import { - authMockFns, - hybridAuthMockFns, - permissionsMock, - permissionsMockFns, - storageServiceMock, - storageServiceMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => { - const mockVerifyFileAccess = vi.fn() - const mockVerifyWorkspaceFileAccess = vi.fn() - const mockVerifyKBFileAccess = vi.fn() - const mockVerifyCopilotFileAccess = vi.fn() - const mockUploadWorkspaceFile = vi.fn() - const mockGetStorageProvider = vi.fn() - const mockIsUsingCloudStorage = vi.fn() - const mockUploadFile = vi.fn() - const mockUploadExecutionFile = vi.fn() - const mockCheckStorageQuota = vi.fn() - const mockCheckStorageQuotaForBillingContext = vi.fn() - const mockDecrementStorageUsageForBillingContext = vi.fn() - const mockIncrementStorageUsageForBillingContext = vi.fn() - const mockResolveStorageBillingContext = vi.fn() - - return { - mockVerifyFileAccess, - mockVerifyWorkspaceFileAccess, - mockVerifyKBFileAccess, - mockVerifyCopilotFileAccess, - mockUploadWorkspaceFile, - mockGetStorageProvider, - mockIsUsingCloudStorage, - mockUploadFile, - mockUploadExecutionFile, - mockCheckStorageQuota, - mockCheckStorageQuotaForBillingContext, - mockDecrementStorageUsageForBillingContext, - mockIncrementStorageUsageForBillingContext, - mockResolveStorageBillingContext, - } -}) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn(() => 'test-uuid'), - generateShortId: vi.fn(() => 'mock-short-id'), - isValidUuid: vi.fn((v: string) => - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) - ), -})) - -vi.mock('@/app/api/files/authorization', () => ({ - verifyFileAccess: mocks.mockVerifyFileAccess, - verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess, - verifyKBFileAccess: mocks.mockVerifyKBFileAccess, - verifyCopilotFileAccess: mocks.mockVerifyCopilotFileAccess, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: mocks.mockUploadWorkspaceFile, -})) - -vi.mock('@/lib/uploads/contexts/execution', () => ({ - uploadExecutionFile: mocks.mockUploadExecutionFile, -})) - -vi.mock('@/lib/uploads', () => ({ - getStorageProvider: mocks.mockGetStorageProvider, - isUsingCloudStorage: mocks.mockIsUsingCloudStorage, - uploadFile: mocks.mockUploadFile, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuota: mocks.mockCheckStorageQuota, - checkStorageQuotaForBillingContext: mocks.mockCheckStorageQuotaForBillingContext, - decrementStorageUsageForBillingContext: mocks.mockDecrementStorageUsageForBillingContext, - incrementStorageUsageForBillingContext: mocks.mockIncrementStorageUsageForBillingContext, - resolveStorageBillingContext: mocks.mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, - } -}) - -vi.mock('@/lib/uploads/setup.server', () => ({ - UPLOAD_DIR_SERVER: '/tmp/test-uploads', -})) - -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { POST } from '@/app/api/files/upload/route' - -/** - * Configure mocks for authenticated file upload tests - */ -function setupFileApiMocks( - options: { - authenticated?: boolean - storageProvider?: 's3' | 'blob' | 'local' - cloudEnabled?: boolean - } = {} -) { - const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - - if (authenticated) { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } }) - } else { - authMockFns.mockGetSession.mockResolvedValue(null) - } - - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }) - - mocks.mockVerifyFileAccess.mockResolvedValue(true) - mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true) - mocks.mockVerifyKBFileAccess.mockResolvedValue(true) - mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true) - - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - - mocks.mockUploadWorkspaceFile.mockResolvedValue({ - id: 'test-file-id', - name: 'test.txt', - url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', - size: 100, - type: 'text/plain', - key: 'workspace/test-workspace-id/1234567890-test.txt', - uploadedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }) - - mocks.mockUploadExecutionFile.mockResolvedValue({ - id: 'test-execution-file-id', - name: 'test.txt', - url: '/api/files/serve/execution/test-workspace-id/test-file.txt', - size: 100, - type: 'text/plain', - key: 'execution/test-workspace-id/1234567890-test.txt', - uploadedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }) - - mocks.mockGetStorageProvider.mockReturnValue(storageProvider) - mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) - mocks.mockUploadFile.mockResolvedValue({ - path: '/api/files/serve/test-key.txt', - key: 'test-key.txt', - name: 'test.txt', - size: 100, - type: 'text/plain', - }) - - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) - storageServiceMockFns.mockUploadFile.mockResolvedValue({ - key: 'test-key', - path: '/test/path', - }) - - mocks.mockCheckStorageQuota.mockResolvedValue({ - allowed: true, - currentUsage: 0, - limit: Number.MAX_SAFE_INTEGER, - }) -} - -describe('File Upload API Route', () => { - const createMockFormData = (files: File[], context = 'workspace'): FormData => { - const formData = new FormData() - formData.append('context', context) - formData.append('workspaceId', 'test-workspace-id') - files.forEach((file) => { - formData.append('file', file) - }) - return formData - } - - const createMockFile = ( - name = 'test.txt', - type = 'text/plain', - content = 'test content' - ): File => { - return new File([content], name, { type }) - } - - const createUploadRequest = (formData: FormData): NextRequest => - new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should upload a file to local storage', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const mockFile = createMockFile() - const formData = createMockFormData([mockFile]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toHaveProperty('url') - expect(data.url).toMatch(/\/api\/files\/serve\/.*\.txt$/) - expect(data).toHaveProperty('name', 'test.txt') - expect(data).toHaveProperty('size') - expect(data).toHaveProperty('type', 'text/plain') - expect(data).toHaveProperty('key') - - expect(uploadWorkspaceFile).toHaveBeenCalled() - }) - - it('should accept chunked multipart uploads without a content-length header', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const formData = createMockFormData([createMockFile()]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) - - expect(req.headers.get('content-length')).toBeNull() - - const response = await POST(req) - - expect(response.status).toBe(200) - expect(uploadWorkspaceFile).toHaveBeenCalled() - }) - - it('should upload a file to S3 when in S3 mode', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const mockFile = createMockFile() - const formData = createMockFormData([mockFile]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toHaveProperty('url') - expect(data.url).toContain('/api/files/serve/') - expect(data).toHaveProperty('name', 'test.txt') - expect(data).toHaveProperty('size') - expect(data).toHaveProperty('type', 'text/plain') - expect(data).toHaveProperty('key') - - expect(uploadWorkspaceFile).toHaveBeenCalled() - }) - - it('uploads a direct mothership attachment without workspace storage accounting', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const response = await POST( - createUploadRequest(createMockFormData([createMockFile('attachment.txt')], 'mothership')) - ) - - expect(response.status).toBe(200) - expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledWith( - expect.objectContaining({ context: 'mothership' }) - ) - expect(mocks.mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mocks.mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() - }) - - it('does not mutate storage counters when a direct mothership upload fails', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - storageServiceMockFns.mockUploadFile.mockRejectedValueOnce(new Error('storage unavailable')) - - const response = await POST( - createUploadRequest(createMockFormData([createMockFile('attachment.txt')], 'mothership')) - ) - - expect(response.status).toBe(500) - expect(mocks.mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mocks.mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() - }) - - it('should handle multiple file uploads', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const mockFile1 = createMockFile('file1.txt', 'text/plain') - const mockFile2 = createMockFile('file2.txt', 'text/plain') - const formData = createMockFormData([mockFile1, mockFile2]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBeGreaterThanOrEqual(200) - expect(response.status).toBeLessThan(600) - expect(data).toBeDefined() - }) - - it('rejects oversized workspace uploads before materializing file contents', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025)) - const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer') - const formData = { - getAll: (name: string) => (name === 'file' ? [mockFile] : []), - get: (name: string) => { - if (name === 'context') return 'workspace' - if (name === 'workspaceId') return 'test-workspace-id' - return null - }, - } as unknown as FormData - - const req = { - formData: async () => formData, - } as unknown as NextRequest - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(413) - expect(data.error).toBe('PayloadSizeLimitError') - expect(data.message).toContain('File exceeds the server upload limit') - expect(data.message).toContain('Use direct upload for larger workspace files') - expect(arrayBufferSpy).not.toHaveBeenCalled() - expect(uploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('should handle missing files', async () => { - setupFileApiMocks() - - const formData = new FormData() - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toHaveProperty('error', 'InvalidRequestError') - expect(data).toHaveProperty('message', 'No files provided') - }) - - it('should handle S3 upload errors', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - mocks.mockUploadWorkspaceFile.mockRejectedValue(new Error('Storage limit exceeded')) - - const mockFile = createMockFile() - const formData = createMockFormData([mockFile]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(413) - expect(data).toHaveProperty('error') - expect(typeof data.error).toBe('string') - }) -}) - -describe('File Upload Security Tests', () => { - beforeEach(() => { - vi.clearAllMocks() - - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'test-user-id' }, - }) - - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) - storageServiceMockFns.mockUploadFile.mockResolvedValue({ - key: 'test-key', - path: '/test/path', - }) - mocks.mockIsUsingCloudStorage.mockReturnValue(false) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('File Extension Validation', () => { - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('should accept allowed file types', async () => { - const allowedTypes = [ - 'pdf', - 'doc', - 'docx', - 'txt', - 'md', - 'png', - 'jpg', - 'jpeg', - 'gif', - 'csv', - 'xlsx', - 'xls', - ] - - for (const ext of allowedTypes) { - const formData = new FormData() - const file = new File(['test content'], `test.${ext}`, { type: 'application/octet-stream' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - } - }) - - it('should accept HTML files (supported document type)', async () => { - const formData = new FormData() - const htmlContent = '

Hello World

' - const file = new File([htmlContent], 'document.html', { type: 'text/html' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - }) - - it('should accept SVG files (supported image type)', async () => { - const formData = new FormData() - const svgContent = - '' - const file = new File([svgContent], 'image.svg', { type: 'image/svg+xml' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - }) - - it('should reject unsupported file types', async () => { - const formData = new FormData() - const content = 'binary data' - const file = new File([content], 'archive.exe', { type: 'application/octet-stream' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain("File type 'exe' is not allowed") - }) - - it('should reject files without extensions', async () => { - const formData = new FormData() - const file = new File(['test content'], 'noextension', { type: 'application/octet-stream' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain("File type 'noextension' is not allowed") - }) - - it('should handle multiple files with mixed valid/invalid types', async () => { - const formData = new FormData() - - const validFile = new File(['valid content'], 'valid.pdf', { type: 'application/pdf' }) - formData.append('file', validFile) - - const invalidFile = new File(['binary content'], 'malicious.exe', { - type: 'application/x-msdownload', - }) - formData.append('file', invalidFile) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain("File type 'exe' is not allowed") - }) - }) - - describe('Execution Context Permission Gate', () => { - const createExecutionFormData = ( - file: File, - workspaceId: string | null = 'test-workspace-id' - ) => { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'execution') - formData.append('workflowId', 'test-workflow-id') - formData.append('executionId', 'test-execution-id') - if (workspaceId !== null) formData.append('workspaceId', workspaceId) - return formData - } - - const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => { - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file, workspaceId) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - return POST(req as unknown as NextRequest) - } - - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('rejects execution uploads without workspaceId', async () => { - const response = await postExecutionUpload(null) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain('workflowId, executionId, and workspaceId') - expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() - }) - - it('rejects execution uploads for a read-only workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - - const response = await postExecutionUpload() - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Write or Admin access required for execution uploads') - expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() - }) - - it('rejects execution uploads for a member with no workspace permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) - - const response = await postExecutionUpload() - - expect(response.status).toBe(403) - expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() - }) - - it('allows execution uploads for a write-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - - const response = await postExecutionUpload() - - expect(response.status).toBe(200) - expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith( - { - workspaceId: 'test-workspace-id', - workflowId: 'test-workflow-id', - executionId: 'test-execution-id', - }, - expect.anything(), - 'test.pdf', - 'application/pdf', - 'test-user-id' - ) - }) - - it('allows execution uploads for an admin-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - - const response = await postExecutionUpload() - - expect(response.status).toBe(200) - expect(mocks.mockUploadExecutionFile).toHaveBeenCalled() - }) - }) - - describe('Mothership Context Permission Gate', () => { - const postMothershipUpload = async (workspaceId: string | null = 'test-workspace-id') => { - const formData = new FormData() - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - formData.append('file', file) - formData.append('context', 'mothership') - if (workspaceId !== null) formData.append('workspaceId', workspaceId) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - return POST(req as unknown as NextRequest) - } - - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('rejects mothership uploads without workspaceId', async () => { - const response = await postMothershipUpload(null) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain('workspaceId') - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('rejects mothership uploads for a workspace the caller does not belong to', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) - - const response = await postMothershipUpload() - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Write or Admin access required for mothership uploads') - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('rejects mothership uploads for a read-only workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - - const response = await postMothershipUpload() - - expect(response.status).toBe(403) - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('rejects mothership uploads over the caller storage quota', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mocks.mockCheckStorageQuota.mockResolvedValue({ - allowed: false, - currentUsage: 100, - limit: 100, - error: 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB', - }) - - const response = await postMothershipUpload() - - expect(response.status).toBe(413) - const data = await response.json() - expect(data.error).toContain('Storage limit exceeded') - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('allows mothership uploads for a write-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - - const response = await postMothershipUpload() - - expect(response.status).toBe(200) - expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( - 'test-user-id', - 'workspace', - 'test-workspace-id' - ) - expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() - }) - - it('allows mothership uploads for an admin-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - - const response = await postMothershipUpload() - - expect(response.status).toBe(200) - expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() - }) - - it('checks quota once against the combined size of a multi-file batch', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - - const formData = new FormData() - const fileA = new File(['a'.repeat(10)], 'a.pdf', { type: 'application/pdf' }) - const fileB = new File(['b'.repeat(20)], 'b.pdf', { type: 'application/pdf' }) - formData.append('file', fileA) - formData.append('file', fileB) - formData.append('context', 'mothership') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - expect(mocks.mockCheckStorageQuota).toHaveBeenCalledTimes(1) - expect(mocks.mockCheckStorageQuota).toHaveBeenCalledWith('test-user-id', 30) - expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledTimes(1) - }) - }) - - describe('Authentication Requirements', () => { - it('should reject uploads without authentication', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const formData = new FormData() - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - formData.append('file', file) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') - }) - }) -}) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts deleted file mode 100644 index 1c013d19c98..00000000000 --- a/apps/sim/app/api/files/upload/route.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { sanitizeFileName } from '@/executor/constants' -import '@/lib/uploads/core/setup.server' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - uploadFilesFormFieldsSchema, - uploadFilesFormFilesSchema, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { - assertKnownSizeWithinLimit, - isPayloadSizeLimitError, - MAX_MULTIPART_OVERHEAD_BYTES, - readFileToBufferWithLimit, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import type { StorageContext } from '@/lib/uploads/config' -import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' -import { isArchiveFileName, isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' -import { - SUPPORTED_ATTACHMENT_EXTENSIONS, - SUPPORTED_IMAGE_EXTENSIONS, - validateFileType, -} from '@/lib/uploads/utils/validation' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' - -const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) - -function validateFileExtension(filename: string, context: StorageContext): boolean { - const extension = filename.split('.').pop()?.toLowerCase() - if (!extension) return false - // Archives are only extractable in the mothership copilot flow; every other - // context keeps rejecting them up front instead of failing downstream. - if (context === 'mothership' && isArchiveFileName(filename)) return true - return ALLOWED_EXTENSIONS.has(extension) -} - -export const dynamic = 'force-dynamic' - -const logger = createLogger('FilesUploadAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const formData = await readFormDataWithLimit(request, { - maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'multipart upload body', - }) - - const rawFiles = formData.getAll('file') - const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles) - if (!filesResult.success) { - throw new InvalidRequestError('No files provided') - } - const files = filesResult.data - const totalFileSize = files.reduce((total, file) => total + file.size, 0) - assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files') - - const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({ - workflowId: formData.get('workflowId'), - executionId: formData.get('executionId'), - workspaceId: formData.get('workspaceId'), - context: formData.get('context'), - }) - if (!formFieldsResult.success) { - throw new InvalidRequestError( - getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data') - ) - } - const formFields = formFieldsResult.data - const { workflowId, executionId, workspaceId, context: contextParam } = formFields - - // Context must be explicitly provided - if (!contextParam) { - throw new InvalidRequestError( - 'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)' - ) - } - - const context = contextParam as StorageContext - - const storageService = await import('@/lib/uploads/core/storage-service') - const usingCloudStorage = storageService.hasCloudStorage() - logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`) - - // Execution context requires a workspace write/admin permission check. Resolve it once per - // request (not per file) since workspaceId is invariant across all files in the upload. - let executionUploadContext: - | { workspaceId: string; workflowId: string; executionId: string } - | undefined - if (context === 'execution') { - if (!workflowId || !executionId || !workspaceId) { - throw new InvalidRequestError( - 'Execution context requires workflowId, executionId, and workspaceId parameters' - ) - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for execution uploads' }, - { status: 403 } - ) - } - - executionUploadContext = { workspaceId, workflowId, executionId } - } - - // Mothership context requires the same workspace write/admin permission check, plus a - // storage quota check. Resolve both once per request (not per file) since workspaceId is - // invariant across all files in the upload and quota must account for the full batch size, - // not just one file. - let mothershipWorkspaceId: string | undefined - if (context === 'mothership') { - if (!workspaceId) { - throw new InvalidRequestError('Mothership context requires workspaceId parameter') - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for mothership uploads' }, - { status: 403 } - ) - } - - const { checkStorageQuota } = await import('@/lib/billing/storage') - const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize) - if (!quotaCheck.allowed) { - return NextResponse.json( - { error: quotaCheck.error || 'Storage limit exceeded' }, - { status: 413 } - ) - } - - mothershipWorkspaceId = workspaceId - } - - const uploadResults = [] - - for (const file of files) { - const originalName = file.name || 'untitled.md' - - if (!validateFileExtension(originalName, context)) { - const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown' - throw new InvalidRequestError( - `File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}` - ) - } - - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE, - label: 'uploaded file', - }) - - // Handle execution context - if (context === 'execution' && executionUploadContext) { - const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') - const userFile = await uploadExecutionFile( - executionUploadContext, - buffer, - originalName, - file.type, - session.user.id - ) - - uploadResults.push(userFile) - continue - } - - // Handle knowledge-base context - if (context === 'knowledge-base') { - // Validate file type for knowledge base - const validationError = validateFileType(originalName, file.type) - if (validationError) { - throw new InvalidRequestError(validationError.message) - } - - if (!workspaceId) { - throw new InvalidRequestError('workspaceId is required for knowledge-base uploads') - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } - - logger.info(`Uploading knowledge-base file: ${originalName}`) - - const storageKey = generateKnowledgeBaseFileKey(originalName) - - const metadata: Record = { - originalName: originalName, - uploadedAt: new Date().toISOString(), - purpose: 'knowledge-base', - userId: session.user.id, - workspaceId, - } - - const fileInfo = await storageService.uploadFile({ - file: buffer, - fileName: storageKey, - contentType: file.type, - context: 'knowledge-base', - preserveKey: true, - customKey: storageKey, - metadata, - }) - - const finalPath = usingCloudStorage - ? `${fileInfo.path}?context=knowledge-base` - : fileInfo.path - - const uploadResult = { - fileName: originalName, - presignedUrl: '', // Not used for server-side uploads - fileInfo: { - path: finalPath, - key: fileInfo.key, - name: originalName, - size: buffer.length, - type: file.type, - }, - directUploadSupported: false, - } - - logger.info(`Successfully uploaded knowledge-base file: ${fileInfo.key}`) - uploadResults.push(uploadResult) - continue - } - - // Handle workspace context - if (context === 'workspace') { - if (!workspaceId) { - throw new InvalidRequestError('Workspace context requires workspaceId parameter') - } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json( - { error: 'Write or Admin access required for workspace uploads' }, - { status: 403 } - ) - } - - try { - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') - const userFile = await uploadWorkspaceFile( - workspaceId, - session.user.id, - buffer, - originalName, - file.type || 'application/octet-stream' - ) - - uploadResults.push(userFile) - continue - } catch (workspaceError) { - const errorMessage = getErrorMessage(workspaceError, 'Upload failed') - const isDuplicate = errorMessage.includes('already exists') - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || - errorMessage.includes('storage limit') - - logger.warn(`Workspace file upload failed: ${errorMessage}`) - - let statusCode = 500 - if (isDuplicate) statusCode = 409 - else if (isStorageLimitError) statusCode = 413 - - return NextResponse.json( - { - success: false, - error: errorMessage, - isDuplicate, - }, - { status: statusCode } - ) - } - } - - // Handle mothership context (chat-scoped uploads to workspace S3) - if (context === 'mothership' && mothershipWorkspaceId) { - logger.info(`Uploading mothership file: ${originalName}`) - - const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName) - - const metadata: Record = { - originalName: originalName, - uploadedAt: new Date().toISOString(), - purpose: 'mothership', - userId: session.user.id, - workspaceId: mothershipWorkspaceId, - } - - const fileInfo = await storageService.uploadFile({ - file: buffer, - fileName: storageKey, - contentType: file.type || 'application/octet-stream', - context: 'mothership', - preserveKey: true, - customKey: storageKey, - metadata, - }) - - const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path - - uploadResults.push({ - fileName: originalName, - presignedUrl: '', - fileInfo: { - path: finalPath, - key: fileInfo.key, - name: originalName, - size: buffer.length, - type: file.type || 'application/octet-stream', - }, - directUploadSupported: false, - }) - - logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`) - continue - } - - if ( - context === 'copilot' || - context === 'chat' || - context === 'profile-pictures' || - context === 'workspace-logos' - ) { - if (context !== 'copilot') { - const mimeType = file.type - const isGenericMime = !mimeType || mimeType === 'application/octet-stream' - const extension = originalName.split('.').pop()?.toLowerCase() ?? '' - const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes( - extension - ) - const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType) - if (!isImage) { - throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`) - } - } - - if (context === 'workspace-logos') { - if (!workspaceId) { - throw new InvalidRequestError('workspace-logos context requires workspaceId parameter') - } - const permission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (permission !== 'admin') { - return NextResponse.json( - { error: 'Admin access required for workspace logo uploads' }, - { status: 403 } - ) - } - } - - if (context === 'chat' && workspaceId) { - const permission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (permission === null) { - return NextResponse.json( - { error: 'Insufficient permissions for workspace' }, - { status: 403 } - ) - } - } - - logger.info(`Uploading ${context} file: ${originalName}`) - - const resolvedContentType = resolveFileType({ type: file.type, name: originalName }) - - const timestamp = Date.now() - const safeFileName = sanitizeFileName(originalName) - const storageKey = `${context}/${timestamp}-${safeFileName}` - - const metadata: Record = { - originalName: originalName, - uploadedAt: new Date().toISOString(), - purpose: context, - userId: session.user.id, - } - - if (workspaceId && context === 'chat') { - metadata.workspaceId = workspaceId - } - - const fileInfo = await storageService.uploadFile({ - file: buffer, - fileName: storageKey, - contentType: resolvedContentType, - context, - preserveKey: true, - customKey: storageKey, - metadata, - }) - - const finalPath = usingCloudStorage ? `${fileInfo.path}?context=${context}` : fileInfo.path - - const uploadResult = { - fileName: originalName, - presignedUrl: '', // Not used for server-side uploads - fileInfo: { - path: finalPath, - key: fileInfo.key, - name: originalName, - size: buffer.length, - type: resolvedContentType, - }, - directUploadSupported: false, - } - - logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`) - - if (context === 'workspace-logos' && workspaceId) { - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - description: `Uploaded workspace logo "${originalName}"`, - metadata: { - fileName: originalName, - fileKey: fileInfo.key, - fileSize: buffer.length, - fileType: resolvedContentType, - }, - request, - }) - - captureServerEvent(session.user.id, 'workspace_logo_uploaded', { - workspace_id: workspaceId, - file_name: originalName, - file_size: buffer.length, - }) - } - - uploadResults.push(uploadResult) - continue - } - - // Unknown context - throw new InvalidRequestError( - `Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos` - ) - } - - if (uploadResults.length === 1) { - return NextResponse.json(uploadResults[0]) - } - return NextResponse.json({ files: uploadResults }) - } catch (error) { - logger.error('Error in file upload:', error) - if (isPayloadSizeLimitError(error)) { - return NextResponse.json( - { - error: 'PayloadSizeLimitError', - message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`, - }, - { status: 413 } - ) - } - return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) - } -}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..8f8e75574f5 --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,44 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' +import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { + requireUploadUser, + toInternalUploadSession, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(completeInternalFileUploadContract, request, context) + if (!parsed.success) return parsed.response + + try { + const session = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + userId: actor.id, + }) + await reauthorizeUploadPurpose(actor.id, session) + const completed = await completeUploadSession({ + session, + completion: parsed.data.body, + finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }), + }) + return NextResponse.json({ + data: toInternalUploadSession(completed.session, completed.value), + }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..5c79744cf75 --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,37 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createInternalFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { requireUploadUser, uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(createInternalFileUploadPartUrlsContract, request, context) + if (!parsed.success) return parsed.response + + try { + const session = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + userId: actor.id, + }) + await reauthorizeUploadPurpose(actor.id, session) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..e1cef9cf378 --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -0,0 +1,37 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { abortInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { + requireUploadUser, + toInternalUploadSession, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const DELETE = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(abortInternalFileUploadContract, request, context) + if (!parsed.success) return parsed.response + + try { + const session = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + userId: actor.id, + }) + await reauthorizeUploadPurpose(actor.id, session) + const aborted = await abortUploadSession(session) + return NextResponse.json({ data: toInternalUploadSession(aborted, null) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts new file mode 100644 index 00000000000..a257244e0ef --- /dev/null +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -0,0 +1,238 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockInsertReturning, + mockSelectLimit, + mockRecordAudit, + mockCaptureServerEvent, + mockGetWorkspaceFile, + mockRegisterUploadedWorkspaceFile, + mockNotifyWorkspaceFilesChanged, +} = vi.hoisted(() => ({ + mockInsertReturning: vi.fn(), + mockSelectLimit: vi.fn(), + mockRecordAudit: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockRegisterUploadedWorkspaceFile: vi.fn(), + mockNotifyWorkspaceFilesChanged: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoNothing: vi.fn(() => ({ returning: mockInsertReturning })), + })), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ limit: mockSelectLimit })), + })), + })), + })), + }, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { WORKSPACE: 'workspace' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) +vi.mock('@/lib/uploads/config', () => ({ getServeStoragePrefix: () => 's3' })) +vi.mock('@/lib/uploads/upload-session/service', () => ({ + UploadSessionError: class UploadSessionError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } + }, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile, +})) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: mockNotifyWorkspaceFilesChanged, +})) + +import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' + +const now = new Date('2026-08-04T12:00:00.000Z') +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const metadataRow = { + id: 'file-1', + key: 'workspace-logos/upload-1-logo.png', + userId: actor.id, + workspaceId: 'workspace-1', + folderId: null, + context: 'workspace-logos', + chatId: null, + messageId: null, + originalName: 'logo.png', + displayName: 'logo.png', + contentType: 'image/png', + size: 128, + sizeBytes: 128, + deletedAt: null, + uploadedAt: now, + updatedAt: now, + contentUpdatedAt: now, +} +const uploadSession = { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: actor.id, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_logo' as const, + method: 'put' as const, + storageContext: 'workspace-logos' as const, + storageKey: metadataRow.key, + storageProvider: 's3' as const, + providerUploadId: null, + fileName: 'logo.png', + contentType: 'image/png', + fileSize: 128, + status: 'uploading' as const, + metadata: {}, + uploadToken: 'signed-token', + createdAt: now, + expiresAt: new Date('2026-08-05T12:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, +} +const workspaceFile = { + id: 'wf-1', + workspaceId: 'workspace-1', + name: 'report.csv', + key: 'workspace/workspace-1/upload-1-report.csv', + path: '/api/files/serve/s3/workspace%2Fworkspace-1%2Fupload-1-report.csv?context=workspace', + size: 128, + type: 'text/csv', + uploadedBy: actor.id, + folderId: null, + deletedAt: null, + uploadedAt: now, + updatedAt: now, +} + +describe('upload purpose finalizers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits workspace-logo side effects only for the metadata insert winner', async () => { + mockSelectLimit.mockResolvedValueOnce([]).mockResolvedValueOnce([metadataRow]) + mockInsertReturning.mockResolvedValueOnce([metadataRow]) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + const first = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + const retry = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + + expect(first.value).toEqual({ + path: `/api/files/serve/s3/${encodeURIComponent(metadataRow.key)}?context=workspace-logos`, + key: metadataRow.key, + name: 'logo.png', + size: 128, + type: 'image/png', + }) + expect(retry.value).toEqual(first.value) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) + }) + + it('rejects a storage key already bound to a different owner', async () => { + mockSelectLimit.mockResolvedValueOnce([{ ...metadataRow, userId: 'other-user' }]) + + await expect( + finalizeUploadPurpose({ + session: uploadSession, + actor, + request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('rejects a replay after its metadata was archived', async () => { + mockSelectLimit.mockResolvedValueOnce([ + { ...metadataRow, deletedAt: new Date('2026-08-04T13:00:00.000Z') }, + ]) + + await expect( + finalizeUploadPurpose({ + session: uploadSession, + actor, + request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockInsertReturning).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('emits workspace-file side effects only for the metadata insert winner', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile + .mockResolvedValueOnce({ file: { id: workspaceFile.id }, created: true }) + .mockResolvedValueOnce({ file: { id: workspaceFile.id }, created: false }) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + const first = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + const retry = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + + expect(retry.value).toEqual(first.value) + expect(mockNotifyWorkspaceFilesChanged).toHaveBeenCalledTimes(1) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) + }) + + it('rejects a workspace-file replay after its metadata was archived', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ + file: { id: workspaceFile.id }, + created: false, + }) + mockGetWorkspaceFile.mockResolvedValueOnce({ ...workspaceFile, deletedAt: now }) + + await expect( + finalizeUploadPurpose({ + session: workspaceSession, + actor, + request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockNotifyWorkspaceFilesChanged).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts new file mode 100644 index 00000000000..6673b9a60d4 --- /dev/null +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -0,0 +1,362 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import type { V2File } from '@/lib/api/contracts/v2/files' +import { captureServerEvent } from '@/lib/posthog/server' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { getServeStoragePrefix } from '@/lib/uploads/config' +import { + getWorkspaceFile, + registerUploadedWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { type StorageContext, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { UploadSessionError, type UploadSessionRecord } from '@/lib/uploads/upload-session/service' +import { toV2File } from '@/app/api/v2/files/utils' + +export interface UploadActor { + id: string + name?: string | null + email?: string | null +} + +export interface StoredUploadResult { + path: string + key: string + name: string + size: number + type: string +} + +export interface ExecutionUploadResult { + id: string + name: string + url: string + size: number + type: string + key: string + context: 'execution' +} + +export type UploadPurposeResult = V2File | StoredUploadResult | ExecutionUploadResult + +interface FinalizedWorkspaceFile { + file: WorkspaceFileRecord + created: boolean +} + +interface FinalizeUploadPurposeParams { + session: UploadSessionRecord + actor: UploadActor + request: NextRequest +} + +interface FinalizedUploadPurpose { + value: UploadPurposeResult + completedFileId?: string +} + +interface FinalizedMetadataInput { + key: string + userId: string + workspaceId: string + context: StorageContext + originalName: string + contentType: string + size: number +} + +type FileMetadataRecord = typeof workspaceFiles.$inferSelect + +/** + * Finalizes the domain resource represented by a verified upload object. + * Metadata-backed purposes use the storage key as their idempotency identity. + */ +export async function finalizeUploadPurpose({ + session, + actor, + request, +}: FinalizeUploadPurposeParams): Promise { + switch (session.purpose) { + case 'workspace_file': + return finalizeInternalWorkspaceFile(session, actor, request) + case 'profile_picture': + return { value: storedAssetResult(session, 'profile-pictures') } + case 'workspace_logo': + return finalizeWorkspaceLogo(session, actor, request) + case 'mothership_attachment': + return finalizeMothershipAttachment(session) + case 'execution_attachment': + return finalizeExecutionAttachment(session) + case 'table_import': + case 'knowledge_document': + throw new UploadSessionError( + 'validation', + `Purpose ${session.purpose} is not finalized by the internal files route` + ) + } +} + +async function finalizeInternalWorkspaceFile( + session: UploadSessionRecord, + actor: UploadActor, + request: NextRequest +): Promise { + const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' }) + return { + value: toV2File(finalized.file), + completedFileId: finalized.file.id, + } +} + +/** + * Registers a verified workspace object and emits its one-time domain side effects. + * The metadata insert winner is the only caller that notifies, audits, or records analytics. + */ +export async function finalizeWorkspaceFileUpload(params: { + session: UploadSessionRecord + actor: UploadActor + request: NextRequest + source: 'api' | 'ui' +}): Promise { + const { session, actor, request, source } = params + const workspaceId = requireWorkspaceId(session) + const metadata = session.metadata as { folderId?: string | null } + const registered = await registerUploadedWorkspaceFile({ + workspaceId, + userId: session.userId, + key: session.storageKey, + originalName: session.fileName, + contentType: session.contentType, + folderId: metadata.folderId, + }) + const file = await getWorkspaceFile(workspaceId, registered.file.id, { + includeDeleted: true, + throwOnError: true, + }) + if (!file) { + throw new Error(`Completed workspace file ${registered.file.id} not found`) + } + if (file.deletedAt) { + throw new UploadSessionError('conflict', 'Upload result was deleted') + } + if (registered.created) { + await notifyWorkspaceFilesChanged(workspaceId) + captureServerEvent( + actor.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: session.contentType }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ + workspaceId, + actorId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}"${source === 'api' ? ' via API' : ''}`, + metadata: { fileSize: file.size, fileType: file.type }, + request, + }) + } + return { file, created: registered.created } +} + +async function finalizeWorkspaceLogo( + session: UploadSessionRecord, + actor: UploadActor, + request: NextRequest +): Promise { + const workspaceId = requireWorkspaceId(session) + const finalized = await insertOrLoadFileMetadata({ + key: session.storageKey, + userId: session.userId, + workspaceId, + context: 'workspace-logos', + originalName: session.fileName, + contentType: session.contentType, + size: session.fileSize, + }) + + if (finalized.created) { + recordAudit({ + workspaceId, + actorId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: `Uploaded workspace logo "${session.fileName}"`, + metadata: { + fileName: session.fileName, + fileKey: session.storageKey, + fileSize: session.fileSize, + fileType: session.contentType, + }, + request, + }) + captureServerEvent(actor.id, 'workspace_logo_uploaded', { + workspace_id: workspaceId, + file_name: session.fileName, + file_size: session.fileSize, + }) + } + + return { value: storedAssetResult(session, 'workspace-logos') } +} + +async function finalizeMothershipAttachment( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + await insertOrLoadFileMetadata({ + key: session.storageKey, + userId: session.userId, + workspaceId, + context: 'mothership', + originalName: session.fileName, + contentType: session.contentType, + size: session.fileSize, + }) + return { + value: storedAssetResult(session, 'mothership'), + } +} + +async function finalizeExecutionAttachment( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + const finalized = await insertOrLoadFileMetadata({ + key: session.storageKey, + userId: session.userId, + workspaceId, + context: 'execution', + originalName: session.fileName, + contentType: session.contentType, + size: session.fileSize, + }) + return { + value: { + id: finalized.file.id, + name: session.fileName, + url: servePath(session.storageKey, 'execution'), + size: session.fileSize, + type: session.contentType, + key: session.storageKey, + context: 'execution', + }, + completedFileId: finalized.file.id, + } +} + +async function insertOrLoadFileMetadata( + input: FinalizedMetadataInput +): Promise<{ file: FileMetadataRecord; created: boolean }> { + const existing = await findFileMetadataByKey(input.key) + if (existing) { + assertMatchingMetadata(existing, input) + assertActiveFileMetadata(existing) + return { file: existing, created: false } + } + + const now = new Date() + const [inserted] = await db + .insert(workspaceFiles) + .values({ + id: generateId(), + key: input.key, + userId: input.userId, + workspaceId: input.workspaceId, + context: input.context, + originalName: input.originalName, + displayName: input.originalName, + contentType: input.contentType, + size: toLegacyWorkspaceFileSize(input.size), + sizeBytes: input.size, + deletedAt: null, + uploadedAt: now, + updatedAt: now, + contentUpdatedAt: now, + }) + .onConflictDoNothing() + .returning() + + if (inserted) return { file: inserted, created: true } + + const raceWinner = await findFileMetadataByKey(input.key) + if (!raceWinner) { + throw new UploadSessionError('conflict', `Storage key ${input.key} could not be registered`) + } + assertMatchingMetadata(raceWinner, input) + assertActiveFileMetadata(raceWinner) + return { file: raceWinner, created: false } +} + +async function findFileMetadataByKey(key: string): Promise { + const [file] = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.key, key)) + .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`) + .limit(1) + return file +} + +function assertMatchingMetadata(existing: FileMetadataRecord, input: FinalizedMetadataInput): void { + const existingSize = existing.sizeBytes ?? existing.size + if ( + existing.key !== input.key || + existing.userId !== input.userId || + existing.workspaceId !== input.workspaceId || + existing.context !== input.context || + existing.originalName !== input.originalName || + existing.contentType !== input.contentType || + existingSize !== input.size + ) { + throw new UploadSessionError( + 'conflict', + `Storage key ${input.key} belongs to a different upload` + ) + } +} + +function assertActiveFileMetadata(file: FileMetadataRecord): void { + if (file.deletedAt) { + throw new UploadSessionError('conflict', 'Upload result was deleted') + } +} + +function servePath(key: string, context: StorageContext): string { + return `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=${context}` +} + +function storedAssetResult( + session: UploadSessionRecord, + context: 'profile-pictures' | 'workspace-logos' | 'mothership' +): StoredUploadResult { + return { + path: servePath(session.storageKey, context), + key: session.storageKey, + name: session.fileName, + size: session.fileSize, + type: session.contentType, + } +} + +function requireWorkspaceId(session: UploadSessionRecord): string { + if (!session.workspaceId) { + throw new UploadSessionError( + 'forbidden', + `Upload session ${session.id} is missing its workspace scope` + ) + } + return session.workspaceId +} diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts new file mode 100644 index 00000000000..a67d2e3298c --- /dev/null +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -0,0 +1,188 @@ +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { + createUploadSession, + UploadSessionError, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { validateAttachmentFileType } from '@/lib/uploads/utils/validation' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +export type InternalUploadPurpose = CreateInternalFileUploadBody['purpose'] + +const INTERNAL_UPLOAD_PURPOSES = new Set([ + 'workspace_file', + 'profile_picture', + 'workspace_logo', + 'mothership_attachment', + 'execution_attachment', +]) + +export async function createPurposeUploadSession( + userId: string, + body: CreateInternalFileUploadBody, + localOrigin: string +) { + validatePurposeFile(body) + + switch (body.purpose) { + case 'workspace_file': { + await requireWorkspacePermission(userId, body.workspaceId, 'write') + const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId) + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + metadata: { folderId }, + localOrigin, + }) + } + case 'profile_picture': + return createUploadSession({ + purpose: body.purpose, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + case 'workspace_logo': + await requireWorkspacePermission(userId, body.workspaceId, 'admin') + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + case 'mothership_attachment': + await requireWorkspacePermission(userId, body.workspaceId, 'write') + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + case 'execution_attachment': + await requireExecutionPermission(userId, body.workflowId, body.workspaceId) + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + } +} + +/** + * Rechecks current domain authorization for every control-plane session request. + */ +export async function reauthorizeUploadPurpose( + userId: string, + session: UploadSessionRecord +): Promise { + if (session.userId !== userId || !isInternalUploadPurpose(session.purpose)) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + + switch (session.purpose) { + case 'workspace_file': + case 'mothership_attachment': + await requireWorkspacePermission(userId, requireSessionScope(session.workspaceId), 'write') + return + case 'profile_picture': + return + case 'workspace_logo': + await requireWorkspacePermission(userId, requireSessionScope(session.workspaceId), 'admin') + return + case 'execution_attachment': + await requireExecutionPermission( + userId, + requireSessionScope(session.workflowId), + requireSessionScope(session.workspaceId) + ) + return + } +} + +export function isInternalUploadPurpose(purpose: string): purpose is InternalUploadPurpose { + return INTERNAL_UPLOAD_PURPOSES.has(purpose as InternalUploadPurpose) +} + +function validatePurposeFile(body: CreateInternalFileUploadBody): void { + if (body.purpose === 'profile_picture' || body.purpose === 'workspace_logo') { + if (!isImageFileType(body.contentType)) { + throw new UploadSessionError( + 'validation', + `Only image files are allowed for ${body.purpose.replace('_', ' ')} uploads` + ) + } + return + } + + if (body.purpose === 'mothership_attachment' || body.purpose === 'execution_attachment') { + const validation = validateAttachmentFileType(body.name, { + allowArchives: body.purpose === 'mothership_attachment', + }) + if (validation) throw new UploadSessionError('validation', validation.message) + } +} + +async function requireWorkspacePermission( + userId: string, + workspaceId: string, + action: 'write' | 'admin' +): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + const allowed = + action === 'admin' ? permission === 'admin' : permission === 'write' || permission === 'admin' + if (!allowed) { + throw new UploadSessionError( + 'forbidden', + action === 'admin' ? 'Admin access required' : 'Write or Admin access required' + ) + } +} + +async function requireExecutionPermission( + userId: string, + workflowId: string, + signedWorkspaceId: string +): Promise { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + if (!authorization.workflow) { + throw new UploadSessionError('not_found', 'Workflow not found') + } + if (!authorization.allowed) { + throw new UploadSessionError('forbidden', authorization.message ?? 'Workflow access denied') + } + if (authorization.workflow.workspaceId !== signedWorkspaceId) { + throw new UploadSessionError('forbidden', 'Workflow does not belong to the upload workspace') + } +} + +function requireSessionScope(value: string | null, label = 'scope'): string { + if (!value) { + throw new UploadSessionError('forbidden', `Upload session is missing its ${label}`) + } + return value +} diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts new file mode 100644 index 00000000000..1d6d3e0576f --- /dev/null +++ b/apps/sim/app/api/files/uploads/route.test.ts @@ -0,0 +1,307 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockCreateUploadSession, + mockGetOwnedUploadSession, + mockCompleteUploadSession, + mockGetUserEntityPermissions, + mockAuthorizeWorkflow, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockGetOwnedUploadSession: vi.fn(), + mockCompleteUploadSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockAuthorizeWorkflow: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + UploadSessionError: class UploadSessionError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } + }, + createUploadSession: mockCreateUploadSession, + getOwnedUploadSession: mockGetOwnedUploadSession, + completeUploadSession: mockCompleteUploadSession, + createUploadPartUrls: vi.fn(), + abortUploadSession: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(), + getWorkspaceFile: vi.fn(), + registerUploadedWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: vi.fn() })) + +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { POST as completeUpload } from '@/app/api/files/uploads/[uploadId]/complete/route' +import { POST as createUpload } from '@/app/api/files/uploads/route' + +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const now = new Date('2026-08-04T12:00:00.000Z') + +function session(overrides: Record = {}) { + return { + id: 'upload-1', + workspaceId: null, + userId: actor.id, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'profile_picture', + method: 'put', + storageContext: 'profile-pictures', + storageKey: 'profile-pictures/upload-1-avatar.png', + storageProvider: 's3', + providerUploadId: null, + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 128, + status: 'uploading', + metadata: {}, + uploadToken: 'signed-token', + createdAt: now, + expiresAt: new Date('2026-08-05T12:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + ...overrides, + } +} + +describe('/api/files/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: actor }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + }) + + it('creates a purpose-scoped PUT session without exposing write capability in the session', async () => { + mockCreateUploadSession.mockResolvedValue({ + ...session(), + transfer: { + method: 'put', + url: 'https://storage.example.com/upload', + headers: { 'Content-Type': 'image/png' }, + }, + }) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'profile_picture', + name: 'avatar.png', + contentType: 'image/png', + size: 128, + }), + }) + + const response = await createUpload(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ + purpose: 'profile_picture', + userId: actor.id, + localOrigin: 'http://localhost', + }) + ) + expect(body.data).toMatchObject({ + session: { + id: 'upload-1', + purpose: 'profile_picture', + status: 'uploading', + result: null, + }, + uploadToken: 'signed-token', + transfer: { method: 'put' }, + }) + expect(body.data.session).not.toHaveProperty('uploadToken') + expect(body.data.session).not.toHaveProperty('transfer') + }) + + it('creates a PUT session for an empty workspace file', async () => { + mockCreateUploadSession.mockResolvedValue({ + ...session({ + workspaceId: 'workspace-1', + purpose: 'workspace_file', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/empty.md', + fileName: 'empty.md', + contentType: 'text/markdown', + fileSize: 0, + }), + transfer: { + method: 'put', + url: 'https://storage.example.com/upload', + headers: { 'Content-Type': 'text/markdown' }, + }, + }) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'workspace_file', + workspaceId: 'workspace-1', + name: 'empty.md', + contentType: 'text/markdown', + size: 0, + }), + }) + + const response = await createUpload(request) + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + ) + await expect(response.json()).resolves.toMatchObject({ + data: { session: { purpose: 'workspace_file', size: 0 } }, + }) + }) + + it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => { + mockCreateUploadSession.mockResolvedValue({ + ...session({ + workspaceId: 'workspace-1', + purpose: 'mothership_attachment', + method: 'multipart', + storageContext: 'mothership', + storageKey: 'mothership/workspace-1/archive.zip', + fileName: 'archive.zip', + contentType: 'application/zip', + fileSize: MAX_WORKSPACE_FILE_SIZE, + }), + transfer: { method: 'multipart', partSize: 8 * 1024 * 1024, partCount: 640 }, + }) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'mothership_attachment', + workspaceId: 'workspace-1', + name: 'archive.zip', + contentType: 'application/zip', + size: MAX_WORKSPACE_FILE_SIZE, + }), + }) + + const response = await createUpload(request) + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ + purpose: 'mothership_attachment', + fileSize: MAX_WORKSPACE_FILE_SIZE, + }) + ) + }) + + it('rejects mothership attachments above the 5 GiB direct-to-storage limit', async () => { + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'mothership_attachment', + workspaceId: 'workspace-1', + name: 'archive.zip', + contentType: 'application/zip', + size: MAX_WORKSPACE_FILE_SIZE + 1, + }), + }) + + const response = await createUpload(request) + + expect(response.status).toBe(400) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('reauthorizes a terminal request and returns only the terminal-safe session', async () => { + const logoSession = session({ + workspaceId: 'workspace-1', + purpose: 'workspace_logo', + storageContext: 'workspace-logos', + storageKey: 'workspace-logos/upload-1-logo.png', + fileName: 'logo.png', + }) + const result = { + path: '/api/files/serve/s3/workspace-logos%2Fupload-1-logo.png?context=workspace-logos', + key: 'workspace-logos/upload-1-logo.png', + name: 'logo.png', + size: 128, + type: 'image/png', + } + mockGetOwnedUploadSession.mockReturnValue(logoSession) + mockCompleteUploadSession.mockResolvedValue({ + session: { ...logoSession, status: 'completed', completedAt: now }, + value: result, + alreadyCompleted: false, + }) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'upload-token': 'signed-token', + }, + body: '{}', + }) + + const response = await completeUpload(request, { + params: Promise.resolve({ uploadId: 'upload-1' }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1') + expect(mockCompleteUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ session: logoSession, completion: {} }) + ) + expect(body).toEqual({ + data: expect.objectContaining({ + id: 'upload-1', + purpose: 'workspace_logo', + status: 'completed', + result, + }), + }) + expect(body.data).not.toHaveProperty('uploadToken') + expect(body.data).not.toHaveProperty('transfer') + }) + + it('authenticates before parsing the request body', async () => { + mockGetSession.mockResolvedValue(null) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + + const response = await createUpload(request) + + expect(response.status).toBe(401) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts new file mode 100644 index 00000000000..85752ea6e82 --- /dev/null +++ b/apps/sim/app/api/files/uploads/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createPurposeUploadSession } from '@/app/api/files/uploads/purposes' +import { + requireUploadUser, + toInternalUploadSession, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(createInternalFileUploadContract, request, {}) + if (!parsed.success) return parsed.response + + try { + const created = await createPurposeUploadSession( + actor.id, + parsed.data.body, + request.nextUrl.origin + ) + return NextResponse.json( + { + data: { + session: toInternalUploadSession(created, null), + uploadToken: created.uploadToken, + transfer: created.transfer, + }, + }, + { status: 201 } + ) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts new file mode 100644 index 00000000000..ca8e9356cb3 --- /dev/null +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import { + type InternalFileUploadSession, + internalFileUploadSessionSchema, +} from '@/lib/api/contracts/upload-sessions' +import { getSession } from '@/lib/auth' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' +import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers' + +export async function requireUploadUser(): Promise { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return { + id: session.user.id, + name: session.user.name, + email: session.user.email, + } +} + +export function uploadSessionErrorResponse(error: unknown): NextResponse | null { + const classified = asOrchestrationError(error) + return classified + ? NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) + : null +} + +export function toInternalUploadSession( + session: UploadSessionRecord, + result: UploadPurposeResult | null +): InternalFileUploadSession { + return internalFileUploadSessionSchema.parse({ + id: session.id, + purpose: session.purpose, + status: session.status, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + result, + }) +} diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 9cd730013ae..0ffbe540e89 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -13,8 +13,8 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { DbOrTx } from '@/lib/db/types' -import { nextFolderSortOrder } from '@/lib/folders/lifecycle' import { deduplicateFolderName } from '@/lib/folders/naming' +import { nextFolderSortOrder } from '@/lib/folders/orchestration' import { toFolderApi } from '@/lib/folders/queries' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 0022d3e8c8c..f67b0f38a2c 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -5,7 +5,7 @@ import { restoreFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { restoreFolder } from '@/lib/folders/lifecycle' +import { restoreFolder } from '@/lib/folders/orchestration' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 25db5804c3c..035223d827a 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -8,8 +8,8 @@ import { authMockFns, createMockRequest, dbChainMockFns, - foldersLifecycleMock, - foldersLifecycleMockFns, + foldersOrchestrationMock, + foldersOrchestrationMockFns, type MockUser, permissionsMock, permissionsMockFns, @@ -34,8 +34,8 @@ const { mockLogger } = vi.hoisted(() => { } }) -const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder -const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder +const mockDeleteFolder = foldersOrchestrationMockFns.mockDeleteFolder +const mockUpdateFolder = foldersOrchestrationMockFns.mockUpdateFolder /** Parent ids the mocked engine treats as closing a cycle for the folder under test. */ const cyclicParentIds = new Set() @@ -49,7 +49,7 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock) +vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 176e43a9aba..682f9f40497 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -10,7 +10,7 @@ import { getSession } from '@/lib/auth' import { HttpError } from '@/lib/core/utils/http-error' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { folderResourceConfig } from '@/lib/folders/config' -import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' +import { deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { toFolderApi } from '@/lib/folders/queries' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 869aaa89f73..c9803b66fbb 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -31,21 +31,30 @@ describe('PUT /api/folders/reorder', () => { const mockFrom = vi.fn() const mockWhere = vi.fn() const mockTxUpdate = vi.fn() + const mockTxExecute = vi.fn() beforeEach(() => { vi.clearAllMocks() + mockFrom.mockReset() + mockWhere.mockReset() + mockTxUpdate.mockReset() + mockTxExecute.mockReset() + mockDb.transaction.mockReset() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDb.select.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockTxUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }) mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb({ update: mockTxUpdate }) + cb({ + execute: mockTxExecute, + select: vi.fn().mockReturnValue({ from: mockFrom }), + update: mockTxUpdate, + }) ) }) @@ -76,8 +85,8 @@ describe('PUT /api/folders/reorder', () => { ]) const uniqueViolation = Object.assign(new Error('duplicate key value'), { code: '23505' }) - mockDb.transaction.mockImplementationOnce(async () => { - throw uniqueViolation + mockTxUpdate.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(uniqueViolation) }), }) const req = createMockRequest('PUT', { @@ -88,6 +97,7 @@ describe('PUT /api/folders/reorder', () => { const response = await PUT(req) + expect(mockTxUpdate).toHaveBeenCalled() expect(response.status).toBe(409) const data = await response.json() expect(data.error).toBe('A folder with this name already exists in this location') @@ -108,7 +118,7 @@ describe('PUT /api/folders/reorder', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Parent folder not found') - expect(mockDb.transaction).not.toHaveBeenCalled() + expect(mockTxUpdate).not.toHaveBeenCalled() }) it('rejects a batch that would form a cycle', async () => { @@ -139,6 +149,6 @@ describe('PUT /api/folders/reorder', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Cannot create circular folder reference') - expect(mockDb.transaction).not.toHaveBeenCalled() + expect(mockTxUpdate).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index b27dbdabe37..dd644402927 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,4 +1,3 @@ -import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' @@ -10,7 +9,9 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withTransactionRetry } from '@/lib/db/transaction' import { folderResourceConfig } from '@/lib/folders/config' +import { acquireFolderMutationLock } from '@/lib/folders/locks' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') @@ -37,136 +38,146 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Write access required' }, { status: 403 }) } - const folderIds = updates.map((u) => u.id) - /** - * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes - * them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a - * guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment - * its parent is deleted. Reordering an archived folder is also a correctness problem in its - * own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out - * of an archived subtree silently drops it from that folder's restore. - */ - const existingFolders = await db - .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) - .from(folderTable) - .where( - and( - inArray(folderTable.id, folderIds), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) + return await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) + const folderIds = updates.map((u) => u.id) + /** + * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` + * excludes them: lock resolution skips archived rows, so an archived-but-locked + * folder would otherwise become mutable while its cascade is still recoverable. + */ + const existingFolders = await tx + .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + + const validIds = new Set( + existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) ) - ) + const validUpdates = updates.filter((u) => validIds.has(u.id)) - const validIds = new Set( - existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) - ) + if (validUpdates.length === 0) { + return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) + } - const validUpdates = updates.filter((u) => validIds.has(u.id)) + const targetParentIds = Array.from( + new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) + ) - if (validUpdates.length === 0) { - return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) - } + if (targetParentIds.length > 0) { + const parentFolders = await tx + .select({ + id: folderTable.id, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, + }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, targetParentIds), + eq(folderTable.resourceType, resourceType) + ) + ) - const targetParentIds = Array.from( - new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) - ) + const validParentIds = new Set( + parentFolders + .filter((f) => f.workspaceId === workspaceId && !f.archivedAt) + .map((f) => f.id) + ) - if (targetParentIds.length > 0) { - const parentFolders = await db - .select({ - id: folderTable.id, - workspaceId: folderTable.workspaceId, - archivedAt: folderTable.deletedAt, - }) - .from(folderTable) - .where( - and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, resourceType)) - ) + for (const update of validUpdates) { + if (!update.parentId) continue + if (update.parentId === update.id) { + return NextResponse.json( + { error: 'Folder cannot be its own parent' }, + { status: 400 } + ) + } + if (!validParentIds.has(update.parentId)) { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + } + } + } - const validParentIds = new Set( - parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) - ) + const workspaceFolders = await tx + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType) + ) + ) - for (const update of validUpdates) { - if (!update.parentId) continue - if (update.parentId === update.id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) + const parentById = new Map() + for (const folder of workspaceFolders) { + parentById.set(folder.id, folder.parentId) } - if (!validParentIds.has(update.parentId)) { - return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + for (const update of validUpdates) { + if (update.parentId !== undefined) { + parentById.set(update.id, update.parentId || null) + } } - } - } - - const workspaceFolders = await db - .select({ id: folderTable.id, parentId: folderTable.parentId }) - .from(folderTable) - .where( - and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, resourceType)) - ) - const parentById = new Map() - for (const folder of workspaceFolders) { - parentById.set(folder.id, folder.parentId) - } - for (const update of validUpdates) { - if (update.parentId !== undefined) { - parentById.set(update.id, update.parentId || null) - } - } - - for (const update of validUpdates) { - const visited = new Set() - let cursor: string | null = update.id - while (cursor) { - if (visited.has(cursor)) { - return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } - ) + for (const update of validUpdates) { + const visited = new Set() + let cursor: string | null = update.id + while (cursor) { + if (visited.has(cursor)) { + return NextResponse.json( + { error: 'Cannot create circular folder reference' }, + { status: 400 } + ) + } + visited.add(cursor) + cursor = parentById.get(cursor) ?? null + } } - visited.add(cursor) - cursor = parentById.get(cursor) ?? null - } - } - // Folder locking is a workflow-only feature; other resource types leave `locked` false. - if (folderResourceConfig(resourceType).supportsLocking) { - for (const update of validUpdates) { - await assertFolderMutable(update.id) - if (update.parentId !== undefined) { - await assertFolderMutable(update.parentId) + if (folderResourceConfig(resourceType).supportsLocking) { + for (const update of validUpdates) { + await assertFolderMutable(update.id) + if (update.parentId !== undefined) { + await assertFolderMutable(update.parentId) + } + } } - } - } - await db.transaction(async (tx) => { - for (const update of validUpdates) { - const updateData: Partial = { - sortOrder: update.sortOrder, - updatedAt: new Date(), - } - if (update.parentId !== undefined) { - updateData.parentId = update.parentId || null - } - await tx - .update(folderTable) - .set(updateData) - .where( - and( - eq(folderTable.id, update.id), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) + for (const update of validUpdates) { + const updateData: Partial = { + sortOrder: update.sortOrder, + updatedAt: new Date(), + } + if (update.parentId !== undefined) { + updateData.parentId = update.parentId || null + } + await tx + .update(folderTable) + .set(updateData) + .where( + and( + eq(folderTable.id, update.id), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) ) - ) - } - }) + } - logger.info( - `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` - ) + logger.info( + `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` + ) - return NextResponse.json({ success: true, updated: validUpdates.length }) + return NextResponse.json({ success: true, updated: validUpdates.length }) + }, + { label: 'reorder-folders' } + ) } catch (error) { if (error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index bc6e72de1f0..65895a1d9b7 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -55,29 +55,36 @@ interface CapturedFolderValues { function createMockTransaction(mockData: { selectResults?: Array> insertResult?: Array<{ id: string; [key: string]: unknown }> + insertError?: Error onInsertValues?: (values: CapturedFolderValues) => void }) { - const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData + const { selectResults = [[], []], insertResult = [], insertError, onInsertValues } = mockData return async (callback: (tx: unknown) => Promise) => { const where = vi.fn() for (const result of selectResults) { - where.mockReturnValueOnce(result) + const withLimit = result as typeof result & { limit: ReturnType } + withLimit.limit = vi.fn().mockReturnValue(result) + where.mockReturnValueOnce(withLimit) } where.mockReturnValue([]) const tx = { + execute: vi.fn(), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where, }), }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values: CapturedFolderValues) => { - onInsertValues?.(values) - return { - returning: vi.fn().mockReturnValue(insertResult), - } - }), + insert: vi.fn().mockImplementation(() => { + if (insertError) throw insertError + return { + values: vi.fn().mockImplementation((values: CapturedFolderValues) => { + onInsertValues?.(values) + return { + returning: vi.fn().mockReturnValue(insertResult), + } + }), + } }), } return await callback(tx) @@ -160,6 +167,7 @@ describe('Folders API Route', () => { mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) mockReturning.mockReturnValue([mockFolders[0]]) + mockTransaction.mockImplementation(createMockTransaction({})) mockGetUserEntityPermissions.mockResolvedValue('admin') }) @@ -363,7 +371,7 @@ describe('Folders API Route', () => { mockTransaction.mockImplementationOnce( createMockTransaction({ - selectResults: [[], []], + selectResults: [[{ workspaceId: 'workspace-123', archivedAt: null }], [], []], insertResult: [{ ...mockFolders[1] }], }) ) @@ -530,9 +538,9 @@ describe('Folders API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockInsert.mockImplementationOnce(() => { - throw new Error('Database insert failed') - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ insertError: new Error('Database insert failed') }) + ) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 84eba3c8cab..1ac1c31e9f1 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { folderResourceConfig } from '@/lib/folders/config' -import { createFolder } from '@/lib/folders/lifecycle' +import { createFolder } from '@/lib/folders/orchestration' import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 3c10f68f4d4..88de0594622 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -20,7 +20,7 @@ const logger = createLogger('HelpAPI') /** * The form can carry several image attachments with no server-side count * cap, so this reuses the repo's largest existing per-request form-data - * bound (see files/upload route) rather than an arbitrary smaller limit + * multipart bound rather than an arbitrary smaller limit * that could reject a legitimate multi-image submission. */ const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index bf347078d73..ce255768db1 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -151,7 +151,10 @@ describe('Knowledge Connector By ID API Route', () => { success: true, userId: 'user-1', }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } }) @@ -174,7 +177,8 @@ describe('Knowledge Connector By ID API Route', () => { mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 } - dbChainMockFns.limit.mockResolvedValueOnce([updatedConnector]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) + dbChainMockFns.returning.mockResolvedValueOnce([updatedConnector]) const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 }) const response = await PATCH(req, { params: mockParams }) @@ -196,6 +200,7 @@ describe('Knowledge Connector By ID API Route', () => { knowledgeBase: { workspaceId: 'ws-free', name: 'Free KB' }, }) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) const req = createMockRequest('PATCH', { syncIntervalMinutes: 5 }) const response = await PATCH(req, { params: mockParams }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 3ff1d479cd0..d63513af694 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -1,20 +1,29 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { document, embedding, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' +import { knowledgeConnectorSyncLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' +import { + deleteKnowledgeConnectorContract, + updateKnowledgeConnectorContract, +} from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { decryptApiKey } from '@/lib/api-key/crypto' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' -import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' -import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service' -import { captureServerEvent } from '@/lib/posthog/server' +import { + getKnowledgeConnector, + type KnowledgeConnectorRow, + performDeleteKnowledgeConnector, + performUpdateKnowledgeConnector, + type SourceConfigRejection, +} from '@/lib/knowledge/orchestration' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' @@ -42,20 +51,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) } - const connectorRows = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (connectorRows.length === 0) { + const connector = await getKnowledgeConnector(knowledgeBaseId, connectorId) + if (!connector) { return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) } @@ -66,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou .orderBy(desc(knowledgeConnectorSyncLog.startedAt)) .limit(10) - const { encryptedApiKey: _, ...connectorData } = connectorRows[0] + const { encryptedApiKey: _, ...connectorData } = connector return NextResponse.json({ success: true, data: { @@ -81,357 +78,179 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou }) /** - * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector + * Validates a replacement `sourceConfig` against the live source, resolving the + * connector's own token first. Returns a rejection message, or `null` to accept. + * + * Stays with the route rather than moving into orchestration because resolving + * the token needs the requesting identity: workspace credentials are shared and + * token reads are scoped to `account.userId`, so the credential's own account + * owner is used — not the knowledge base owner, and not the acting user when a + * service account mints its own token. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await context.params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response - const body = parsed.data.body - - if ( - body.syncIntervalMinutes !== undefined && - body.syncIntervalMinutes > 0 && - body.syncIntervalMinutes < 60 - ) { - const workspaceId = writeCheck.knowledgeBase.workspaceId - if (!workspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } - const canUseLiveSync = await hasWorkspaceLiveSyncAccess(workspaceId) - if (!canUseLiveSync) { - return NextResponse.json( - { error: 'Live sync requires a Max or Enterprise plan' }, - { status: 403 } - ) +function makeSourceConfigValidator( + actingUserId: string, + workspaceId: string | null, + connectorId: string +) { + return async ( + connector: KnowledgeConnectorRow, + sourceConfig: Record + ): Promise => { + const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] + if (!connectorConfig) { + return { + message: `Unknown connector type: ${connector.connectorType}`, + errorCode: 'validation', } } - if (body.sourceConfig !== undefined) { - const existingRows = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (existingRows.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const existing = existingRows[0] - const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType] - - if (!connectorConfig) { - return NextResponse.json( - { error: `Unknown connector type: ${existing.connectorType}` }, - { status: 400 } - ) - } - - let accessToken: string | null = null - if (connectorConfig.auth.mode === 'apiKey') { - if (!existing.encryptedApiKey) { - return NextResponse.json( - { error: 'API key not found. Please reconfigure the connector.' }, - { status: 400 } - ) - } - accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted - } else { - if (!existing.credentialId) { - return NextResponse.json( - { error: 'OAuth credential not found. Please reconfigure the connector.' }, - { status: 400 } - ) + let accessToken: string | null = null + if (connectorConfig.auth.mode === 'apiKey') { + if (!connector.encryptedApiKey) { + return { + message: 'API key not found. Please reconfigure the connector.', + errorCode: 'validation', } - const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!connectorWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace context' }, - { status: 409 } - ) - } - /** - * Resolve the credential's own account owner, not the knowledge base owner: - * workspace credentials are shared, and token reads are scoped to - * `account.userId`. - */ - const identity = await resolveCredentialTokenIdentity( - existing.credentialId, - connectorWorkspaceId - ) - if (!identity) { - return NextResponse.json( - { error: 'Credential is no longer usable in this workspace. Please reconnect it.' }, - { status: 400 } - ) + } + accessToken = (await decryptApiKey(connector.encryptedApiKey)).decrypted + } else { + if (!connector.credentialId) { + return { + message: 'OAuth credential not found. Please reconfigure the connector.', + errorCode: 'validation', } - accessToken = await refreshAccessTokenIfNeeded( - existing.credentialId, - // Service accounts mint their own token and ignore the acting user. - identity.kind === 'oauth' ? identity.userId : auth.userId, - `patch-${connectorId}` - ) } - - if (!accessToken) { - return NextResponse.json( - { error: 'Failed to refresh access token. Please reconnect your account.' }, - { status: 401 } - ) + if (!workspaceId) { + return { + message: 'Knowledge base is missing workspace context', + errorCode: 'conflict', + } } - - const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig) - if (!validation.valid) { - return NextResponse.json( - { error: validation.error || 'Invalid source configuration' }, - { status: 400 } - ) + const identity = await resolveCredentialTokenIdentity(connector.credentialId, workspaceId) + if (!identity) { + return { + message: 'Credential is no longer usable in this workspace. Please reconnect it.', + errorCode: 'validation', + } } + accessToken = await refreshAccessTokenIfNeeded( + connector.credentialId, + // Service accounts mint their own token and ignore the acting user. + identity.kind === 'oauth' ? identity.userId : actingUserId, + `patch-${connectorId}` + ) } - const updates: Record = { updatedAt: new Date() } - if (body.sourceConfig !== undefined) { - updates.sourceConfig = body.sourceConfig - } - if (body.syncIntervalMinutes !== undefined) { - updates.syncIntervalMinutes = body.syncIntervalMinutes - if (body.syncIntervalMinutes > 0) { - updates.nextSyncAt = new Date(Date.now() + body.syncIntervalMinutes * 60 * 1000) - } else { - updates.nextSyncAt = null - } - } - if (body.status !== undefined) { - updates.status = body.status - if (body.status === 'active') { - updates.consecutiveFailures = 0 - updates.lastSyncError = null - if (updates.nextSyncAt === undefined) { - updates.nextSyncAt = new Date() - } + if (!accessToken) { + // A stale stored credential, not an unauthenticated caller — but the route + // has always answered 401 here, so keep that rather than silently + // reclassifying it as part of this refactor. + return { + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized', } } - await db - .update(knowledgeConnector) - .set(updates) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) + const validation = await connectorConfig.validateConfig(accessToken, sourceConfig) + return validation.valid + ? null + : { message: validation.error || 'Invalid source configuration', errorCode: 'validation' } + } +} - const updated = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) +/** + * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, connectorId } = await context.params - const { encryptedApiKey: __, ...updatedData } = updated[0] + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_UPDATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: updatedData.connectorType, - description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType: updatedData.connectorType, - updatedFields: Object.keys(parsed.data), - ...(body.syncIntervalMinutes !== undefined && { - syncIntervalMinutes: body.syncIntervalMinutes, - }), - ...(body.status !== undefined && { newStatus: body.status }), - }, - request, - }) + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - return NextResponse.json({ success: true, data: updatedData }) - } catch (error) { - logger.error(`[${requestId}] Error updating connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response + + const outcome = await performUpdateKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: writeCheck.knowledgeBase.workspaceId ?? null, + }, + connectorId, + updates: parsed.data.body, + validateSourceConfig: makeSourceConfigValidator( + auth.userId, + writeCheck.knowledgeBase.workspaceId ?? null, + connectorId + ), + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ success: true, data: outcome.connector }) }) /** * DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector */ -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteParams) => { const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const existingConnector = await db - .select({ id: knowledgeConnector.id, connectorType: knowledgeConnector.connectorType }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (existingConnector.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const { searchParams } = new URL(request.url) - const deleteDocuments = searchParams.get('deleteDocuments') === 'true' - - const { deletedDocs, docCount } = await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) - - // Includes pending-removal (tombstoned) docs — the connector is being - // deleted, so there's no future sync left to confirm or resurrect them. - const docs = await tx - .select({ id: document.id, fileUrl: document.fileUrl }) - .from(document) - .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) - - const documentIds = docs.map((doc) => doc.id) - if (deleteDocuments) { - if (documentIds.length > 0) { - await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) - await tx.delete(document).where(inArray(document.id, documentIds)) - } - } else if (documentIds.length > 0) { - // Kept documents become normal standalone KB entries once their connector - // is gone — resurrect any pending-removal ones rather than leaving them - // invisible tombstones with no future sync left to ever confirm or - // resurrect them. - await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds)) - } - - const deletedConnectors = await tx - .delete(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .returning({ id: knowledgeConnector.id }) - - if (deletedConnectors.length === 0) { - throw new Error('Connector not found') - } - - return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length } - }) - - const kbWorkspaceId = writeCheck.knowledgeBase?.workspaceId ?? null + const { id: knowledgeBaseId, connectorId } = await context.params - if (deleteDocuments) { - await Promise.all([ - deletedDocs.length > 0 - ? deleteDocumentStorageFiles( - deletedDocs.map((doc) => ({ ...doc, workspaceId: kbWorkspaceId })), - requestId - ) - : Promise.resolve(), - cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => { - logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) - }), - ]) - } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info( - `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` - ) + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - captureServerEvent( - auth.userId, - 'knowledge_base_connector_removed', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - connector_type: existingConnector[0].connectorType, - documents_deleted: deleteDocuments ? docCount : 0, - }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined + const parsed = await parseRequest(deleteKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response + + const outcome = await performDeleteKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: writeCheck.knowledgeBase.workspaceId ?? null, + }, + connectorId, + deleteDocuments: parsed.data.query.deleteDocuments, + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_DELETED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: existingConnector[0].connectorType, - description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType: existingConnector[0].connectorType, - deleteDocuments, - documentsDeleted: deleteDocuments ? docCount : 0, - documentsKept: deleteDocuments ? 0 : docCount, - }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error deleting connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + return NextResponse.json({ success: true }) }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index c79c85df58a..b8869013644 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -62,7 +62,10 @@ describe('Connector Manual Sync API Route', () => { success: true, userId: 'user-1', }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('POST') @@ -76,7 +79,10 @@ describe('Connector Manual Sync API Route', () => { success: true, userId: 'user-1', }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) const req = createMockRequest('POST') diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 714f554040b..21e6bfdb50e 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -1,8 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeConnector } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' @@ -11,14 +6,15 @@ import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { dispatchSync } from '@/lib/knowledge/connectors/queue' -import { captureServerEvent } from '@/lib/posthog/server' +import { performSyncKnowledgeConnector } from '@/lib/knowledge/orchestration' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -const logger = createLogger('ConnectorManualSyncAPI') - type RouteParams = { params: Promise<{ id: string; connectorId: string }> } /** @@ -31,105 +27,50 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const { id: knowledgeBaseId, connectorId } = parsed.data.params const { rehydrate } = parsed.data.query - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const connectorRows = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (connectorRows.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - if (connectorRows[0].status === 'syncing') { - return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 }) - } + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? null - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!kbWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } - const billingAttribution = + const outcome = await performSyncKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: kbWorkspaceId, + }, + connectorId, + resolveBillingAttribution: async () => auth.authType === AuthType.INTERNAL_JWT ? requireBillingAttributionHeader(request.headers, { - actorUserId: auth.userId, - workspaceId: kbWorkspaceId, - }) - : await resolveBillingAttribution({ - actorUserId: auth.userId, - workspaceId: kbWorkspaceId, + actorUserId: auth.userId as string, + workspaceId: kbWorkspaceId as string, }) - - logger.info( - `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` - ) - - captureServerEvent( - auth.userId, - 'knowledge_base_connector_synced', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId, - connector_type: connectorRows[0].connectorType, - }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined + : resolveBillingAttribution({ + actorUserId: auth.userId as string, + workspaceId: kbWorkspaceId as string, + }), + rehydrate, + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_SYNCED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connectorRows[0].connectorType, - description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType: connectorRows[0].connectorType, - connectorStatus: connectorRows[0].status, - syncType: rehydrate ? 'manual-rehydrate' : 'manual', - }, - request, - }) - - dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { - logger.error( - `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, - error - ) - }) - - return NextResponse.json({ - success: true, - message: 'Sync triggered', - }) - } catch (error) { - logger.error(`[${requestId}] Error triggering manual sync`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + return NextResponse.json({ success: true, message: 'Sync triggered' }) }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts index 6087572fb40..361a8e2ad68 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts @@ -118,7 +118,8 @@ describe('Knowledge Connectors API Route', () => { }) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([ + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'connector-1', knowledgeBaseId: 'knowledge-base-1', @@ -173,6 +174,8 @@ describe('Knowledge Connectors API Route', () => { expect(response.status).toBe(403) expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-free') + // The payer is resolved lazily, so a request the plan gate rejects never + // pays for the lookup. expect(mockResolveBillingAttribution).not.toHaveBeenCalled() expect(mockDispatchSync).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index b7df3198990..df2f246ae1d 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -1,28 +1,25 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema' +import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { and, desc, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' -import { encryptApiKey } from '@/lib/api-key/crypto' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { + messageForOrchestrationError, + OrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { dispatchSync } from '@/lib/knowledge/connectors/queue' -import { allocateTagSlots } from '@/lib/knowledge/constants' -import { createTagDefinition } from '@/lib/knowledge/tags/service' -import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateKnowledgeConnector } from '@/lib/knowledge/orchestration' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' const logger = createLogger('KnowledgeConnectorsAPI') @@ -80,263 +77,71 @@ export const POST = withRouteHandler( const requestId = generateRequestId() const { id: knowledgeBaseId } = await context.params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json( - { error: status === 404 ? 'Not found' : 'Unauthorized' }, - { status } - ) - } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const parsed = await parseRequest(createKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = - parsed.data.body + const parsed = await parseRequest(createKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!kbWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } + const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = + parsed.data.body - if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) { - const canUseLiveSync = await hasWorkspaceLiveSyncAccess(kbWorkspaceId) - if (!canUseLiveSync) { - return NextResponse.json( - { error: 'Live sync requires a Max or Enterprise plan' }, - { status: 403 } - ) - } - } + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId + if (!kbWorkspaceId) { + return NextResponse.json( + { error: 'Knowledge base is missing workspace billing context' }, + { status: 409 } + ) + } - const billingAttribution = + const outcome = await performCreateKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: kbWorkspaceId, + }, + connectorType, + credentialId, + apiKey, + sourceConfig, + syncIntervalMinutes, + resolveBillingAttribution: async () => auth.authType === AuthType.INTERNAL_JWT ? requireBillingAttributionHeader(request.headers, { - actorUserId: auth.userId, + actorUserId: auth.userId as string, workspaceId: kbWorkspaceId, }) - : await resolveBillingAttribution({ - actorUserId: auth.userId, + : resolveBillingAttribution({ + actorUserId: auth.userId as string, workspaceId: kbWorkspaceId, - }) - - const connectorConfig = CONNECTOR_REGISTRY[connectorType] - if (!connectorConfig) { - return NextResponse.json( - { error: `Unknown connector type: ${connectorType}` }, - { status: 400 } - ) - } - - let resolvedCredentialId: string | null = null - let resolvedEncryptedApiKey: string | null = null - let accessToken: string - - if (connectorConfig.auth.mode === 'apiKey') { - if (!apiKey) { - return NextResponse.json({ error: 'API key is required' }, { status: 400 }) - } - accessToken = apiKey - } else { - if (!credentialId) { - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const credential = await getCredential(requestId, credentialId, auth.userId) - if (!credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 400 }) - } - - if (!credential.accessToken) { - return NextResponse.json( - { error: 'Credential has no access token. Please reconnect your account.' }, - { status: 400 } - ) - } - - accessToken = credential.accessToken - resolvedCredentialId = credentialId - } - - const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) - if (!configValidation.valid) { - return NextResponse.json( - { error: configValidation.error || 'Invalid source configuration' }, - { status: 400 } - ) - } - - let finalSourceConfig: Record = { ...sourceConfig } - - if (connectorConfig.auth.mode === 'apiKey' && apiKey) { - const { encrypted } = await encryptApiKey(apiKey) - resolvedEncryptedApiKey = encrypted - } - - const tagSlotMapping: Record = {} - let newTagSlots: Record = {} - - if (connectorConfig.tagDefinitions?.length) { - const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? []) - const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id)) - - const existingDefs = await db - .select({ - tagSlot: knowledgeBaseTagDefinitions.tagSlot, - displayName: knowledgeBaseTagDefinitions.displayName, - fieldType: knowledgeBaseTagDefinitions.fieldType, - }) - .from(knowledgeBaseTagDefinitions) - .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) - - const usedSlots = new Set(existingDefs.map((d) => d.tagSlot)) - const existingByName = new Map( - existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }]) - ) - - const defsNeedingSlots: typeof enabledDefs = [] - for (const td of enabledDefs) { - const existing = existingByName.get(td.displayName) - if (existing && existing.fieldType === td.fieldType) { - tagSlotMapping[td.id] = existing.tagSlot - } else { - defsNeedingSlots.push(td) - } - } - - const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots) - Object.assign(tagSlotMapping, mapping) - newTagSlots = mapping - - for (const name of skippedTags) { - logger.warn(`[${requestId}] No available slots for "${name}"`) - } - - if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { - return NextResponse.json( - { error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` }, - { status: 422 } - ) - } - - finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } - } - - const now = new Date() - const connectorId = generateId() - const nextSyncAt = - syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null - - await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - - const activeKb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) - - if (activeKb.length === 0) { - throw new Error('Knowledge base not found') - } - - for (const [semanticId, slot] of Object.entries(newTagSlots)) { - const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)! - await createTagDefinition( - { - knowledgeBaseId, - tagSlot: slot, - displayName: td.displayName, - fieldType: td.fieldType, - }, - requestId, - tx - ) - } - - await tx.insert(knowledgeConnector).values({ - id: connectorId, - knowledgeBaseId, - connectorType, - credentialId: resolvedCredentialId, - encryptedApiKey: resolvedEncryptedApiKey, - sourceConfig: finalSourceConfig, - syncIntervalMinutes, - status: 'active', - nextSyncAt, - createdAt: now, - updatedAt: now, - }) - }) - - logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`) - - captureServerEvent( - auth.userId, - 'knowledge_base_connector_added', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId, - connector_type: connectorType, - sync_interval_minutes: syncIntervalMinutes, - }, - { - groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined, - setOnce: { first_connector_added_at: new Date().toISOString() }, - } + }), + resolveAccessToken: async (id) => { + const credential = await getCredential(requestId, id, auth.userId as string) + if (!credential) throw new OrchestrationError('validation', 'Credential not found') + return credential.accessToken ?? null + }, + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_CREATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connectorType, - description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType, - syncIntervalMinutes, - authMode: connectorConfig.auth.mode, - }, - request, - }) - - dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { - logger.error( - `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, - error - ) - }) - - const created = await db - .select() - .from(knowledgeConnector) - .where(eq(knowledgeConnector.id, connectorId)) - .limit(1) - - const { encryptedApiKey: _, ...createdData } = created[0] - return NextResponse.json({ success: true, data: createdData }, { status: 201 }) - } catch (error) { - if (error instanceof Error && error.message === 'Knowledge base not found') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - logger.error(`[${requestId}] Error creating connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + return NextResponse.json({ success: true, data: outcome.connector }, { status: 201 }) } ) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 7acdc821391..dab693e462f 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' @@ -8,15 +7,19 @@ import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + type OrchestrationErrorCode, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - deleteDocument, - markDocumentAsFailedTimeout, - retryDocumentProcessing, - updateDocument, -} from '@/lib/knowledge/documents/service' -import { captureServerEvent } from '@/lib/posthog/server' + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, +} from '@/lib/knowledge/orchestration' import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentByIdAPI') @@ -108,58 +111,30 @@ export const PUT = withRouteHandler( ) if (!parsed.success) return parsed.response - const validatedData = parsed.data.body - - const updateData: any = {} + const { markFailedDueToTimeout, retryProcessing, ...documentUpdates } = parsed.data.body + const doc = accessCheck.document + const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? null - if (validatedData.markFailedDueToTimeout) { - const doc = accessCheck.document - - if (doc.processingStatus !== 'processing') { - return NextResponse.json( - { error: `Document is not in processing state (current: ${doc.processingStatus})` }, - { status: 400 } - ) - } - - if (!doc.processingStartedAt) { - return NextResponse.json( - { error: 'Document has no processing start time' }, - { status: 400 } - ) - } - - try { - await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId) + const failed = (outcome: { error?: string; errorCode?: OrchestrationErrorCode }) => + NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) - return NextResponse.json({ - success: true, - data: { - documentId, - status: 'failed', - message: 'Document marked as failed due to timeout', - }, - }) - } catch (error) { - if (error instanceof Error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (validatedData.retryProcessing) { - const doc = accessCheck.document + if (markFailedDueToTimeout) { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: doc, + requestId, + }) + if (!outcome.success) return failed(outcome) - if (doc.processingStatus !== 'failed') { - return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 }) - } + return NextResponse.json({ + success: true, + data: { documentId, status: outcome.status, message: outcome.message }, + }) + } - const docData = { - filename: doc.filename, - fileUrl: doc.fileUrl, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - } - const workspaceId = accessCheck.knowledgeBase?.workspaceId + if (retryProcessing) { const billingAttribution = workspaceId ? auth.authType === AuthType.INTERNAL_JWT ? requireBillingAttributionHeader(req.headers, { @@ -172,56 +147,38 @@ export const PUT = withRouteHandler( }) : undefined - const result = await retryDocumentProcessing( + const outcome = await performRetryKnowledgeDocumentProcessing({ knowledgeBaseId, - documentId, - docData, + document: doc, + billingAttribution, requestId, - billingAttribution - ) - - return NextResponse.json({ - success: true, - data: { - documentId, - status: result.status, - message: result.message, - }, - }) - } else { - const updatedDocument = await updateDocument(documentId, validatedData, requestId) - - logger.info( - `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPDATED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: validatedData.filename ?? accessCheck.document?.filename, - description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename ?? accessCheck.document?.filename, - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), - }, - request: req, }) + if (!outcome.success) return failed(outcome) return NextResponse.json({ success: true, - data: updatedDocument, + data: { documentId, status: outcome.status, message: outcome.message }, }) } + + const outcome = await performUpdateKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: accessCheck.knowledgeBase?.name, + workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + updates: documentUpdates, + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request: req, + }) + if (!outcome.success) return failed(outcome) + + return NextResponse.json({ success: true, data: outcome.document }) } catch (error) { logger.error(`[${requestId}] Error updating document ${documentId}`, error) return NextResponse.json({ error: 'Failed to update document' }, { status: 500 }) @@ -257,43 +214,30 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const result = await deleteDocument(documentId, requestId) - - logger.info( - `[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}` - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: accessCheck.knowledgeBase?.name, + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + }, + document: accessCheck.document, + userId, actorName: auth.userName, actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: accessCheck.document?.filename, - description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: accessCheck.document?.filename, - fileSize: accessCheck.document?.fileSize, - mimeType: accessCheck.document?.mimeType, - }, + source: 'ui', + requestId, request: req, }) - - const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? '' - captureServerEvent( - userId, - 'knowledge_base_document_deleted', - { knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined - ) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, - data: result, + data: { success: true, message: 'Document deleted successfully' }, }) } catch (error) { logger.error(`[${requestId}] Error deleting document`, error) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 971c4a8f28b..84b523c7870 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -570,7 +570,9 @@ describe('Knowledge Base Documents API Route', () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.error).toBe('Database error') + // An unclassified fault renders the route's own wording; the driver's + // message is logged, not returned. + expect(data.error).toBe('Failed to create document') }) }) }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 9025cea9891..a46d08abae4 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' @@ -19,19 +18,22 @@ import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { bulkDocumentOperation, bulkDocumentOperationByFilter, - createDocumentRecords, - createSingleDocument, getDocuments, getProcessingConfig, - KnowledgeBaseFileOwnershipError, - processDocumentsWithQueue, } from '@/lib/knowledge/documents/service' import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter' -import { captureServerEvent } from '@/lib/posthog/server' +import { + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from '@/lib/knowledge/orchestration' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentsAPI') @@ -210,168 +212,77 @@ export const POST = withRouteHandler( ) } - if (body.bulk === true) { - const createdDocuments = await createDocumentRecords( - body.documents, - knowledgeBaseId, - requestId, - userId - ) - - logger.info( - `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents` - ) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: createdDocuments.length, - uploadType: 'bulk', - recipe: body.processingOptions?.recipe, - }) - } catch (_e) { - // Silently fail - } - - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: createdDocuments.length, - upload_type: 'bulk', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - body.processingOptions ?? {}, - requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) - }) + const knowledgeBase = { + id: knowledgeBaseId, + name: accessCheck.knowledgeBase?.name, + workspaceId: kbWorkspaceId ?? null, + } + const actor = { + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui' as const, + requestId, + request: req, + } - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: `${createdDocuments.length} document(s)`, - description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileCount: createdDocuments.length, - }, - request: req, + if (body.bulk === true) { + const outcome = await performUploadKnowledgeDocuments({ + ...actor, + knowledgeBase, + documents: body.documents, + processingOptions: body.processingOptions, + billingAttribution, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + const { batchSize, maxConcurrentDocuments } = getProcessingConfig() return NextResponse.json({ success: true, data: { - total: createdDocuments.length, - documentsCreated: createdDocuments.map((doc) => ({ + total: outcome.documents.length, + documentsCreated: outcome.documents.map((doc) => ({ documentId: doc.documentId, filename: doc.filename, status: 'pending', })), processingMethod: 'background', processingConfig: { - maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, - batchSize: getProcessingConfig().batchSize, - totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize), + maxConcurrentDocuments, + batchSize, + totalBatches: Math.ceil(outcome.documents.length / batchSize), }, }, }) } const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body - const newDocument = await createSingleDocument( - singleDocumentData, - knowledgeBaseId, - requestId, - userId - ) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: singleDocumentData.mimeType, - fileSize: singleDocumentData.fileSize, - }) - } catch (_e) { - // Silently fail - } - - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: 1, - upload_type: 'single', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: singleDocumentData.filename, - description: `Uploaded document "${singleDocumentData.filename}" to knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: singleDocumentData.filename, - fileType: singleDocumentData.mimeType, - fileSize: singleDocumentData.fileSize, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - data: newDocument, + // Indexing is deliberately not started here: this path only records the + // document, and its caller drives processing separately. + const outcome = await performUploadKnowledgeDocument({ + ...actor, + knowledgeBase, + document: singleDocumentData, + billingAttribution, }) - } catch (error) { - logger.error(`[${requestId}] Error creating document`, error) - - if (error instanceof KnowledgeBaseFileOwnershipError) { + if (!outcome.success) { return NextResponse.json( - { error: 'File URL does not reference a file owned by this knowledge base' }, - { status: 403 } + { error: messageForOrchestrationError(outcome, 'Failed to create document') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - const errorMessage = getErrorMessage(error, 'Failed to create document') - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') - const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' - + return NextResponse.json({ success: true, data: outcome.document }) + } catch (error) { + logger.error(`[${requestId}] Error creating document`, error) return NextResponse.json( - { error: errorMessage }, - { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } + { error: getErrorMessage(error, 'Failed to create document') }, + { status: 500 } ) } } diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..7426a000fbd --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,74 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeUploadSession } from '@/lib/uploads/upload-session/service' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, + resolveKnowledgeDocumentUploadAttribution, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { + finalizeKnowledgeDocumentUpload, + getOwnedKnowledgeDocumentUpload, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(completeKnowledgeDocumentUploadContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + const requestId = generateRequestId() + try { + const upload = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId: actor.id, + uploadToken: parsed.data.headers['upload-token'], + }) + const completed = await completeUploadSession({ + session: upload, + completion: parsed.data.body, + finalize: (claimed) => + finalizeKnowledgeDocumentUpload({ + claimed, + knowledgeBaseId, + knowledgeBaseName: access.knowledgeBase.name, + workspaceId, + userId: actor.id, + resolveAttribution: () => + resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId: actor.id }), + source: 'ui', + requestId, + request, + actorName: actor.name, + actorEmail: actor.email, + }), + }) + return NextResponse.json({ + data: toV2KnowledgeDocumentUpload(completed.session, completed.value), + }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..38390d4c13e --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { getOwnedKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest( + createKnowledgeDocumentUploadPartUrlsContract, + request, + context + ) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + try { + const upload = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId: actor.id, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..bc37c1026c2 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -0,0 +1,50 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { + abortKnowledgeDocumentUpload, + getOwnedKnowledgeDocumentUpload, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(abortKnowledgeDocumentUploadContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + try { + const upload = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId: actor.id, + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortKnowledgeDocumentUpload(upload, knowledgeBaseId) + return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts new file mode 100644 index 00000000000..ea79d0f4dc9 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateKnowledgeDocumentUploadSession, + mockRequireKnowledgeDocumentUploadAccess, + mockRequireKnowledgeDocumentUploadActor, + mockRequireKnowledgeDocumentUploadBilling, +} = vi.hoisted(() => ({ + mockCreateKnowledgeDocumentUploadSession: vi.fn(), + mockRequireKnowledgeDocumentUploadAccess: vi.fn(), + mockRequireKnowledgeDocumentUploadActor: vi.fn(), + mockRequireKnowledgeDocumentUploadBilling: vi.fn(), +})) + +vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ + requireKnowledgeDocumentUploadAccess: mockRequireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor: mockRequireKnowledgeDocumentUploadActor, + requireKnowledgeDocumentUploadBilling: mockRequireKnowledgeDocumentUploadBilling, +})) +vi.mock('@/app/api/files/uploads/utils', () => ({ uploadSessionErrorResponse: vi.fn() })) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession, + toV2KnowledgeDocumentUpload: (session: Record) => ({ + ...session, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: '2026-08-05T00:00:00.000Z', + document: null, + }), +})) + +import { POST } from '@/app/api/knowledge/[id]/documents/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +function request() { + return POST( + new NextRequest('http://localhost:3000/api/knowledge/kb-1/documents/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }), + }), + { params: Promise.resolve({ id: 'kb-1' }) } + ) +} + +describe('POST /api/knowledge/[id]/documents/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequireKnowledgeDocumentUploadActor.mockResolvedValue({ id: 'user-1' }) + mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: WORKSPACE_ID }, + }) + mockRequireKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) + mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'uploading', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + uploadToken: 'token', + error: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'application/pdf' }, + }, + }) + }) + + it('authorizes and bills before allocating a first-party upload session', async () => { + const response = await request() + + expect(response.status).toBe(201) + expect(mockRequireKnowledgeDocumentUploadAccess).toHaveBeenCalledWith({ + knowledgeBaseId: 'kb-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + localOrigin: 'http://localhost:3000', + }) + expect((await response.json()).data).toMatchObject({ + session: { id: 'upload-1', status: 'uploading', document: null }, + uploadToken: 'token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, + }) + expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] + ) + }) + + it('does not bill or allocate storage when write access is denied', async () => { + mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue( + NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + ) + + const response = await request() + + expect(response.status).toBe(403) + expect(mockRequireKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() + expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts new file mode 100644 index 00000000000..58a1c69c253 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts @@ -0,0 +1,71 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, + requireKnowledgeDocumentUploadBilling, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { + createKnowledgeDocumentUploadSession, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadsRouteParams { + params: Promise<{ id: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(createKnowledgeDocumentUploadContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + const billing = await requireKnowledgeDocumentUploadBilling({ + workspaceId, + userId: actor.id, + }) + if (billing instanceof NextResponse) return billing + const fileTypeError = validateFileType(name, contentType) + if (fileTypeError) { + return NextResponse.json({ error: fileTypeError.message }, { status: 415 }) + } + try { + const upload = await createKnowledgeDocumentUploadSession({ + workspaceId, + userId: actor.id, + knowledgeBaseId, + fileName: name, + contentType, + fileSize: size, + metadata, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json( + { + data: { + session: toV2KnowledgeDocumentUpload(upload, null), + uploadToken: upload.uploadToken, + transfer: upload.transfer, + }, + }, + { status: 201 } + ) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts new file mode 100644 index 00000000000..450b17ecd0b --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import type { KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + +export interface KnowledgeDocumentUploadActor { + id: string + name?: string | null + email?: string | null +} + +export async function requireKnowledgeDocumentUploadActor(): Promise< + KnowledgeDocumentUploadActor | NextResponse +> { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return { + id: session.user.id, + name: session.user.name, + email: session.user.email, + } +} + +export async function requireKnowledgeDocumentUploadAccess(params: { + knowledgeBaseId: string + workspaceId: string + userId: string +}): Promise<{ knowledgeBase: KnowledgeBaseAccessResult['knowledgeBase'] } | NextResponse> { + const access = await checkKnowledgeBaseWriteAccess(params.knowledgeBaseId, params.userId) + if (!access.hasAccess) { + return 'notFound' in access && access.notFound + ? NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + : NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (access.knowledgeBase.workspaceId !== params.workspaceId) { + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + return { knowledgeBase: access.knowledgeBase } +} + +export async function requireKnowledgeDocumentUploadBilling(params: { + workspaceId: string + userId: string +}): Promise { + const attribution = await resolveKnowledgeDocumentUploadAttribution(params) + const usage = await checkAttributedUsageLimits(attribution) + if (usage.isExceeded) { + return NextResponse.json( + { + error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + }, + { status: 402 } + ) + } + return attribution +} + +export function resolveKnowledgeDocumentUploadAttribution(params: { + workspaceId: string + userId: string +}): Promise { + return resolveBillingAttribution({ + actorUserId: params.userId, + workspaceId: params.workspaceId, + }) +} diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 5dee08582a6..a5ed8b85808 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -4,6 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -45,12 +49,17 @@ export const POST = withRouteHandler( const result = await performRestoreKnowledgeBase({ knowledgeBaseId: id, userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', requestId, + request, }) if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json( + { error: messageForOrchestrationError(result, 'Failed to restore knowledge base') }, + { status: statusForOrchestrationError(result.errorCode) } + ) } logger.info(`[${requestId}] Restored knowledge base ${id}`) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index cd47c173ab4..3b91289af20 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,20 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { PlatformEvents } from '@/lib/core/telemetry' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - deleteKnowledgeBase, - getKnowledgeBaseById, - KnowledgeBaseConflictError, - KnowledgeBaseFolderError, - KnowledgeBasePermissionError, - updateKnowledgeBase, -} from '@/lib/knowledge/service' + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseByIdAPI') @@ -69,93 +68,56 @@ export const PUT = withRouteHandler( const requestId = generateRequestId() const { id } = await context.params - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) } - - const parsed = await parseRequest(updateKnowledgeBaseContract, req, context) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - - const updatedKnowledgeBase = await updateKnowledgeBase( - id, - { - name: validatedData.name, - description: validatedData.description, - workspaceId: validatedData.workspaceId, - folderId: validatedData.folderId, - chunkingConfig: validatedData.chunkingConfig, - }, - requestId, - { actorUserId: userId } + logger.warn( + `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: validatedData.name ?? updatedKnowledgeBase.name, - description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`, - metadata: { - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.name && { newName: validatedData.name }), - ...(validatedData.description !== undefined && { - description: validatedData.description, - }), - ...(validatedData.chunkingConfig && { - chunkMaxSize: validatedData.chunkingConfig.maxSize, - chunkMinSize: validatedData.chunkingConfig.minSize, - chunkOverlap: validatedData.chunkingConfig.overlap, - }), - }, - request: req, - }) - - return NextResponse.json({ - success: true, - data: updatedKnowledgeBase, - }) - } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - if (error instanceof KnowledgeBaseFolderError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error instanceof KnowledgeBasePermissionError) { - logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`) - return NextResponse.json({ error: error.message }, { status: 403 }) - } - - logger.error(`[${requestId}] Error updating knowledge base`, error) - return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) + const parsed = await parseRequest(updateKnowledgeBaseContract, req, context) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + updates: { + name: body.name, + description: body.description, + workspaceId: body.workspaceId, + folderId: body.folderId, + chunkingConfig: body.chunkingConfig, + }, + requestId, + request: req, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ success: true, data: outcome.knowledgeBase }) } ) @@ -164,62 +126,49 @@ export const DELETE = withRouteHandler( const requestId = generateRequestId() const { id } = await params - try { - const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - await deleteKnowledgeBase(id, requestId) + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - try { - PlatformEvents.knowledgeBaseDeleted({ - knowledgeBaseId: id, - }) - } catch { - // Telemetry should not fail the operation + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) } + logger.warn( + `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`) - - recordAudit({ + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { + id, + name: accessCheck.knowledgeBase.name, workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: accessCheck.knowledgeBase.name, - description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase.name, - }, - request: _request, - }) - - return NextResponse.json({ - success: true, - data: { message: 'Knowledge base deleted successfully' }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting knowledge base`, error) - return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 }) + }, + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request: _request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ + success: true, + data: { message: 'Knowledge base deleted successfully' }, + }) } ) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index b2f9177b49d..09178f9dff1 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -7,19 +6,14 @@ import { } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { PlatformEvents } from '@/lib/core/telemetry' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' -import { - createKnowledgeBase, - getKnowledgeBases, - KnowledgeBaseConflictError, - KnowledgeBaseFolderError, - KnowledgeBasePermissionError, - type KnowledgeBaseScope, -} from '@/lib/knowledge/service' -import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/service' const logger = createLogger('KnowledgeBaseAPI') @@ -65,113 +59,49 @@ export const GET = withRouteHandler(async (req: NextRequest) => { export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createKnowledgeBaseContract, - req, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - - try { - const embeddingModel = getConfiguredEmbeddingModel() - - const createData = { - ...validatedData, - userId: session.user.id, - embeddingModel, - embeddingDimension: EMBEDDING_DIMENSIONS, - } - - const newKnowledgeBase = await createKnowledgeBase(createData, requestId) - - try { - PlatformEvents.knowledgeBaseCreated({ - knowledgeBaseId: newKnowledgeBase.id, - name: validatedData.name, - workspaceId: validatedData.workspaceId, - }) - } catch { - // Telemetry should not fail the operation - } - - captureServerEvent( - session.user.id, - 'knowledge_base_created', - { - knowledge_base_id: newKnowledgeBase.id, - workspace_id: validatedData.workspaceId, - name: validatedData.name, - }, - { - groups: { workspace: validatedData.workspaceId }, - setOnce: { first_kb_created_at: new Date().toISOString() }, - } - ) - - logger.info( - `[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}` - ) - - recordAudit({ - workspaceId: validatedData.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.KNOWLEDGE_BASE_CREATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: newKnowledgeBase.id, - resourceName: validatedData.name, - description: `Created knowledge base "${validatedData.name}"`, - metadata: { - name: validatedData.name, - description: validatedData.description, - embeddingModel, - embeddingDimension: EMBEDDING_DIMENSIONS, - chunkingStrategy: validatedData.chunkingConfig.strategy, - chunkMaxSize: validatedData.chunkingConfig.maxSize, - chunkMinSize: validatedData.chunkingConfig.minSize, - chunkOverlap: validatedData.chunkingConfig.overlap, - }, - request: req, - }) + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - return NextResponse.json({ - success: true, - data: newKnowledgeBase, - }) - } catch (createError) { - if (createError instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: createError.message }, { status: 409 }) - } - if (createError instanceof KnowledgeBaseFolderError) { - return NextResponse.json({ error: createError.message }, { status: 400 }) - } - if (createError instanceof KnowledgeBasePermissionError) { - logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`) - return NextResponse.json({ error: createError.message }, { status: 403 }) - } - throw createError + const parsed = await parseRequest( + createKnowledgeBaseContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ) + }, } - } catch (error) { - logger.error(`[${requestId}] Error creating knowledge base`, error) - return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + + const outcome = await performCreateKnowledgeBase({ + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + source: 'ui', + workspaceId: body.workspaceId, + name: body.name, + description: body.description, + folderId: body.folderId, + chunkingConfig: body.chunkingConfig, + requestId, + request: req, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ success: true, data: outcome.knowledgeBase }) }) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 99bf39603b6..837aec3a24b 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -16,12 +16,14 @@ import { NextRequest } from 'next/server' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { + mockExecuteWorkflowService, mockAssertBillingAttributionSnapshot, mockGenerateInternalToken, mockResolveBillingAttribution, mockSerializeBillingAttributionHeader, fetchMock, } = vi.hoisted(() => ({ + mockExecuteWorkflowService: vi.fn(), mockAssertBillingAttributionSnapshot: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveBillingAttribution: vi.fn(), @@ -65,6 +67,10 @@ vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, })) +vi.mock('@/lib/workflows/executor/execute-service', () => ({ + executeWorkflowService: mockExecuteWorkflowService, +})) + import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { @@ -230,7 +236,7 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) - it('uses an internal bridge token for private server api_key auth', async () => { + it('executes in-process with the personal-key actor override for private server api_key auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -251,13 +257,16 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -272,21 +281,25 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) - expect(fetchMock).toHaveBeenCalledTimes(1) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-user-1') - expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user') - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['X-API-Key']).toBeUndefined() - expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1) + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + userId: 'user-1', + triggerType: 'mcp', + useAuthenticatedUserAsActor: true, + deploymentVersionId: 'deployment-1', + includeFileBase64: false, + rejectLargeInlineOutput: true, + }) + ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'user-1', workspaceId: 'ws-1', }) }) - it('forwards internal token for private server session auth', async () => { + it('executes in-process without the actor override for private server session auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -306,13 +319,16 @@ describe('MCP Serve Route', () => { authType: 'session', }) mockGetUserEntityPermissions.mockResolvedValueOnce('read') - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -326,14 +342,12 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) - expect(fetchMock).toHaveBeenCalledTimes(1) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-user-1') - expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined() - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['X-API-Key']).toBeUndefined() - expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + useAuthenticatedUserAsActor: false, + }) + ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'user-1', workspaceId: 'ws-1', @@ -353,13 +367,16 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -380,12 +397,9 @@ describe('MCP Serve Route', () => { }) const attribution = createBillingAttribution('owner-1', 'ws-1') expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(attribution) - expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(attribution) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-owner-1') - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['x-sim-billing-attribution']).not.toBe('caller-controlled-attribution') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ upstreamBillingAttribution: attribution, userId: 'owner-1' }) + ) }) it.each([null, 'ws-other'])( @@ -545,8 +559,7 @@ describe('MCP Serve Route', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('cancels and rejects oversized workflow execution responses', async () => { - const cancelSpy = vi.fn() + it('maps oversized workflow outputs to the response-direction 413', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -559,17 +572,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - new ReadableStream({ - cancel: cancelSpy, - }), - { - status: 200, - headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'output_too_large', + statusCode: 413, + message: 'Workflow execution response exceeds maximum size', + code: 'workflow_response_too_large', + executionId: 'exec-1', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -580,17 +593,19 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(413) - expect(body.error.message).toContain('MCP workflow execution response') - expect(cancelSpy).toHaveBeenCalled() + expect(body.error.data.httpStatus).toBe(413) + // Response-direction 413 keeps the workflow_response_too_large code so + // clients can distinguish it from a request-side payload rejection. + expect(body.error.data.code).toBe('workflow_response_too_large') + expect(body.error.data.executionId).toBe('exec-1') }) - it('cancels and rejects streamed workflow responses that exceed the cap', async () => { - const cancelSpy = vi.fn() + it('surfaces rate-limit failures with Retry-After and the retryable flag', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -603,21 +618,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT)) - controller.enqueue(new Uint8Array(1)) - }, - cancel: cancelSpy, - }), - { - status: 200, - headers: { 'content-length': '1' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'precheck', + statusCode: 429, + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: 9_000, + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -628,13 +639,14 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() - expect(response.status).toBe(413) - expect(body.error.message).toContain('MCP workflow execution response') - expect(cancelSpy).toHaveBeenCalled() + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('9') + expect(body.error.data.retryable).toBe(true) + expect(body.error.data.code).toBe('RATE_LIMIT_EXCEEDED') }) it('preserves recoverable workflow execution statuses through the MCP bridge', async () => { @@ -650,18 +662,15 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: 'Workflow execution request body exceeds maximum size', - }), - { - status: 413, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'infra', + statusCode: 503, + message: 'Error checking rate limits', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -672,19 +681,13 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() - expect(response.status).toBe(413) - expect(body.error.code).toBe(-32600) - expect(body.error.data.httpStatus).toBe(413) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers['X-Sim-MCP-Tool-Call']).toBe('true') - expect(JSON.parse(fetchOptions.body as string)).toMatchObject({ - deploymentVersionId: 'deployment-1', - }) + expect(response.status).toBe(503) + expect(body.error.data.httpStatus).toBe(503) + expect(body.error.data.retryable).toBe(true) }) it('preserves downstream attributed usage admission rejections', async () => { @@ -700,18 +703,15 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: 'Workspace usage limit exceeded.', - }), - { - status: 402, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'precheck', + statusCode: 402, + message: 'Workspace usage limit exceeded.', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -722,16 +722,16 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a' }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(402) - expect(body.error.message).toBe('Workspace usage limit exceeded.') expect(body.error.data.httpStatus).toBe(402) + expect(body.error.message).toBe('Workspace usage limit exceeded.') }) - it('preserves upstream error status when workflow response is not JSON', async () => { + it('maps the sync timeout onto the retryable 408 shape', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -744,7 +744,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 })) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'failed', + aborted: 'timeout', + output: undefined, + error: { message: 'Execution timed out after 60000ms', code: 'TIMEOUT' }, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -755,13 +765,14 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(408) expect(body.error.data.httpStatus).toBe(408) expect(body.error.data.retryable).toBe(true) + expect(body.error.data.code).toBe('TIMEOUT') }) it('preserves falsy workflow outputs in MCP tool results', async () => { @@ -777,12 +788,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ success: true, output: false }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: false, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -793,15 +809,16 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) expect(body.result.content[0].text).toBe('false') + expect(body.result.isError).toBe(false) }) - it('serializes missing workflow output without failing the MCP tool call', async () => { + it('serializes failed runs with the structured error and child executionId', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -814,12 +831,23 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-9', + workflowId: 'wf-1', + status: 'failed', + aborted: null, + output: { partial: true }, + error: { + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'b-1', + blockName: 'Send Email', + blockType: 'gmail', + }, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -830,27 +858,32 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) - expect(body.result.content[0].text).toContain('"success": true') + expect(body.result.isError).toBe(true) + const text = body.result.content[0].text + expect(text).toContain('"executionId": "exec-9"') + expect(text).toContain('"code": "BLOCK_EXECUTION_FAILED"') + expect(text).toContain('"blockName": "Send Email"') }) - it('serializes non-object workflow JSON responses from response blocks', async () => { + it('serializes non-object workflow outputs', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { id: 'server-1', - name: 'Private Server', + name: 'Public Server', workspaceId: 'ws-1', - isPublic: false, + isPublic: true, createdBy: 'owner-1', }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, userId: 'user-1', @@ -858,12 +891,16 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify(['a', 'b']), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: ['a', 'b'], + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -875,12 +912,12 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) - expect(body.result.content[0].text).toBe(JSON.stringify(['a', 'b'], null, 2)) + expect(JSON.parse(body.result.content[0].text)).toEqual(['a', 'b']) }) it('rejects duplicate tool names instead of choosing an arbitrary workflow', async () => { @@ -917,8 +954,7 @@ describe('MCP Serve Route', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('aborts the internal workflow fetch when the MCP client disconnects', async () => { - const requestAbortController = new AbortController() + it('maps a client-aborted run onto ConnectionClosed', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -931,36 +967,34 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockImplementationOnce((_url, init: RequestInit) => { - const signal = init.signal as AbortSignal - return new Promise((_resolve, reject) => { - signal.addEventListener( - 'abort', - () => { - reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) - }, - { once: true } - ) - requestAbortController.abort() - }) - }) - const req = new NextRequest( - new Request('http://localhost:3000/api/mcp/serve/server-1', { - method: 'POST', - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: 'tool_a', arguments: { q: 'test' } }, - }), - signal: requestAbortController.signal, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + }) + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + expect(response.status).toBe(499) + expect(body.error.data.httpStatus).toBe(499) + expect(body.error.data.executionId).toBe('exec-1') }) it('paginates tools/list by tool count', async () => { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index e342f35e343..a60436ec6cf 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -35,34 +35,29 @@ import { mcpToolCallParamsSchema, } from '@/lib/api/contracts/mcp' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' -import { generateInternalToken } from '@/lib/auth/internal' import { assertBillingAttributionSnapshot, - BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, resolveBillingAttribution, - serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { generateRequestId } from '@/lib/core/utils/request' import { assertContentLengthWithinLimit, assertKnownSizeWithinLimit, isPayloadSizeLimitError, - readResponseTextWithLimit, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' +import { parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_LIST_RESPONSE_BYTES, MAX_MCP_TOOLS_PER_SERVER, MAX_MCP_WORKFLOW_RESPONSE_BYTES, - MCP_TOOL_BRIDGE_ACTOR_HEADER, - MCP_TOOL_BRIDGE_HEADER, } from '@/lib/mcp/constants' import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' +import { executeWorkflowService } from '@/lib/workflows/executor/execute-service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -281,21 +276,6 @@ async function getDuplicateToolName(serverId: string): Promise { return duplicate?.toolName ?? null } -async function readWorkflowExecutionResult( - response: Response, - signal: AbortSignal -): Promise { - const text = await readResponseTextWithLimit(response, { - maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, - label: 'MCP workflow execution response', - signal, - }) - const parsed = parseJsonValue(text) - if (parsed.success) return parsed.value - if (!response.ok) return { error: response.statusText || 'Workflow execution failed' } - throw new Error('Invalid workflow execution response') -} - async function getServer(serverId: string) { const [server] = await db .select({ @@ -809,83 +789,113 @@ async function handleToolsCall( wf.workspaceId ) - const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute` - const headers: Record = { - 'Content-Type': 'application/json', - [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), - [MCP_TOOL_BRIDGE_HEADER]: 'true', - } - const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal) if (abortedBeforeExecute) return abortedBeforeExecute - const internalToken = await generateInternalToken(actorUserId) - headers.Authorization = `Bearer ${internalToken}` - if (executeAuthContext?.useAuthenticatedUserAsActor) { - headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user' - } - - if (simViaHeader) { - headers[SIM_VIA_HEADER] = simViaHeader - } - - logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name} (in-process)`) - const workflowRequestBody = JSON.stringify({ - input: params.arguments || {}, - triggerType: 'mcp', - includeFileBase64: false, - ...(wf.deploymentVersionId ? { deploymentVersionId: wf.deploymentVersionId } : {}), - }) + const workflowInput = params.arguments || {} assertKnownSizeWithinLimit( - Buffer.byteLength(workflowRequestBody, 'utf-8'), + Buffer.byteLength(JSON.stringify(workflowInput), 'utf-8'), MAX_MCP_WORKFLOW_REQUEST_BYTES, 'MCP workflow execution request body' ) - const response = await fetch(executeUrl, { - method: 'POST', - headers, - body: workflowRequestBody, - signal: abortSignal.signal, - }) - const executeResult = await readWorkflowExecutionResult(response, abortSignal.signal) - const executeResultObject = isJsonObject(executeResult) ? executeResult : null + /** + * In-process execution replaces the historical HTTP hop to the execute + * endpoint: the bridge's special needs — deployment-version pinning, MCP + * response-size rejection, actor override — are typed options instead of + * header sniffing, and billing attribution is passed as the immutable + * upstream snapshot exactly as the header carried it. + */ + const serviceResult = await executeWorkflowService({ + workflowId: tool.workflowId, + userId: actorUserId, + input: workflowInput, + triggerType: 'mcp', + requestId: generateRequestId(), + useAuthenticatedUserAsActor: executeAuthContext?.useAuthenticatedUserAsActor ?? false, + upstreamBillingAttribution: billingAttribution, + deploymentVersionId: wf.deploymentVersionId, + includeFileBase64: false, + rejectLargeInlineOutput: true, + callChain: simViaHeader ? parseCallChain(simViaHeader) : undefined, + abortSignal: abortSignal.signal, + }) - if (!response.ok) { - const errorMessage = - typeof executeResultObject?.error === 'string' - ? executeResultObject.error - : 'Workflow execution failed' - const status = getWorkflowErrorStatus(response.status) + if (!serviceResult.ok) { + const failure = serviceResult.failure + const status = getWorkflowErrorStatus(failure.statusCode) const responseHeaders: Record = {} - const retryAfter = response.headers.get('retry-after') - if (retryAfter) responseHeaders['Retry-After'] = retryAfter + if (failure.retryAfterMs !== undefined) { + responseHeaders['Retry-After'] = Math.max( + 1, + Math.ceil(failure.retryAfterMs / 1000) + ).toString() + } return NextResponse.json( createError( id, - getWorkflowErrorCode(response.status, executeResultObject ?? {}), - errorMessage, + getWorkflowErrorCode(failure.statusCode, { code: failure.code }), + failure.message, { - httpStatus: response.status, - retryable: [408, 429, 503].includes(response.status), - code: - typeof executeResultObject?.code === 'string' ? executeResultObject.code : undefined, + httpStatus: failure.statusCode, + retryable: [408, 429, 503].includes(failure.statusCode), + code: failure.code, + ...(failure.executionId ? { executionId: failure.executionId } : {}), } ), { status, headers: responseHeaders } ) } - const toolOutput = - executeResultObject?.success === false - ? executeResult - : executeResultObject && hasResponseField(executeResultObject, 'output') - ? executeResultObject.output - : executeResult + if ('queued' in serviceResult || 'stream' in serviceResult) { + // The bridge never requests async or stream modes. + throw new Error('Unexpected execution mode result for MCP tool call') + } + + if (serviceResult.aborted === 'client') { + return NextResponse.json( + createError(id, ErrorCode.ConnectionClosed, 'Client cancelled request', { + httpStatus: 499, + retryable: false, + executionId: serviceResult.executionId, + }), + { status: 499 } + ) + } + + if (serviceResult.aborted === 'timeout') { + return NextResponse.json( + createError( + id, + ErrorCode.InternalError, + serviceResult.error?.message ?? 'Execution timed out', + { + httpStatus: 408, + retryable: true, + code: 'TIMEOUT', + executionId: serviceResult.executionId, + } + ), + { status: 408 } + ) + } + + const isError = serviceResult.status !== 'completed' + const toolOutput = isError + ? { + success: false, + executionId: serviceResult.executionId, + output: serviceResult.output ?? {}, + // Structured error: parents/clients route on `code` and hand the + // provider the executionId to reproduce the failure. + error: serviceResult.error, + } + : (serviceResult.output ?? {}) const result: CallToolResult = { content: [{ type: 'text', text: serializeToolText(toolOutput) }], - isError: executeResultObject?.success === false, + isError, } return createJsonRpcResponseWithLimit( diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index f31f0881e71..251635a37fb 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,10 +9,14 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + statusForSkillOrchestrationError, +} from '@/lib/skills/orchestration' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') @@ -92,84 +95,75 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { - return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 }) + /** + * Each item is applied through the skill orchestration, which owns the + * built-in guard, the field limits, the per-skill editor check, and the + * audit. Creating still requires workspace write; editing an existing skill + * is gated per skill inside `performUpdateSkill`. + * + * The batch is applied item by item rather than in one transaction: this + * endpoint's callers submit a single skill, and one shared authority for the + * rules is worth more than atomicity across a batch nobody sends. + */ + const actor = { + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request: req, } - // Updating an existing skill requires editor access (explicit editor row - // or derived workspace admin); creating a new one requires workspace write. - const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : [])) - const { existingIds, denied } = await checkSkillsUpdateAccess({ - workspaceId, - userId, - skillIds: requestedIds, - workspaceAccess, - }) - - if (denied.length > 0) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, { - deniedSkillIds: denied.map((s) => s.id), - }) - return NextResponse.json( - { - error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`, - }, - { status: 403 } - ) - } + for (const item of skills) { + if (item.id) { + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: item.id, + name: item.name, + description: item.description, + content: item.content, + ...actor, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill update rejected`, { + skillId: item.id, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to update skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } + continue + } - const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id)) - if (hasCreates && !workspaceAccess.canWrite) { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } + if (!workspaceAccess.canWrite) { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } - try { - const { touched } = await upsertSkills({ - skills, + const result = await performCreateSkill({ workspaceId, userId, - requestId, - returnSkills: false, + name: item.name!, + description: item.description!, + content: item.content!, + ...actor, }) - - for (const { id, name, operation } of touched) { - const isUpdate = operation === 'updated' - recordAudit({ - workspaceId, - actorId: userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: isUpdate ? AuditAction.SKILL_UPDATED : AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: id, - resourceName: name, - description: `${isUpdate ? 'Updated' : 'Created'} skill "${name}"`, - metadata: { source }, - }) - captureServerEvent( - userId, - isUpdate ? 'skill_updated' : 'skill_created', - { skill_id: id, skill_name: name, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } + if (!result.success) { + logger.warn(`[${requestId}] Skill create rejected`, { errorCode: result.errorCode }) + return NextResponse.json( + { error: result.error ?? 'Failed to create skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } ) } + } - const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) - const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) + const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) + const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) - return NextResponse.json({ success: true, data }) - } catch (upsertError) { - if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) { - return NextResponse.json({ error: upsertError.message }, { status: 409 }) - } - if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) { - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - throw upsertError - } + return NextResponse.json({ success: true, data }) } catch (error) { logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) @@ -200,42 +194,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id: skillId, workspaceId, source } = query.data - if (!isBuiltinSkillId(skillId)) { - const actor = await getSkillActorContext(skillId, userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - if (!actor.canEdit) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`) - return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) - } - } - - const deleted = await deleteSkill({ skillId, workspaceId }) - if (!deleted) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: authResult.userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: skillId, - description: `Deleted skill`, - metadata: { source }, - }) - - captureServerEvent( userId, - 'skill_deleted', - { skill_id: skillId, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } - ) + skillId, + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill delete rejected`, { + skillId, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to delete skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } logger.info(`[${requestId}] Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.test.ts b/apps/sim/app/api/table/[tableId]/columns/route.test.ts index 4ac282861cd..e8497a0fa04 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts @@ -42,6 +42,13 @@ vi.mock('@/lib/table', () => ({ updateColumnOptions: mockUpdateColumnOptions, updateColumnType: mockUpdateColumnType, })) +vi.mock('@/lib/table/columns/service', () => ({ + renameColumn: mockRenameColumn, + updateColumnConstraints: mockUpdateColumnConstraints, + updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnOptions: mockUpdateColumnOptions, + updateColumnType: mockUpdateColumnType, +})) vi.mock('@/app/api/table/utils', () => ({ accessError: () => new Response('denied', { status: 403 }), checkAccess: mockCheckAccess, @@ -50,6 +57,7 @@ vi.mock('@/app/api/table/utils', () => ({ tableLockErrorResponse: () => null, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/table/[tableId]/columns/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' @@ -159,7 +167,10 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { // Stands in for the race the guards cannot close: the column stopped being // a currency between the snapshot the guards read and this write. mockUpdateColumnCurrency.mockRejectedValue( - new Error('Cannot set currency on column "amount" of type "string"') + new OrchestrationError( + 'validation', + 'Cannot set currency on column "amount" of type "string"' + ) ) const response = await patch({ name: 'renamed', currencyCode: 'USD' }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index abda376ede7..d899372d2be 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -8,21 +8,12 @@ import { import { parseRequest } from '@/lib/api/server' import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, -} from '@/lib/table' -import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { addTableColumn, deleteColumn } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -122,216 +113,35 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const { updates } = validated - let updatedTable = null - - // A payload that repeats the current type must not go through - // `updateColumnType` — it early-returns on an unchanged type and would drop - // any `options` alongside it. Only a real type change routes there; an - // unchanged type with options routes to the options-only update. - const currentColumn = table.schema.columns.find((c) => - columnMatchesRef(c, validated.columnName) - ) - // Address every write below by the stable id, not the name: a rename folded - // into one of them must not break the next one's lookup. - const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName - // The constraints write below is a separate, unconditional step, so it is - // the last one whenever it runs — that is the write the rename rides on. - const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type - if (!currentColumn) { - return NextResponse.json( - { error: `Column "${validated.columnName}" not found` }, - { status: 404 } - ) - } - - // A retype applies and validates the constraints itself, so the separate - // constraint write only runs when the type is unchanged. The rename rides - // whichever write actually runs last. - const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined - const constraintsWriteRuns = - !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) - const renameWithTypedWrite = - updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} - - // Every write below is its own locked transaction, so one that is going to - // fail leaves the earlier ones committed. These guards reject the knowable - // cases up front, before any write at all. - // Gate on the type the column ENDS UP with, not on whether the type is - // changing: an options-only update on an existing select column carries the - // same hazard as a conversion does. - const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } - } - // The rename runs last (see below), so a name already taken would fail after - // the typed write committed. This is the only rename failure a caller can - // cause; catching it here leaves just the concurrent-collision race, which - // no pre-flight check can close. - if ( - updates.name && - table.schema.columns.some( - (c) => - c.name.toLowerCase() === updates.name?.toLowerCase() && - !columnMatchesRef(c, validated.columnName) - ) - ) { - return NextResponse.json( - { error: `Column "${updates.name}" already exists` }, - { status: 400 } - ) - } - if ( - currentColumn?.workflowGroupId && - (updates.required !== undefined || updates.unique !== undefined) - ) { - return NextResponse.json( - { - error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, - }, - { status: 400 } - ) - } - if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId: authResult.userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { return NextResponse.json( - { error: `Cannot set a ${resultingType} column as unique` }, - { status: 400 } - ) - } - - if (typeChanging) { - updatedTable = await updateColumnType( - { - tableId, - columnName: columnRef, - newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), - // Forwarded so the conversion validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( - { - tableId, - columnName: columnRef, - currencyCode: updates.currencyCode, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { - updatedTable = await updateColumnOptions( - { - tableId, - columnName: columnRef, - options: updates.options ?? currentColumn?.options ?? [], - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - // Forwarded so the removal guard validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } - - // Skipped whenever a typed write ran: that write already applied and - // validated these, in one transaction with the change they accompany. - if (constraintsWriteRuns) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: columnRef, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...(updates.name ? { newName: updates.name } : {}), - }, - requestId + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - // A rename rides along with the LAST write above, inside that write's - // transaction — a rename is metadata-only (rows key on the stable column - // id), so nothing forces it to be its own write, and folding it in is what - // stops a combined request from committing one half and then failing. Only - // a rename with nothing to ride on runs standalone. - if (updates.name && !updatedTable) { - updatedTable = await renameColumn( - { tableId, oldName: columnRef, newName: updates.name }, - requestId - ) - } - - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + // Live-collab: tell open viewers the change landed so they refetch. signalTableSchemaChanged(tableId) return NextResponse.json({ success: true, data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + columns: outcome.table.schema.columns.map(normalizeColumn), }, }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } - const msg = rootErrorMessage(error) - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Cannot set unique column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') || - msg.includes('option') || - msg.includes('currency') || - msg.includes('is already type') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } - logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index e1927a204c1..e9140d22a83 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -9,7 +9,12 @@ import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { toLegacyFilter } from '@/lib/table/query-builder/converters' import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableFilterError, +} from '@/app/api/table/utils' const logger = createLogger('TableRunColumnAPI') @@ -74,9 +79,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (error instanceof TableQueryValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - if (error instanceof Error && error.message === 'Invalid workspace ID') { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`run-column failed:`, error) return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 58df047c629..17a845ed0ae 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -1,25 +1,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { namedRowMapper } from '@/lib/table/cell-format' -import { getColumnId } from '@/lib/table/column-keys' -import { formatCsvCell } from '@/lib/table/export-format' -import { queryRows } from '@/lib/table/rows/service' +import { + createTableExportStream, + exportContentType, + sanitizeExportFilename, +} from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableExport') - -const EXPORT_BATCH_SIZE = 1000 - -type ExportFormat = 'csv' | 'json' - interface RouteParams { params: Promise<{ tableId: string }> } @@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou { status: 400 } ) } - const format: ExportFormat = formatValidation.data + const format = formatValidation.data const access = await checkAccess(tableId, auth.userId, 'read') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access - const columns = table.schema.columns - // Stored row data is id-keyed; CSV headers and JSON keys are display names, so - // translate id → name on the way out (export is a name-friendly boundary). - const toNamedRow = namedRowMapper(columns) - const safeName = sanitizeFilename(table.name) - const filename = `${safeName}.${format}` - // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, @@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou ) } - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - try { - if (format === 'csv') { - controller.enqueue( - encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) - ) - } else { - controller.enqueue(encoder.encode('[')) - } - - let offset = 0 - let firstJsonRow = true - while (true) { - const result = await queryRows( - table, - { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, - requestId - ) - - for (const row of result.rows) { - if (format === 'csv') { - const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) - controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) - } else { - const prefix = firstJsonRow ? '' : ',' - firstJsonRow = false - controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) - } - } - - // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, - // so a short page does NOT mean the export is done — only a null cursor does. - if (!result.nextCursor) break - offset += result.rows.length - } - - if (format === 'json') controller.enqueue(encoder.encode(']')) - controller.close() - - logger.info(`[${requestId}] Exported table ${tableId}`, { - format, - rowCount: table.rowCount, - }) - } catch (err) { - logger.error(`[${requestId}] Export failed for table ${tableId}`, err) - controller.error(err) - } - }, - }) - - return new NextResponse(stream, { + return new NextResponse(createTableExportStream(table, format, requestId), { status: 200, headers: { - 'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Type': exportContentType(format), + 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, 'Cache-Control': 'no-store', }, }) }) - -function sanitizeFilename(name: string): string { - const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') - return cleaned || 'table' -} - -function toCsvRow(values: string[]): string { - return values.map(escapeCsvField).join(',') -} - -function escapeCsvField(field: string): string { - if (/[",\n\r]/.test(field)) { - return `"${field.replace(/"/g, '""')}"` - } - return field -} diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts new file mode 100644 index 00000000000..525f455b81b --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableExportResource, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, 'table-export') + if (access.table.workspaceId !== parsed.data.body.workspaceId) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } + try { + const record = await createTableExportResource({ + table: access.table, + format: parsed.data.body.format, + }) + return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index baf8c313a4f..a2689295725 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -79,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({ limit >= 0 && current + added > limit, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/[tableId]/import/route' @@ -372,7 +373,10 @@ describe('POST /api/table/[tableId]/import', () => { it('surfaces unique violations from importAppendRows as 400', async () => { mockImportAppendRows.mockRejectedValueOnce( - new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx') + new OrchestrationError( + 'validation', + 'Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx' + ) ) const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) @@ -516,7 +520,9 @@ describe('POST /api/table/[tableId]/import', () => { }) it('surfaces column-creation failures from importAppendRows as 400', async () => { - mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists')) + mockImportAppendRows.mockRejectedValueOnce( + new OrchestrationError('validation', 'Column "email" already exists') + ) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', @@ -529,7 +535,9 @@ describe('POST /api/table/[tableId]/import', () => { }) it('surfaces row insert failures without success when schema was mutated', async () => { - mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique')) + mockImportAppendRows.mockRejectedValueOnce( + new OrchestrationError('validation', 'must be unique') + ) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 8ee9ed8f170..465777f46a3 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -1,7 +1,5 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, @@ -14,38 +12,18 @@ import { import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - buildAutoMapping, - CSV_MAX_FILE_SIZE_BYTES, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - createCsvParser, - dispatchAfterBatchInsert, - generateColumnId, - getMaxRowsPerTable, - inferColumnType, - markTableJobRunning, - releaseJobClaim, - sanitizeName, - type TableDefinition, - type TableSchema, - validateMapping, - wouldExceedRowLimit, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' -import { signalTableSchemaChanged } from '@/lib/table/events' -import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table' +import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, - tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -62,7 +40,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const requestId = generateRequestId() const { tableId } = tableIdParamsSchema.parse(await params) let fileStream: Readable | undefined - let claimedImportId: string | null = null try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -131,18 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if (table.archivedAt) { - return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) - } - // Don't run a sync import on top of an in-flight background job — concurrent writers - // would insert at colliding row positions. - if (table.jobStatus === 'running') { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - let mapping: CsvHeaderMapping | undefined if (fields.mapping) { const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping) @@ -179,264 +144,46 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro timezone = timezoneValidation.data } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionValidation.data === 'tsv' ? '\t' : ',' - ) - let headers: string[] = [] - const parser = createCsvParser(delimiter, (parsedHeaders) => { - headers = parsedHeaders + const outcome = await performTableCsvImport({ + table, + workspaceId, + userId: authResult.userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionValidation.data === 'tsv' ? '\t' : ',', + mode, + mapping, + createColumns, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (streamErr) => parser.destroy(streamErr)) - csvStream.pipe(parser) - const rows: Record[] = [] - for await (const record of parser as AsyncIterable>) { - rows.push(record) - } - if (rows.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - - let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) - let prospectiveTable: TableDefinition = table - const additions: { id?: string; name: string; type: string }[] = [] - - if (createColumns && createColumns.length > 0) { - const headerSet = new Set(headers) - const unknownHeaders = createColumns.filter((h) => !headerSet.has(h)) - if (unknownHeaders.length > 0) { - return NextResponse.json( - { - error: `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, - }, - { status: 400 } - ) - } - - const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) - const updatedMapping: CsvHeaderMapping = { ...effectiveMapping } - const newColumns: TableSchema['columns'] = [] - for (const header of createColumns) { - const base = sanitizeName(header) - let columnName = base - let suffix = 2 - while (usedNames.has(columnName.toLowerCase())) { - columnName = `${base}_${suffix}` - suffix++ - } - usedNames.add(columnName.toLowerCase()) - const inferredType = inferColumnType(rows.map((r) => r[header])) - // Pre-assign the id so the prospective schema (used to coerce rows) and - // the persisted column (created in importAppendRows) share the same key. - const id = generateColumnId() - additions.push({ id, name: columnName, type: inferredType }) - newColumns.push({ - id, - name: columnName, - type: inferredType as TableSchema['columns'][number]['type'], - required: false, - unique: false, - }) - updatedMapping[header] = columnName + if (!outcome.success) { + // A lock rejection renders `{ error, lock }` and deliberately carries NO + // `details`: the client's `isValidationError` treats any array-valued + // `details` as a field-validation error and swallows the toast. + if (outcome.errorCode === 'locked') { + return NextResponse.json({ error: outcome.error, lock: outcome.lock }, { status: 423 }) } - - prospectiveTable = { - ...table, - schema: { columns: [...table.schema.columns, ...newColumns] }, - } - effectiveMapping = updatedMapping - } - - let validation: ReturnType - try { - validation = validateMapping({ - csvHeaders: headers, - mapping: effectiveMapping, - tableSchema: prospectiveTable.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return NextResponse.json({ error: err.message, details: err.details }, { status: 400 }) - } - throw err - } - - if (validation.mappedHeaders.length === 0) { return NextResponse.json( { - error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveTable.schema.columns.map((c) => c.name).join(', ')}`, + error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error, + ...(outcome.details !== undefined ? { details: outcome.details } : {}), + // The append dialog reads this to distinguish "nothing landed" from a + // partial import; only that mode has ever carried it. + ...(mode === 'append' ? { data: { insertedCount: 0 } } : {}), }, - { status: 400 } - ) - } - - const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, { - timezone, - }) - - // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot - // taken before the parse/validation; a background import could claim the table in that window. - // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in - // the finally so a sync import can't write concurrently with a background one (corrupts replace). - const syncImportId = generateId() - if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } + { status: statusForOrchestrationError(outcome.errorCode) } ) } - claimedImportId = syncImportId - - if (mode === 'append') { - const maxRows = await getMaxRowsPerTable(workspaceId) - if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) { - const deficit = prospectiveTable.rowCount + coerced.length - maxRows - return NextResponse.json( - { - error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, - }, - { status: 400 } - ) - } - - try { - const { inserted: insertedRows, table: finalTable } = await importAppendRows( - table, - additions, - coerced, - { workspaceId, userId: authResult.userId, requestId } - ) - const inserted = insertedRows.length - // Fire trigger + scheduler AFTER the tx commits — both read through the - // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, insertedRows, requestId, authResult.userId) - - logger.info(`[${requestId}] Append CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - inserted, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - skippedHeaders: validation.skippedHeaders.length, - }) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - insertedCount: inserted, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - // This branch returns rather than rethrowing, so the outer catch's - // mapper is unreachable from here — map the lock error first or a 423 - // degrades into a generic 500 (replace mode rethrows and maps fine). - const lockError = tableLockErrorResponse(err) - if (lockError) return lockError - - const message = toError(err).message - logger.warn(`[${requestId}] Append failed for table ${tableId}`, { - total: coerced.length, - createdColumns: additions.length, - error: message, - }) - const isClientError = - message.includes('row limit') || - message.includes('Insufficient capacity') || - message.includes('Schema validation') || - message.includes('must be unique') || - message.includes('Row size exceeds') || - message.includes('already exists') || - message.includes('Invalid column name') || - /^Row \d+:/.test(message) - return NextResponse.json( - { - error: isClientError ? message : 'Failed to import CSV', - data: { insertedCount: 0 }, - }, - { status: isClientError ? 400 : 500 } - ) - } - } - - try { - const result = await importReplaceRows( - table, - additions, - { rows: coerced, workspaceId, userId: authResult.userId }, - requestId - ) - - logger.info(`[${requestId}] Replace CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - deleted: result.deletedCount, - inserted: result.insertedCount, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - const message = toError(err).message - const isClientError = - message.includes('row limit') || - message.includes('Schema validation') || - message.includes('must be unique') || - message.includes('Row size exceeds') || - message.includes('already exists') || - message.includes('Invalid column name') || - /^Row \d+:/.test(message) - if (isClientError) { - return NextResponse.json({ error: message }, { status: 400 }) - } - throw err - } + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) - const message = toError(error).message logger.error(`[${requestId}] CSV import into existing table failed:`, error) - - const isClientError = - message.includes('CSV file has no') || - message.includes('already exists') || - message.includes('Invalid column name') - - return NextResponse.json( - { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } - ) + return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() - // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts index 7f1f48243c7..2396ba13a21 100644 --- a/apps/sim/app/api/table/[tableId]/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/route.test.ts @@ -33,6 +33,13 @@ vi.mock('@/lib/table', () => ({ updateTableLocks: mockUpdateTableLocks, TableConflictError: class extends Error {}, })) +vi.mock('@/lib/table/service', () => ({ + deleteTable: mockDeleteTable, + getTableById: mockGetTableById, + moveTableToFolder: mockMoveTableToFolder, + renameTable: mockRenameTable, + updateTableLocks: mockUpdateTableLocks, +})) vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits })) vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() })) @@ -77,6 +84,13 @@ const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) } describe('PATCH /api/table/[tableId] folder moves', () => { beforeEach(() => { vi.clearAllMocks() + mockMoveTableToFolder.mockResolvedValue({ name: 'Table' }) + mockRenameTable.mockResolvedValue({ id: 'tbl_1', name: 'Table' }) + mockDeleteTable.mockResolvedValue({ archived: { name: 'Table', workspaceId: 'workspace-1' } }) + mockUpdateTableLocks.mockResolvedValue({ + table: { ...TABLE, locks: {} }, + previousLocks: {}, + }) hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -99,8 +113,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => { 'tbl_1', 'workspace-1', 'folder-1', - expect.any(String), - 'user-1' + expect.any(String) ) }) @@ -118,8 +131,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => { 'tbl_1', 'workspace-1', null, - expect.any(String), - 'user-1' + expect.any(String) ) }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 225e7556613..12f40106575 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -5,21 +5,19 @@ import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/ta import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { captureServerEvent } from '@/lib/posthog/server' -import { - deleteTable, - getTableById, - moveTableToFolder, - renameTable, - TableConflictError, - type TableSchema, - updateTableLocks, -} from '@/lib/table' +import { getTableById, TableConflictError, type TableSchema } from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' import { signalTableSchemaChanged } from '@/lib/table/events' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from '@/lib/table/orchestration' import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { @@ -181,13 +179,35 @@ export const PATCH = withRouteHandler( { status: 403 } ) } - await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request) + const lockOutcome = await performUpdateTableLocks({ + tableId, + partial: validated.locks, + userId: authResult.userId, + requestId, + request, + }) + if (!lockOutcome.success) { + return NextResponse.json( + { error: lockOutcome.error ?? 'Failed to update table locks' }, + { status: statusForOrchestrationError(lockOutcome.errorCode) } + ) + } } if (validated.name !== undefined) { - await renameTable(tableId, validated.name, requestId, authResult.userId) - // Live-collab: tell open viewers the definition changed so they refetch. - signalTableSchemaChanged(tableId) + const renameOutcome = await performRenameTable({ + table, + newName: validated.name, + userId: authResult.userId, + requestId, + request, + }) + if (!renameOutcome.success) { + return NextResponse.json( + { error: renameOutcome.error ?? 'Failed to rename table' }, + { status: statusForOrchestrationError(renameOutcome.errorCode) } + ) + } } if (validated.folderId !== undefined) { @@ -199,24 +219,28 @@ export const PATCH = withRouteHandler( ) { return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) } - try { - await moveTableToFolder( - tableId, - table.workspaceId, - validated.folderId, - requestId, - authResult.userId + // The move re-asserts workspace and active state, so a miss means the table was + // archived between `checkAccess` and the write. That is a 404, not a server fault. + const moveOutcome = await performMoveTableToFolder({ + table, + folderId: validated.folderId, + userId: authResult.userId, + requestId, + request, + }) + if (!moveOutcome.success) { + return NextResponse.json( + { + error: moveOutcome.errorCode === 'not_found' ? 'Table not found' : moveOutcome.error, + }, + { status: statusForOrchestrationError(moveOutcome.errorCode) } ) - } catch (moveError) { - // The move re-asserts workspace and active state, so a miss means the table was - // archived between `checkAccess` and the write. That is a 404, not a server fault. - if (moveError instanceof Error && moveError.message.endsWith('not found')) { - return NextResponse.json({ error: 'Table not found' }, { status: 404 }) - } - throw moveError } } + // Live-collab: tell open viewers the definition changed so they refetch. + signalTableSchemaChanged(tableId) + // Re-read so the response reflects both a rename and a lock change. const updated = await getTableById(tableId) if (!updated) { @@ -271,14 +295,18 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId, authResult.userId) - - captureServerEvent( - authResult.userId, - 'table_deleted', - { table_id: tableId, workspace_id: table.workspaceId }, - { groups: { workspace: table.workspaceId } } - ) + const outcome = await performDeleteTable({ + table, + userId: authResult.userId, + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete table' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 6cb994bff85..88776a69a20 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -11,16 +10,18 @@ import { } from '@/lib/api/contracts/tables' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { deleteRow, updateRow } from '@/lib/table' +import { updateRow } from '@/lib/table' import { signalTableRowsChanged } from '@/lib/table/events' +import { performDeleteTableRow } from '@/lib/table/orchestration' import { rowWireTranslators } from '@/app/api/table/row-wire' import { accessError, checkAccess, - rootErrorMessage, + orchestrationErrorResponse, rowWriteErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' @@ -145,12 +146,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR table, requestId ) + + // Live-collab: tell open viewers the change landed so they refetch. + signalTableRowsChanged(tableId) // Only `null` when a `cancellationGuard` is supplied and the SQL guard // rejects the write — this route doesn't pass one, so reaching null is a bug. if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard') - // An edit that also triggers a dispatch already emits dispatch/cell events; the - // debounced rows refetch on the peer coalesces the two. - signalTableRowsChanged(tableId) // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). // Firing a second mode: 'incomplete' dispatch here would race with the // `mode: 'new'` one AND bulk-clear sibling-group outputs (the incomplete @@ -177,10 +178,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { - if (rootErrorMessage(error) === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - const response = rowWriteErrorResponse(error) if (response) return response @@ -216,7 +213,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteRow(table, rowId, requestId) + const outcome = await performDeleteTableRow({ table, rowId, requestId }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete row' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + + // Live-collab: tell open viewers the change landed so they refetch. signalTableRowsChanged(tableId) return NextResponse.json({ @@ -230,11 +235,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts new file mode 100644 index 00000000000..93ba5175585 --- /dev/null +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +interface ExportRouteParams { + params: Promise<{ exportId: string }> +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(downloadTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const record = await requireTableExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId + ) + const access = await checkAccess(record.tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, 'table-export') + const result = tableExportResult(record) + return NextResponse.json({ + data: { + url: await generatePresignedDownloadUrl( + result.resultKey, + 'workspace', + DOWNLOAD_TTL_SECONDS + ), + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + }, + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts new file mode 100644 index 00000000000..c7e9f56b405 --- /dev/null +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + cancelTableExportResourceContract, + getTableExportResourceContract, +} from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + cancelTableExportResource, + requireTableExport, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ExportRouteParams { + params: Promise<{ exportId: string }> +} + +async function authorizedExport(exportId: string, workspaceId: string, userId: string) { + const record = await requireTableExport(exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + return { record, access } +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(getTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const { record, access } = await authorizedExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId, + auth.userId + ) + if (!access.ok) return accessError(access, 'table-export') + return NextResponse.json({ data: toV2TableExport(record) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(cancelTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const { record, access } = await authorizedExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId, + auth.userId + ) + if (!access.ok) return accessError(access, 'table-export') + return NextResponse.json({ data: toV2TableExport(await cancelTableExportResource(record)) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 57d879c1f32..04039178db7 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -18,11 +18,11 @@ import { releaseJobClaim, sanitizeName, TABLE_LIMITS, - TableConflictError, } from '@/lib/table' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { orchestrationErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportAsync') @@ -101,12 +101,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestId ) } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - if (error instanceof Error && error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified throw error } diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index b85e1ccb01b..a9722924755 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' -import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -32,6 +31,9 @@ vi.mock('@/lib/table/rows/service', () => ({ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') + const { asOrchestrationError, statusForOrchestrationError } = await import( + '@/lib/core/orchestration/types' + ) return { normalizeColumn: (column: unknown) => column, csvProxyBodyCapResponse: () => null, @@ -40,16 +42,20 @@ vi.mock('@/app/api/table/utils', async () => { { error: error.message }, { status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 } ), - rowWriteErrorResponse: (error: unknown) => { - const message = getErrorMessage(error) - return message.includes('row limit') - ? NextResponse.json({ error: message }, { status: 400 }) + orchestrationErrorResponse: (error: unknown) => { + const classified = asOrchestrationError(error) + return classified + ? NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) : null }, } }) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/table/import-csv/route' type Part = @@ -184,7 +190,10 @@ describe('POST /api/table/import-csv', () => { it('returns 400 with the reason when an insert exceeds the plan row limit', async () => { mockBatchInsertRows.mockRejectedValueOnce( - new Error('This table has reached its row limit (1,000 rows) on your current plan.') + new OrchestrationError( + 'validation', + 'This table has reached its row limit (1,000 rows) on your current plan.' + ) ) const response = await POST(makeRequest(uploadParts(csvWithRows(250)))) const data = await response.json() diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 9ca0381fe90..ef4f1cc7547 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,41 +1,20 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { - batchInsertRows, - CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, - CSV_SCHEMA_SAMPLE_SIZE, - coerceRowsForTable, - createCsvParser, - createTable, - deleteTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - sanitizeName, - TABLE_LIMITS, - type TableDefinition, - type TableSchema, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - csvProxyBodyCapResponse, - multipartErrorResponse, - normalizeColumn, - rowWriteErrorResponse, -} from '@/app/api/table/utils' +import { csvProxyBodyCapResponse, multipartErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') @@ -126,146 +105,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionResult.data === 'tsv' ? '\t' : ',' - ) - let csvHeaders: string[] = [] - const parser = createCsvParser(delimiter, (headers) => { - csvHeaders = headers + const outcome = await performCreateTableFromCsv({ + workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionResult.data === 'tsv' ? '\t' : ',', + folderId, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (err) => parser.destroy(err)) - csvStream.pipe(parser) - - interface ImportState { - table: TableDefinition - schema: TableSchema - headerToColumn: Map - } - - const insertRows = async ( - rows: Record[], - state: ImportState, - currentRowCount: number - ) => { - if (rows.length === 0) return 0 - const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone }) - const result = await batchInsertRows( - { tableId: state.table.id, rows: coerced, workspaceId, userId }, - // The created table's rowCount is frozen at 0; pass the running total so the - // per-batch capacity check sees cumulative rows, not an always-empty table. - { ...state.table, rowCount: currentRowCount }, - generateId().slice(0, 8) - ) - return result.length - } - /** Infer the schema from the buffered sample and create the (empty) table. */ - const buildTable = async (sampleRows: Record[]): Promise => { - const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) - const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } - const planLimits = await getWorkspaceTableLimits(workspaceId) - const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const table = await createTable( - { - name: tableName, - description: `Imported from ${file.filename}`, - schema, - workspaceId, - folderId, - userId, - maxTables: planLimits.maxTables, - }, - requestId + if (!outcome.success) { + return NextResponse.json( + { error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - // Coerce against the *created* schema so rows key by the ids `createTable` - // assigned (the local `schema` is the id-less inferred one). - return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } - } - - let state: ImportState | null = null - let inserted = 0 - const sample: Record[] = [] - let batch: Record[] = [] - - try { - for await (const record of parser as AsyncIterable>) { - if (!state) { - sample.push(record) - if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } - continue - } - batch.push(record) - if (batch.length >= CSV_MAX_BATCH_SIZE) { - inserted += await insertRows(batch, state, inserted) - batch = [] - } - } - - if (!state) { - if (sample.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } else { - inserted += await insertRows(batch, state, inserted) - } - } catch (streamError) { - if (state) await deleteTable(state.table.id, requestId).catch(() => {}) - throw streamError } - logger.info(`[${requestId}] CSV imported`, { - tableId: state.table.id, - fileName: file.filename, - columns: state.schema.columns.length, - rows: inserted, - }) - - return NextResponse.json({ - success: true, - data: { - table: { - id: state.table.id, - name: state.table.name, - description: state.table.description, - schema: state.schema, - rowCount: inserted, - }, - }, - }) + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import failed:`, error) - - // Row-write failures (e.g. the plan row-limit check) map to a 400 with the real reason. - const rowWriteError = rowWriteErrorResponse(error) - if (rowWriteError) return rowWriteError - - const message = toError(error).message - const isClientError = - message.includes('maximum table limit') || - message.includes('CSV file has no') || - message.includes('Invalid table name') || - message.includes('Invalid schema') || - message.includes('already exists') - - return NextResponse.json( - { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } - ) + return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() } diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..54b93e41b19 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findOwnedTableImport, + getOwnedTableImportUpload, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { + completeUploadSession, + validateUploadCompletion, +} from '@/lib/uploads/upload-session/service' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(completeTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], + }) + validateUploadCompletion(upload, parsed.data.body) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: parsed.data.query.workspaceId, + userId: upload.userId, + }) + if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) + const completed = await completeUploadSession({ + session: upload, + completion: parsed.data.body, + finalize: async () => ({ value: null }), + }) + return NextResponse.json({ + data: toV2TableImport(await startUploadedTableImport(completed.session)), + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts new file mode 100644 index 00000000000..130fe570a29 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) + if (!parsed.success) return parsed.response + try { + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts new file mode 100644 index 00000000000..15fb5cd2914 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + cancelTableImportResourceContract, + getTableImportResourceContract, +} from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + abortTableImportUpload, + cancelTableImportResource, + getOwnedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +async function userId(request: NextRequest): Promise { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + return auth.success && auth.userId + ? auth.userId + : NextResponse.json({ error: 'Authentication required' }, { status: 401 }) +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const user = await userId(request) + if (user instanceof NextResponse) return user + const parsed = await parseRequest(getTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + return NextResponse.json({ data: await toV2TableImport(record) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const user = await userId(request) + if (user instanceof NextResponse) return user + const parsed = await parseRequest(cancelTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const uploadToken = parsed.data.headers['upload-token'] + const record = uploadToken + ? await abortTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + uploadToken, + }) + : await cancelTableImportResource( + await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + ) + return NextResponse.json({ + data: toV2TableImport(record), + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts new file mode 100644 index 00000000000..d7e42288f09 --- /dev/null +++ b/apps/sim/app/api/table/imports/route.ts @@ -0,0 +1,31 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableImportResource, + toV2CreateTableImport, +} from '@/lib/table/orchestration/import-resource' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableImportResourceContract, request, {}) + if (!parsed.success) return parsed.response + try { + const created = await createTableImportResource( + parsed.data.body, + auth.userId, + request.nextUrl.origin + ) + return NextResponse.json({ data: toV2CreateTableImport(created) }, { status: 201 }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 2522cddb7c6..28714885cb5 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -16,7 +16,7 @@ import { type TableScope, } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { normalizeColumn } from '@/app/api/table/utils' +import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableAPI') @@ -153,18 +153,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts index 99d0ce0c5a5..fa7b57ac6dd 100644 --- a/apps/sim/app/api/table/utils.test.ts +++ b/apps/sim/app/api/table/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableRowLimitError } from '@/lib/table/billing' import type { ColumnDefinition } from '@/lib/table/types' import { rootErrorMessage, rowWriteErrorResponse, tableFilterError } from '@/app/api/table/utils' @@ -38,15 +39,30 @@ describe('rowWriteErrorResponse', () => { ) }) - it('passes known validation messages through as 400', async () => { - const response = rowWriteErrorResponse(new Error('Value for column "email" must be unique')) + it('passes a classified validation failure through as 400', async () => { + const response = rowWriteErrorResponse( + new OrchestrationError('validation', 'Value for column "email" must be unique') + ) expect(response?.status).toBe(400) const body = await response?.json() expect(body.error).toBe('Value for column "email" must be unique') }) - it('matches per-row batch validation messages', () => { - expect(rowWriteErrorResponse(new Error('Row 3: name is required'))?.status).toBe(400) + it('answers the code the failure carries, not one derived from its wording', () => { + expect( + rowWriteErrorResponse(new OrchestrationError('not_found', 'Row not found'))?.status + ).toBe(404) + // The phrase that used to force a 400 no longer decides anything. + expect( + rowWriteErrorResponse(new OrchestrationError('conflict', 'Row 3: must be unique'))?.status + ).toBe(409) + }) + + it('unwraps a classified failure drizzle wrapped in a query error', () => { + expect( + rowWriteErrorResponse(wrapLikeDrizzle(new OrchestrationError('validation', 'Row 3: bad'))) + ?.status + ).toBe(400) }) it('returns null for unknown errors so callers keep their generic 500', () => { diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index ceb399556c4..805d2b16206 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -8,6 +8,7 @@ import { updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import type { MultipartError } from '@/lib/core/utils/multipart' import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table' import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' @@ -42,8 +43,9 @@ export async function tablesV2GateError( * Maps a {@link TableLockedError} thrown by the service layer to a 423 response * carrying `{ error, lock }`; returns `null` for any other error so the caller * falls through to its existing handling. Call this as the FIRST statement of a - * table route's catch block — otherwise `rowWriteErrorResponse` (and the other - * substring funnels) turn the lock error into a generic 500. + * table route's catch block — `TableLockedError` is an `HttpError`, not an + * `OrchestrationError`, so nothing else classifies it and it would otherwise + * reach the route's generic 500. * * The body deliberately omits a `details` array: the client's `isValidationError` * treats any `ApiClientError` with array-valued `details` as a field-validation @@ -106,48 +108,36 @@ export function rootErrorMessage(error: unknown): string { } /** - * Known user-facing row-write failures (service validation + the best-effort - * plan row-limit check). Anything outside this list stays a generic 500 — - * unknown errors can carry SQL/internals that don't belong in a toast. - */ -const ROW_WRITE_ERROR_PATTERNS = [ - 'row limit', - 'Insufficient capacity', - 'Schema validation', - 'must be unique', - 'must be valid', - 'must be string', - 'must be number', - 'must be boolean', - 'unique column', - 'Unique constraint violation', - 'Row size exceeds', - 'conflictTarget', - 'Upsert requires', - 'Rows not found', - 'Filter is required', -] as const - -/** - * Maps a known user-facing row-write failure to a 400 carrying the real message - * (so client toasts can show the actual reason); `null` when the error is - * unrecognized and the caller should log it and return its generic 500. + * Maps a classified domain failure to its status, carrying the real message so + * client toasts can show the actual reason; `null` when the error carries no + * classification and the caller should log it and return its own generic 500 — + * an unrecognized error can hold SQL/internals that don't belong in a toast. + * + * This is the whole classification story for the UI and v1 table routes. It + * replaced per-route lists of message substrings, which decided a status by + * searching prose and so silently changed one whenever a message was reworded. */ -export function rowWriteErrorResponse(error: unknown): NextResponse | null { - // A lock violation is a 423, not a 400/500 — check before the pattern match, - // which would otherwise let it fall through to the caller's generic 500. +export function orchestrationErrorResponse(error: unknown): NextResponse | null { + // A lock violation is a 423, and `TableLockedError` is an `HttpError` rather + // than an `OrchestrationError`, so it needs its own check first. const lockResponse = tableLockErrorResponse(error) if (lockResponse) return lockResponse - const message = rootErrorMessage(error) - - if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { - return NextResponse.json({ error: message }, { status: 400 }) - } + const classified = asOrchestrationError(error) + if (!classified) return null - return null + return NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) } +/** + * {@link orchestrationErrorResponse} under the name the row-write routes call + * it by. Row writes have no classification rules of their own any more. + */ +export const rowWriteErrorResponse = orchestrationErrorResponse + /** * Next.js buffers the request body for the proxy and silently truncates it past this * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts index 5f35c91da86..0795475b549 100644 --- a/apps/sim/app/api/tools/deployments/deploy/route.ts +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -3,10 +3,10 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/ import { type NextRequest, NextResponse } from 'next/server' import { deploymentsDeployContract } from '@/lib/api/contracts/tools/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performFullDeploy } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts index a126c3dbd57..523a5630a32 100644 --- a/apps/sim/app/api/tools/deployments/promote/route.ts +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -3,10 +3,10 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/ import { type NextRequest, NextResponse } from 'next/server' import { deploymentsPromoteContract } from '@/lib/api/contracts/tools/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, diff --git a/apps/sim/app/api/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts index 8f2b18d024e..b5ef4610fb8 100644 --- a/apps/sim/app/api/users/me/usage-limits/route.ts +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits' -import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage' -import { RateLimiter } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse } from '@/app/api/workflows/utils' @@ -23,22 +22,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const authenticatedUserId = auth.userId const userSubscription = await getHighestPrioritySubscription(authenticatedUserId) - const rateLimiter = new RateLimiter() - const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual' - const [syncStatus, asyncStatus] = await Promise.all([ - rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - false - ), - rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - true - ), - ]) const [usageCheck, storageUsage, storageLimit] = await Promise.all([ checkServerSideUsageLimits(authenticatedUserId), @@ -52,23 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, - rateLimit: { - sync: { - isLimited: syncStatus.remaining === 0, - requestsPerMinute: syncStatus.requestsPerMinute, - maxBurst: syncStatus.maxBurst, - remaining: syncStatus.remaining, - resetAt: syncStatus.resetAt, - }, - async: { - isLimited: asyncStatus.remaining === 0, - requestsPerMinute: asyncStatus.requestsPerMinute, - maxBurst: asyncStatus.maxBurst, - remaining: asyncStatus.remaining, - resetAt: asyncStatus.resetAt, - }, - authType: triggerType, - }, usage: { currentPeriodCost, limit: usageCheck.limit, diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts index a45c8a0ff92..32295c7f887 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -57,7 +57,7 @@ describe('GET /api/users/me/usage-logs', () => { source: 'workflow', workflowName: null, creditCost: 100, - dollarCost: 0.5, + hasCost: true, }, ]) expect(body.summary).toEqual({ diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index d976d188bc8..9abd381c48c 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -17,6 +17,7 @@ const logger = createLogger('UsageLogsAPI') /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. + * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -51,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { source: log.source, workflowName: log.workflowName ?? null, creditCost: creditsByLogId[log.id] ?? 0, - dollarCost: log.cost, + hasCost: log.cost > 0, })) const bySourceCredits = Object.fromEntries( diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 9610232d357..f3dbc231e69 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -31,21 +31,13 @@ import { internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminAuditLog, - createPaginationMeta, - parsePaginationParams, - toAdminAuditLog, -} from '@/app/api/v1/admin/types' +import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const parsed = await parseRequest( v1AdminListAuditLogsContract, request, @@ -56,6 +48,7 @@ export const GET = withRouteHandler( try { const query = parsed.data.query + const { limit, offset } = query const conditions = buildFilterConditions({ action: query.action, resourceType: query.resourceType, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 69b773accf5..18bc485fbe6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -152,7 +153,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 68b79e3a78a..83234df0a7b 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -45,6 +45,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -144,7 +145,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 5c9525ca7ff..e6c84765379 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -101,7 +101,10 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to requeue outbox event' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index f88ac55536c..57ce53c49f5 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -77,7 +77,10 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to list outbox events' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index b7f7c162118..1432b46d37b 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -181,7 +182,7 @@ export const POST = withRouteHandler( {}, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 566a17a5e27..60c3d61fc5c 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -26,10 +26,19 @@ type AuthResult = | { success: false; response: NextResponse } /** - * Validates enterprise audit log access for the given user. + * Structured enterprise audit-access result shared by the v1 and v2 surfaces so + * each version can render the failure in its own response envelope. + */ +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: number; message: string } + +/** + * Core enterprise audit-access check (no response rendering). * * Checks: - * 1. User belongs to an organization + * 1. User belongs to an organization (the target one when + * `targetOrganizationId` is given) * 2. User has admin or owner role * 3. The organization is entitled to audit logs — an active enterprise * subscription when billing runs, otherwise the deployment's audit-logs @@ -39,13 +48,12 @@ type AuthResult = * there made audit logs unreachable on every self-hosted deployment, since no * subscription row is ever written without billing. * - * Returns the organization ID and all member user IDs on success, - * or an error response on failure. + * Returns the organization ID and all member user IDs on success. */ -export async function validateEnterpriseAuditAccess( +export async function resolveEnterpriseAuditAccess( userId: string, targetOrganizationId?: string -): Promise { +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -57,43 +65,24 @@ export async function validateEnterpriseAuditAccess( .limit(1) if (!membership) { - return { - success: false, - response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }), - } + return { success: false, status: 403, message: 'Not a member of any organization' } } if (membership.role !== 'admin' && membership.role !== 'owner') { - return { - success: false, - response: NextResponse.json( - { error: 'Organization admin or owner role required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Organization admin or owner role required' } } if (isBillingEnabled) { const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) if (billingBlocked) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } } else if (!isAuditLogsEnabled) { return { success: false, - response: NextResponse.json( - { - error: - 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', - }, - { status: 403 } - ), + status: 403, + message: + 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', } } @@ -118,13 +107,7 @@ export async function validateEnterpriseAuditAccess( ]) if (isBillingEnabled && orgSub.length === 0) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } const orgMemberIds = orgMembers.map((m) => m.userId) @@ -137,9 +120,22 @@ export async function validateEnterpriseAuditAccess( return { success: true, - context: { - organizationId: membership.organizationId, - orgMemberIds, - }, + context: { organizationId: membership.organizationId, orgMemberIds }, + } +} + +/** + * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` + * response body. + */ +export async function validateEnterpriseAuditAccess( + userId: string, + targetOrganizationId?: string +): Promise { + const result = await resolveEnterpriseAuditAccess(userId, targetOrganizationId) + if (result.success) return { success: true, context: result.context } + return { + success: false, + response: NextResponse.json({ error: result.message }, { status: result.status }), } } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 94c4832f265..33ac4611ffe 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' @@ -8,8 +7,12 @@ import { v1GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteDocument } from '@/lib/knowledge/documents/service' +import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' @@ -152,19 +155,24 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Document not found' }, { status: 404 }) } - await deleteDocument(documentId, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: docs[0].filename, - description: `Deleted document "${docs[0].filename}" from knowledge base via API`, - metadata: { knowledgeBaseId }, + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + document: { id: documentId, filename: docs[0].filename }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index 18898d704af..5cba10e6338 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -75,6 +75,9 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, + // Read at module scope by `lib/uploads/utils/file-utils`, which the route now + // reaches transitively through the knowledge orchestration module. + SUPPORTED_ARCHIVE_EXTENSIONS: [], })) vi.mock('@/lib/knowledge/documents/service', () => ({ diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index dfd08d4c892..8f77bb467c6 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1ListKnowledgeDocumentsContract, @@ -10,19 +9,19 @@ import { resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createSingleDocument, - type DocumentData, - getDocuments, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' +import { getDocuments } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' @@ -189,47 +188,29 @@ export const POST = withRouteHandler( contentType ) - const newDocument = await createSingleDocument( - { + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, + document: { filename: file.name, fileUrl: uploadedFile.url, fileSize: file.size, mimeType: contentType, }, - knowledgeBaseId, - requestId, - billingActorUserId - ) - - const documentData: DocumentData = { - documentId: newDocument.id, - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - } - - processDocumentsWithQueue( - [documentData], - knowledgeBaseId, - {}, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingActorUserId, + userId, + source: 'api', requestId, - billingAttribution - ).catch(() => { - // Processing errors are logged internally - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: newDocument.id, - resourceName: file.name, - description: `Uploaded document "${file.name}" to knowledge base via API`, - metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to upload document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + const newDocument = outcome.document return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 8dbb280559f..373d0951636 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteKnowledgeBaseContract, @@ -6,8 +5,15 @@ import { v1UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import { + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' import { formatKnowledgeBase, handleError, @@ -67,33 +73,26 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const updates: { - name?: string - description?: string - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - } = {} - if (name !== undefined) updates.name = name - if (description !== undefined) updates.description = description - if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig - - const updatedKb = await updateKnowledgeBase(id, updates, requestId) - - recordAudit({ + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: updatedKb.name, - description: `Updated knowledge base "${updatedKb.name}" via API`, - metadata: { updatedFields: Object.keys(updates) }, + userId, + source: 'api', + updates: { name, description, chunkingConfig }, + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, data: { - knowledgeBase: formatKnowledgeBase(updatedKb), + knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase), message: 'Knowledge base updated successfully', }, }) @@ -125,18 +124,23 @@ export const DELETE = withRouteHandler( ) if (result instanceof NextResponse) return result - await deleteKnowledgeBase(id, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: result.kb.name, - description: `Deleted knowledge base "${result.kb.name}" via API`, + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { + id, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 5b608484025..cacb36ed482 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,13 +1,16 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1CreateKnowledgeBaseContract, v1ListKnowledgeBasesContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' -import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, @@ -76,35 +79,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError - const kb = await createKnowledgeBase( - { - name, - description, - workspaceId, - userId, - embeddingModel: getConfiguredEmbeddingModel(), - embeddingDimension: EMBEDDING_DIMENSIONS, - chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, - }, - requestId - ) - - recordAudit({ + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_CREATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: kb.id, - resourceName: kb.name, - description: `Created knowledge base "${kb.name}" via API`, - metadata: { chunkingConfig }, + name, + description, + chunkingConfig, + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, data: { - knowledgeBase: formatKnowledgeBase(kb), + knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase), message: 'Knowledge base created successfully', }, }) diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 70b89ae5824..ab540813893 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -1,5 +1,5 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' export interface LogFilters { workspaceId: string @@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL { return conditions.length > 0 ? and(...conditions)! : sql`true` } +/** + * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple + * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that + * share a `startedAt` have an arbitrary order and can be skipped or duplicated + * across pages. + */ export function getOrderBy(order: 'desc' | 'asc' = 'desc') { return order === 'desc' - ? desc(workflowExecutionLogs.startedAt) - : sql`${workflowExecutionLogs.startedAt} ASC` + ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] + : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 1da2529a84f..e6da3cb0e2a 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -118,7 +118,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = await baseQuery .where(conditions) - .orderBy(orderBy) + .orderBy(...orderBy) .limit(params.limit + 1) const hasMore = logs.length > params.limit diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index bdc82a6613a..4182a4ea4a1 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -30,6 +30,8 @@ export type ApiEndpoint = | 'workflow-detail' | 'workflow-deploy' | 'workflow-rollback' + | 'workflow-versions' + | 'workflow-version-detail' | 'workflow-export' | 'workflow-import' | 'audit-logs' @@ -37,15 +39,34 @@ export type ApiEndpoint = | 'table-detail' | 'table-rows' | 'table-row-detail' + | 'table-rows-find' | 'table-columns' + | 'table-views' + | 'table-view-detail' + | 'table-groups' + | 'table-enrichment' + | 'table-import' + | 'table-export' + | 'table-jobs' | 'files' | 'file-detail' + | 'file-share' + | 'file-content' + | 'file-move' + | 'file-bulk-delete' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' | 'copilot-chat' - | 'v2-tables' - | 'v2-table-rows' + | 'billing-usage' + | 'mcp-servers' + | 'mcp-server-detail' + | 'skills' + | 'skill-detail' + | 'custom-tools' + | 'custom-tool-detail' + | 'credentials' + | 'credential-detail' export interface RateLimitResult { allowed: boolean @@ -192,42 +213,83 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { } /** - * Verify that the API key is allowed to access the requested workspace. - * - * Enforces two policies: + * Structured workspace-access failure shared by the v1 and v2 API surfaces so + * each version can render the failure in its own response envelope. + */ +export interface WorkspaceAccessError { + status: number + code: 'FORBIDDEN' + message: string +} + +/** + * Core workspace-scope check (no response rendering). Enforces two policies: * - A workspace-scoped key may only target its own workspace. * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`), matching the workflow-execution * surface in `app/api/workflows/middleware.ts`. */ -export async function checkWorkspaceScope( +export async function resolveWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): Promise { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && rateLimit.workspaceId !== requestedWorkspaceId ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + } } if (rateLimit.keyType === 'personal') { const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) if (!settings?.allowPersonalApiKeys) { - return NextResponse.json( - { error: 'Personal API keys are not allowed for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + } } } return null } +/** + * Core workspace-access check (scope + the user's workspace permission level), + * shared by v1 and v2. Returns a structured failure or null on success. + */ +export async function resolveWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return scopeError + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permissionSatisfies(permission, level)) { + return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + } + return null +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + */ +export async function checkWorkspaceScope( + rateLimit: RateLimitResult, + requestedWorkspaceId: string +): Promise { + const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} + /** * Resolves the usage actor for a workspace-scoped v1 request. Personal keys * identify their human owner; shared workspace keys use the billed account as @@ -244,7 +306,7 @@ export async function resolveWorkspaceRequestActor( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. * Returns null on success, NextResponse on failure. */ export async function validateWorkspaceAccess( @@ -253,14 +315,8 @@ export async function validateWorkspaceAccess( workspaceId: string, level: PermissionType = 'read' ): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permissionSatisfies(permission, level)) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - return null + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null } /** diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index b5471842a6c..988c24825c9 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -7,25 +7,17 @@ import { v1UpdateTableColumnContract, } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, -} from '@/lib/table' -import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { addTableColumn, deleteColumn } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, normalizeColumn, + orchestrationErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' import { @@ -103,22 +95,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - // Same caller-error set the internal columns route maps — an invalid - // select option set is a bad request, not a server fault. - if ( - error.message.includes('already exists') || - error.message.includes('maximum column') || - error.message.includes('Invalid column') || - error.message.includes('exceeds maximum') || - error.message.includes('option') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error adding column to table:`, error) return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) @@ -156,228 +134,34 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const { updates } = validated - let updatedTable = null - - // A payload that repeats the current type must not go through - // `updateColumnType` — it early-returns on an unchanged type and would drop - // any `options` alongside it. Only a real type change routes there; an - // unchanged type with options routes to the options-only update. - const currentColumn = table.schema.columns.find((c) => - columnMatchesRef(c, validated.columnName) - ) - // Address every write below by the stable id, not the name: a rename folded - // into one of them must not break the next one's lookup. - const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName - // The constraints write below is a separate, unconditional step, so it is - // the last one whenever it runs — that is the write the rename rides on. - const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type - if (!currentColumn) { - return NextResponse.json( - { error: `Column "${validated.columnName}" not found` }, - { status: 404 } - ) - } - - // A retype applies and validates the constraints itself, so the separate - // constraint write only runs when the type is unchanged. The rename rides - // whichever write actually runs last. - const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined - const constraintsWriteRuns = - !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) - const renameWithTypedWrite = - updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} - - // Every write below is its own locked transaction, so one that is going to - // fail leaves the earlier ones committed. These guards reject the knowable - // cases up front, before any write at all. - // Gate on the type the column ENDS UP with, not on whether the type is - // changing: an options-only update on an existing select column carries the - // same hazard as a conversion does. - const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } - } - // The rename runs last (see below), so a name already taken would fail after - // the typed write committed. This is the only rename failure a caller can - // cause; catching it here leaves just the concurrent-collision race, which - // no pre-flight check can close. - if ( - updates.name && - table.schema.columns.some( - (c) => - c.name.toLowerCase() === updates.name?.toLowerCase() && - !columnMatchesRef(c, validated.columnName) - ) - ) { - return NextResponse.json( - { error: `Column "${updates.name}" already exists` }, - { status: 400 } - ) - } - if ( - currentColumn?.workflowGroupId && - (updates.required !== undefined || updates.unique !== undefined) - ) { - return NextResponse.json( - { - error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, - }, - { status: 400 } - ) - } - if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { return NextResponse.json( - { error: `Cannot set a ${resultingType} column as unique` }, - { status: 400 } - ) - } - - if (typeChanging) { - updatedTable = await updateColumnType( - { - tableId, - columnName: columnRef, - newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), - // Forwarded so the conversion validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( - { - tableId, - columnName: columnRef, - currencyCode: updates.currencyCode, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { - updatedTable = await updateColumnOptions( - { - tableId, - columnName: columnRef, - options: updates.options ?? currentColumn?.options ?? [], - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - // Forwarded so the removal guard validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } - - // Skipped whenever a typed write ran: that write already applied and - // validated these, in one transaction with the change they accompany. - if (constraintsWriteRuns) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: columnRef, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...(updates.name ? { newName: updates.name } : {}), - }, - requestId + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - // A rename rides along with the LAST write above, inside that write's - // transaction — a rename is metadata-only (rows key on the stable column - // id), so nothing forces it to be its own write, and folding it in is what - // stops a combined request from committing one half and then failing. Only - // a rename with nothing to ride on runs standalone. - if (updates.name && !updatedTable) { - updatedTable = await renameColumn( - { tableId, oldName: columnRef, newName: updates.name }, - requestId - ) - } - - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + // Live-collab: tell open viewers the change landed so they refetch. signalTableSchemaChanged(tableId) - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${validated.columnName}" in table "${table.name}"`, - metadata: { columnName: validated.columnName, updates }, - request, - }) - return NextResponse.json({ success: true, data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + columns: outcome.table.schema.columns.map(normalizeColumn), }, }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') || - msg.includes('option') || - msg.includes('currency') || - msg.includes('is already type') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } - } - logger.error(`[${requestId}] Error updating column in table:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } @@ -445,14 +229,8 @@ export const DELETE = withRouteHandler( const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting column from table:`, error) return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index c06492d02b7..149bc674651 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -1,11 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteTableContract, v1GetTableContract } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteTable, type TableSchema } from '@/lib/table' +import type { TableSchema } from '@/lib/table' +import { performDeleteTable } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -139,18 +140,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: result.table.name, - description: `Archived table "${result.table.name}"`, - request, - }) + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete table' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index fe2a5a022ab..ed7a68753f2 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,14 +9,21 @@ import { v1UpdateTableRowContract, } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { deleteRow, updateRow } from '@/lib/table' +import { updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' import { signalTableRowsChanged } from '@/lib/table/events' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { performDeleteTableRow } from '@/lib/table/orchestration' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -155,12 +161,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR table, requestId ) + + // Live-collab: tell open viewers the change landed so they refetch. + signalTableRowsChanged(tableId) // No `cancellationGuard` is passed here, so `updateRow` can't return null // from this caller. Defensive narrowing for TypeScript. if (!updatedRow) { return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } - signalTableRowsChanged(tableId) // Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new'). // Firing a second mode: 'incomplete' dispatch here would race with it AND // bulk-clear sibling-group outputs. @@ -190,21 +198,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) - } - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) @@ -240,9 +235,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - // Route through the service (not a raw `db.delete`) so the delete lock is - // enforced — the raw path would return 200 on a locked table. - await deleteRow(result.table, rowId, requestId) + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete row' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + + // Live-collab: tell open viewers the change landed so they refetch. signalTableRowsChanged(tableId) return NextResponse.json({ @@ -255,9 +256,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - if (error instanceof Error && error.message === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index f89bad3096f..6107e99e4e9 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' @@ -10,7 +9,12 @@ import { upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' import { signalTableRowsChanged } from '@/lib/table/events' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -76,6 +80,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser table, requestId ) + + // Live-collab: tell open viewers the change landed so they refetch. signalTableRowsChanged(tableId) return NextResponse.json({ @@ -103,19 +109,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error upserting row:`, error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 82bc6618247..6213fd59053 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' -import { normalizeColumn } from '@/app/api/table/utils' +import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, createRateLimitResponse, @@ -171,18 +171,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 7068239e134..304d69f63d8 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -8,11 +8,11 @@ import { v1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index b445e81a3f1..f7ee32c26cb 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -4,16 +4,10 @@ import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import type { Edge } from 'reactflow' -import { - type V1WorkflowExportPayload, - v1ExportWorkflowContract, -} from '@/lib/api/contracts/v1/workflows' +import { v1ExportWorkflowContract } from '@/lib/api/contracts/v1/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' -import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' +import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -26,70 +20,14 @@ const logger = createLogger('V1WorkflowExportAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] - -/** - * Projects a persisted ReactFlow edge onto the wire shape declared by the - * response contract. Field-by-field rather than a spread because ReactFlow's - * `Edge` is looser than the contract in three places: handles are `null` when - * unset (the contract and the importer both model absent as `undefined`), - * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker` - * object. Non-serializable values in those slots are dropped rather than - * emitted as `{}`. - */ -function toExportedEdge(edge: Edge): ExportedEdge { - return { - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, - type: edge.type, - animated: edge.animated, - style: edge.style as Record | undefined, - data: edge.data, - label: typeof edge.label === 'string' ? edge.label : undefined, - labelStyle: edge.labelStyle as Record | undefined, - labelShowBg: edge.labelShowBg, - labelBgStyle: edge.labelBgStyle as Record | undefined, - labelBgPadding: edge.labelBgPadding, - labelBgBorderRadius: edge.labelBgBorderRadius, - markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined, - markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined, - } -} - /** * GET /api/v1/workflows/[id]/export * * Exports a workflow as a portable JSON envelope that - * `POST /api/v1/workflows/import` accepts verbatim. - * - * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits - * the raw state for backup/restore, this surface runs the payload through - * `sanitizeForExport`, which nulls three classes of sub-block value: - * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}` - * reference, which is preserved so the import resolves it in the target - * workspace; - * - `oauth-input` credentials; - * - **workspace-scoped bindings** — `knowledge-base-selector`, `file-selector`, - * `channel-selector`, `project-selector`, `folder-selector`, - * `mcp-server-selector` and friends, plus fields keyed `knowledgeBaseId`, - * `fileId`, `channelId`, `projectId`, `documentId`, `tagFilters`. These point - * at rows that do not exist in another workspace, so they are cleared rather - * than carried across as dangling ids. - * - * The last class means an export is **not** a byte-for-byte clone even when - * re-imported into the same workspace: those bindings come back empty and must - * be re-selected. This matches the in-app export and is documented on the - * public endpoint so callers do not expect otherwise. - * - * Workflow **variables** are emitted as stored, matching `GET - * /api/v1/workflows/[id]` and the in-app export. Variables are plaintext - * workflow configuration readable by anyone with workspace read (the same - * permission this route requires); secrets belong in environment variables, - * which travel as unresolved `{{ENV_VAR}}` references. Redacting them here - * would break import round-trips without narrowing access. + * `POST /api/v1/workflows/import` accepts verbatim. Payload assembly and the + * sanitization guarantees are documented on the shared + * {@link buildWorkflowExportPayload}; this route authenticates and renders the + * v1 envelope. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -122,45 +60,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const normalizedData = await loadWorkflowFromNormalizedTables(id) - if (!normalizedData) { + const payload = await buildWorkflowExportPayload(workflowData) + if (!payload) { return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) } - const sanitized = sanitizeForExport({ - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: workflowData.name, - description: workflowData.description ?? undefined, - }, - variables: parseWorkflowVariables(workflowData.variables), - }) - - const payload: V1WorkflowExportPayload = { - version: '1.0', - exportedAt: sanitized.exportedAt, - workflow: { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - workspaceId: workflowData.workspaceId, - folderId: workflowData.folderId, - }, - state: { - ...sanitized.state, - edges: sanitized.state.edges.map(toExportedEdge), - metadata: { - ...sanitized.state.metadata, - name: workflowData.name, - description: workflowData.description ?? undefined, - exportedAt: sanitized.exportedAt, - }, - }, - } - recordAudit({ workspaceId: workflowData.workspaceId, actorId: userId, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index a0779babf51..d015ba4b024 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -7,10 +7,10 @@ import { v1RollbackWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index dc602b987db..762be48852c 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -1,32 +1,17 @@ -import { db } from '@sim/db' -import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertFolderInWorkspace, - assertFolderMutable, - FolderLockedError, - FolderNotFoundError, -} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { truncate } from '@sim/utils/string' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { - V1_IMPORT_DESCRIPTION_MAX_LENGTH, - V1_IMPORT_NAME_MAX_LENGTH, type V1ImportWorkflowData, v1ImportWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { workflowStateSchema } from '@/lib/api/contracts/workflows' -import { parseRequest, serializeZodIssues } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { performCreateWorkflow } from '@/lib/workflows/orchestration' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' +import { + importWorkflowIntoWorkspace, + MAX_IMPORT_BODY_BYTES, +} from '@/lib/workflows/operations/import-workflow' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -34,115 +19,19 @@ import { v1ValidationErrorResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' -import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('V1WorkflowImportAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Workflow JSON is a bounded document — a few hundred blocks at the outside. - * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) - * keeps a hostile caller from buffering a large body before validation runs. - */ -const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024 - -const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow' - -const TRUNCATION_SUFFIX = '...' - -/** - * Caps a payload-derived string at `maxLength` *including* the ellipsis. - * `truncate` appends its suffix after slicing, so passing the limit straight - * through would yield `maxLength + 3` characters and overshoot the very bound - * this is enforcing. - */ -function capLength(value: string, maxLength: number): string { - return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX) -} - -/** - * Reads a dot-delimited path off a parsed payload and returns it only when it - * is a non-empty string, so blank metadata falls through to the next candidate. - */ -function readString(source: unknown, path: string): string | undefined { - let current: unknown = source - for (const segment of path.split('.')) { - if (!current || typeof current !== 'object') return undefined - current = (current as Record)[segment] - } - return typeof current === 'string' && current.trim() ? current.trim() : undefined -} - -/** - * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so - * a caller can pipe an export response body straight into import. - * `parseWorkflowJson` already tolerates this shape when reading the graph; - * mirroring it here keeps metadata resolution from silently falling back to the - * default name for the same payload. - */ -function unwrapResponseEnvelope(payload: unknown): unknown { - if (!payload || typeof payload !== 'object') return payload - const inner = (payload as Record).data - if (!inner || typeof inner !== 'object') return payload - const candidate = inner as Record - return candidate.state || candidate.version || candidate.workflow ? candidate : payload -} - -/** - * Resolves the imported workflow's name and description, preferring explicit - * request overrides and then the payload's own metadata. Accepts every shape - * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) - * and a bare state (`metadata.*`). - * - * Candidate order deliberately matches `extractWorkflowName` — the resolver the - * in-app importer has always used — so the same payload yields the same name on - * both surfaces. The in-app version additionally falls back to the uploaded - * filename, which has no analogue here; that is the only intended difference. - * - * Payload-derived values are capped at the same bounds the contract applies to - * the explicit overrides, otherwise the declared `maxLength` would not be the - * effective one — a caller could store an unbounded name simply by embedding it - * in the payload instead of passing it as a field. - */ -function resolveImportedMetadata( - rawPayload: unknown, - overrideName?: string, - overrideDescription?: string -): { name: string; description: string } { - const payload = unwrapResponseEnvelope(rawPayload) - - const name = - overrideName || - capLength( - readString(payload, 'state.metadata.name') || - readString(payload, 'workflow.name') || - readString(payload, 'metadata.name') || - DEFAULT_IMPORTED_WORKFLOW_NAME, - V1_IMPORT_NAME_MAX_LENGTH - ) - - const description = - overrideDescription ?? - capLength( - readString(payload, 'state.metadata.description') ?? - readString(payload, 'workflow.description') ?? - readString(payload, 'metadata.description') ?? - '', - V1_IMPORT_DESCRIPTION_MAX_LENGTH - ) - - return { name, description } -} - /** * POST /api/v1/workflows/import * * Creates a new workflow in the target workspace from an export payload - * produced by `GET /api/v1/workflows/{id}/export`. Block, edge, loop and - * parallel ids are regenerated on import, so the same payload can be imported - * repeatedly and alongside its source workflow without collisions. + * produced by `GET /api/v1/workflows/{id}/export`. The shared + * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this + * route authenticates and renders the v1 envelope. */ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -166,12 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { - workspaceId, - folderId, - name: overrideName, - description: overrideDescription, - } = parsed.data.body + const { workspaceId, folderId, name, description } = parsed.data.body logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { userId, @@ -181,191 +65,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - - if (!workspaceData) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - /** - * Ownership before lock state: `assertFolderMutable` walks the folder's - * ancestor chain without filtering on workspace, so checking it first would - * let a caller distinguish a locked folder in someone else's workspace - * (423) from a nonexistent one (404). - */ - if (folderId) { - await assertFolderInWorkspace(folderId, workspaceId) - } - await assertFolderMutable(folderId ?? null) - - const rawWorkflow = parsed.data.body.workflow - const workflowContent = - typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow) - - const { data: parsedState, errors } = parseWorkflowJson(workflowContent) - if (!parsedState || errors.length > 0) { - return NextResponse.json({ error: `Invalid workflow: ${errors.join(', ')}` }, { status: 400 }) - } - - /** - * Variables are normalized before validation, not after: older exports - * carry them as an array, which is a shape `workflowStateSchema` rightly - * rejects but the importer has always accepted. Normalizing first keeps - * that tolerance while still validating what actually gets persisted. - */ - const variables = normalizeImportedVariables(parsedState.variables) - - /** - * `parseWorkflowJson` only checks that blocks/edges are structurally - * present. The normalized tables are read back through - * {@link workflowStateSchema}, and the client parses that response - * strictly — so a block field with the wrong type (`data.extent: 'child'`, - * `data.count: '5'`) would persist happily here and then throw on every - * subsequent load, leaving a workflow nothing can open. Gate on the same - * schema the canonical `PUT /api/workflows/[id]/state` path enforces. - */ - const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables }) - if (!stateValidation.success) { - const issue = stateValidation.error.issues[0] - const path = issue?.path.join('.') - return NextResponse.json( - { - error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`, - details: serializeZodIssues(stateValidation.error), - }, - { status: 400 } - ) - } - - /** - * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via - * the one shared implementation — the two import surfaces must land - * byte-identical data for the same payload. - */ - const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState) - if (warnings.length > 0) { - logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings }) - } - - const workflowState: WorkflowState = { ...parsedState, ...preparedState } - - let parsedPayload: unknown = rawWorkflow - if (typeof rawWorkflow === 'string') { - try { - parsedPayload = JSON.parse(rawWorkflow) - } catch { - parsedPayload = undefined - } - } - - const { name, description } = resolveImportedMetadata( - parsedPayload, - overrideName, - overrideDescription - ) - - const created = await performCreateWorkflow({ - name, - description, + const result = await importWorkflowIntoWorkspace({ workspaceId, folderId, - deduplicate: true, + name, + description, + workflow: parsed.data.body.workflow, userId, requestId, }) - if (!created.success || !created.workflow) { - const status = - created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: created.error }, { status }) - } - - const workflowId = created.workflow.id - - /** - * The graph and the variables are written in one transaction so an import - * can never half-land, and any failure deletes the shell row created above - * — a caller that receives an error must not be left with a partially - * imported workflow in their workspace. - */ - try { - await db.transaction(async (tx) => { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to save workflow state') - } - - if (Object.keys(variables).length > 0) { - await tx - .update(workflow) - .set({ variables, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) - } - }) - } catch (error) { - logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, { - workflowId, - error: getErrorMessage(error, 'Unknown error'), - }) - /** - * The rollback runs under the same conditions that just failed the write, - * so it can fail too. Losing it must not turn into an unlogged orphan: - * the caller still gets a 500, but the id is recorded loudly enough to - * clean up. - */ - try { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - } catch (rollbackError) { - logger.error( - `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, - { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') } - ) - } - return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 }) - } - - /** - * Matches the canonical state-write path: agent blocks may carry inline - * custom-tool definitions that must exist as workspace rows to be - * resolvable at execution. Failures are logged, not fatal — the workflow - * itself imported successfully. - */ - try { - const { saved, errors: toolErrors } = await extractAndPersistCustomTools( - workflowState, - workspaceId, - userId + if (!result.success) { + return NextResponse.json( + { error: result.error, ...(result.details !== undefined && { details: result.details }) }, + { status: result.status } ) - if (saved > 0 || toolErrors.length > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, { - workflowId, - errors: toolErrors, - }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools from import`, { - workflowId, - error: getErrorMessage(error, 'Unknown error'), - }) } - logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { - name: created.workflow.name, - blocksCount: Object.keys(workflowState.blocks).length, - }) - const data: V1ImportWorkflowData = { - id: workflowId, - name: created.workflow.name, - description: created.workflow.description ?? null, - workspaceId, - folderId: created.workflow.folderId ?? null, - createdAt: created.workflow.createdAt.toISOString(), - updatedAt: created.workflow.updatedAt.toISOString(), + id: result.workflow.id, + name: result.workflow.name, + description: result.workflow.description, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId, + createdAt: result.workflow.createdAt.toISOString(), + updatedAt: result.workflow.updatedAt.toISOString(), } const limits = await getUserLimits(userId) @@ -373,9 +97,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers }) } catch (error: unknown) { - if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } const message = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Workflow import error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..65a270342fa --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,80 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogDetailAPI') + +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs/[id] + * + * Returns a single audit log entry scoped to the authenticated user's + * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization + * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted + * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate + * is folded into the lookup so a non-org log reads as 404 (existence is not + * leaked). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const parsed = await parseRequest(v2GetAuditLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { organizationId, orgMemberIds } = authResult.context + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) + + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) + + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + + return v2Data(formatV2AuditLogEntry(log), { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/audit-logs/format.test.ts b/apps/sim/app/api/v2/audit-logs/format.test.ts new file mode 100644 index 00000000000..cece8dd51df --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' + +describe('formatV2AuditLogEntry', () => { + it('removes internal folder identifiers recursively from the public projection', () => { + const formatted = formatV2AuditLogEntry({ + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'user-1', + actorName: 'Teddy', + actorEmail: 'teddy@example.com', + action: 'folder.moved', + resourceType: 'folder', + resourceId: 'internal-folder-id', + resourceName: 'Reports', + description: 'Moved Reports', + metadata: { + folderId: 'internal-folder-id', + targetFolderId: 'internal-target-id', + nested: { tableImportFolderId: 'internal-import-id', path: '/Reports' }, + }, + createdAt: new Date('2024-01-01T00:00:00Z'), + }) + + expect(formatted.resourceId).toBeNull() + expect(formatted.metadata).toEqual({ nested: { path: '/Reports' } }) + }) +}) diff --git a/apps/sim/app/api/v2/audit-logs/format.ts b/apps/sim/app/api/v2/audit-logs/format.ts new file mode 100644 index 00000000000..b1e7fb88b7c --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/format.ts @@ -0,0 +1,57 @@ +import type { auditLog } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import type { InferSelectModel } from 'drizzle-orm' + +type DbAuditLog = Pick< + InferSelectModel, + | 'id' + | 'workspaceId' + | 'actorId' + | 'actorName' + | 'actorEmail' + | 'action' + | 'resourceType' + | 'resourceId' + | 'resourceName' + | 'description' + | 'metadata' + | 'createdAt' +> + +const INTERNAL_FOLDER_ID_KEYS = new Set([ + 'folderId', + 'folderIds', + 'parentId', + 'tableImportFolderId', + 'targetFolderId', +]) + +function sanitizeMetadata(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeMetadata) + if (!isRecordLike(value)) return value + + const sanitized: Record = {} + for (const [key, child] of Object.entries(value)) { + if (INTERNAL_FOLDER_ID_KEYS.has(key)) continue + sanitized[key] = sanitizeMetadata(child) + } + return sanitized +} + +/** Removes database folder identifiers from the public v2 audit projection. */ +export function formatV2AuditLogEntry(log: DbAuditLog) { + return { + id: log.id, + workspaceId: log.workspaceId, + actorId: log.actorId, + actorName: log.actorName, + actorEmail: log.actorEmail, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceType === 'folder' ? null : log.resourceId, + resourceName: log.resourceName, + description: log.description, + metadata: sanitizeMetadata(log.metadata), + createdAt: log.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts new file mode 100644 index 00000000000..32ef339a8f9 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,107 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs + * + * Lists audit logs scoped to the authenticated user's organization. Org-scoped + * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — + * access is gated by enterprise org admin/owner membership. Auth ordering + * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the + * untrusted query is parsed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + v2ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + + if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { + return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') + } + + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: params.includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) + + return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts new file mode 100644 index 00000000000..ed7d0390381 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' + +const { mockCheckRateLimit, mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/usage/logs/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callLogs(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage/logs${query}`)) +} + +describe('GET /api/v2/billing/usage/logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-sonnet', + cost: 0.06, + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([{ key: 'log-1', dollars: 0.06 }]) + ) + }) + + it('returns credit-denominated rows in the cursor envelope, no dollar costs', async () => { + const res = await callLogs() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'copilot', + workflowName: null, + creditCost: 12, + }, + ]) + expect(JSON.stringify(body)).not.toContain('ollarCost') + }) + + it('forwards the cursor when more rows remain', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-42' }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue({}) + const body = await (await callLogs()).json() + expect(body.nextCursor).toBe('log-42') + }) + + it('pins a workspace API key to its own workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callLogs() + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callLogs('?workspaceId=ws-2') + expect(res.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('rejects "custom" period without a startDate', async () => { + const res = await callLogs('?period=custom') + expect(res.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callLogs() + expect(res.status).toBe(429) + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts new file mode 100644 index 00000000000..93621a9606b --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { + getUsageCreditsByLogId, + getUserUsageLogs, + type UsageLogSource, +} from '@/lib/billing/core/usage-log' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingUsageLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/billing/usage/logs — Cursor-paged, credit-denominated ledger of + * the account's usage events. The per-source aggregate lives on + * `GET /api/v2/billing/usage`; this is the row-level detail. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListUsageLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query + + const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + const dateRange = resolveDateRange(period, startDate, endDate) + const filter = { + source: source as UsageLogSource | undefined, + workspaceId: workspaceFilter.workspaceId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + } + + const [result, creditsByLogId] = await Promise.all([ + getUserUsageLogs(userId, { ...filter, limit, cursor, includeSummary: false }), + getUsageCreditsByLogId(userId, filter), + ]) + + const items = result.logs.map((log) => ({ + id: log.id, + createdAt: log.createdAt, + source: log.source, + workflowName: log.workflowName ?? null, + creditCost: creditsByLogId[log.id] ?? 0, + })) + + return v2CursorList( + items, + result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing usage logs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts new file mode 100644 index 00000000000..5e2a63f5adf --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockGetUserUsageLogs, + mockCheckServerSideUsageLimits, + mockGetHighestPrioritySubscription, + mockDeriveBillingContext, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockCheckServerSideUsageLimits: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockDeriveBillingContext: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/lib/billing', () => ({ + checkServerSideUsageLimits: mockCheckServerSideUsageLimits, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mockDeriveBillingContext, + getUserUsageLogs: mockGetUserUsageLogs, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/usage/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callSummary(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage${query}`)) +} + +describe('GET /api/v2/billing/usage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) + mockDeriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-07-01T00:00:00Z'), + end: new Date('2026-08-01T00:00:00Z'), + }, + }) + mockCheckServerSideUsageLimits.mockResolvedValue({ + isExceeded: false, + currentUsage: 2.5, + limit: 100, + }) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 2.5, bySource: { workflow: 1.9, copilot: 0.6 } }, + pagination: { hasMore: false }, + }) + }) + + it('returns the billing-period summary with per-source credits, no dollars', async () => { + const res = await callSummary() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual({ + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + totalCredits: 500, + bySourceCredits: { workflow: 380, copilot: 120 }, + limitCredits: 20000, + plan: 'pro', + }) + expect(JSON.stringify(body)).not.toContain('dollar') + }) + + it('queries the ledger summary over the derived billing period', async () => { + await callSummary() + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + startDate: new Date('2026-07-01T00:00:00Z'), + endDate: new Date('2026-08-01T00:00:00Z'), + includeSummary: true, + }) + ) + }) + + it('pins a workspace API key to its own workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callSummary() + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callSummary('?workspaceId=ws-2') + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callSummary() + expect(res.status).toBe(429) + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts new file mode 100644 index 00000000000..60d826fc5c8 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { type V2UsageSummaryData, v2GetUsageSummaryContract } from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { checkServerSideUsageLimits } from '@/lib/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingUsageAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/billing/usage — Current-billing-period usage summary with the + * per-source credit breakdown, for external monitoring (e.g. alerting on + * Copilot consumption before an overage). Credits only — dollar costs and + * rate-limit internals are not part of this surface. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2GetUsageSummaryContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + const subscription = await getHighestPrioritySubscription(userId) + const { billingPeriod } = deriveBillingContext(userId, subscription) + + const [usageCheck, ledger] = await Promise.all([ + checkServerSideUsageLimits(userId, subscription), + getUserUsageLogs(userId, { + workspaceId: workspaceFilter.workspaceId, + startDate: billingPeriod.start, + endDate: billingPeriod.end, + limit: 1, + includeSummary: true, + }), + ]) + + const bySourceCredits = Object.fromEntries( + Object.entries(ledger.summary.bySource).map(([source, cost]) => [ + source, + dollarsToCredits(cost), + ]) + ) + + const data: V2UsageSummaryData = { + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + totalCredits: dollarsToCredits(ledger.summary.totalCost), + bySourceCredits, + limitCredits: dollarsToCredits(usageCheck.limit), + plan: subscription?.plan || 'free', + } + + return v2Data(data, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error building usage summary`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts new file mode 100644 index 00000000000..3e7eea9ca70 --- /dev/null +++ b/apps/sim/app/api/v2/billing/utils.ts @@ -0,0 +1,30 @@ +import type { NextResponse } from 'next/server' +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Error } from '@/app/api/v2/lib/response' + +type BillingWorkspaceFilter = + | { ok: true; workspaceId: string | undefined } + | { ok: false; response: NextResponse } + +/** + * Resolves the effective `workspaceId` ledger filter for the caller's key. + * Personal keys read the account's full ledger with whatever filter they asked + * for; a workspace-scoped key is pinned to its own workspace — the filter + * defaults to the key's workspace and an explicit mismatch is rejected rather + * than silently ignored. + */ +export function v2BillingWorkspaceFilter( + rateLimit: RateLimitResult, + requestedWorkspaceId: string | undefined +): BillingWorkspaceFilter { + if (rateLimit.keyType !== 'workspace') { + return { ok: true, workspaceId: requestedWorkspaceId } + } + if (requestedWorkspaceId && requestedWorkspaceId !== rateLimit.workspaceId) { + return { + ok: false, + response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), + } + } + return { ok: true, workspaceId: rateLimit.workspaceId } +} diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts new file mode 100644 index 00000000000..b76997daad2 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -0,0 +1,414 @@ +/** + * @vitest-environment node + * + * Public v2 credential detail: workspace scoping of the id, the 404 mask for a + * credential the caller has no membership on, and secret-free reads. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCredential, + mockGetCredentialActorContext, + mockPerformUpdateCredential, + mockPerformDeleteCredential, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), + mockPerformUpdateCredential: vi.fn(), + mockPerformDeleteCredential: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mockGetWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/credentials/orchestration', async () => { + const actual = await import('@/lib/credentials/orchestration/credential-create') + return { + isProviderOutageCode: actual.isProviderOutageCode, + performUpdateCredential: mockPerformUpdateCredential, + performDeleteCredential: mockPerformDeleteCredential, + } +}) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/credentials/[id]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'cred_abc123' }) }) +const url = (query = `workspaceId=${WORKSPACE_ID}`) => + `http://localhost:3000/api/v2/credentials/cred_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/credentials/cred_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks a credential the caller has no membership on as 404', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public shape with no secret material', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.credential).toEqual({ + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + }) +}) + +describe('PATCH /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + mockPerformUpdateCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, bogus: 'x' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('403s when the caller is not a credential admin', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('503s when the provider is unreachable during a secret rotation', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('rejects a displayName rename on an env credential instead of dropping it', async () => { + mockGetWorkspaceCredential.mockResolvedValue( + buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' }) + ) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('envKey') + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('still allows a description change on an env credential', async () => { + mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' })) + const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateCredential).toHaveBeenCalled() + }) + + it('503s on an Atlassian outage too, not just a token-provider one', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'atlassian_unavailable', + errorCode: 'validation', + providerErrorCode: 'atlassian_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('keeps a rejected secret a 400, not a 503', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'invalid_credentials', + errorCode: 'validation', + providerErrorCode: 'invalid_credentials', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(400) + }) + + it('rotates a secret without echoing it back', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(JSON.stringify(body)).not.toContain('brand-new-token') + expect(mockPerformUpdateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'cred_abc123', + userId: 'user-1', + apiToken: 'brand-new-token', + }) + ) + }) +}) + +describe('DELETE /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + mockPerformDeleteCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('deletes the credential and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'cred_abc123', deleted: true } }) + expect(mockPerformDeleteCredential).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'cred_abc123', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts new file mode 100644 index 00000000000..d92c016b284 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -0,0 +1,216 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCredentialContract, + v2GetCredentialContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/credentials/[id] — Fetch a single credential. Secrets are never returned. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const credential = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!credential) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * Workspace access is not credential access: seeing a credential requires a + * membership row (or workspace admin over a shared type). A caller who has + * neither gets 404 rather than 403 so credential existence never leaks to + * someone who cannot use it. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data( + { credential: toV2CredentialRow(credential, actor.isAdmin ? 'admin' : 'member') }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error fetching credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/credentials/[id] — Rename, re-describe, or rotate a credential's secret. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...changes } = parsed.data.body + + /** + * Credential mutations are gated per credential, not per workspace: + * `performUpdateCredential` requires credential admin, and the internal + * surface applies no workspace-level bar at all. Requiring workspace `write` + * here would lock out a credential admin who only holds `read`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // Tenant-scope the id before the orchestration re-derives access from the + // credential's own workspace. + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * An env credential's display name IS its `envKey` — the lib only applies + * `displayName` to `oauth` and `service_account`, so accepting it here would + * either drop the rename silently (when sent alongside `description`) or + * fail with an unrelated environment-editor message (when sent alone). + */ + if ( + changes.displayName !== undefined && + (existing.type === 'env_workspace' || existing.type === 'env_personal') + ) { + return v2Error( + 'BAD_REQUEST', + 'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.' + ) + } + + const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) + + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to update credential', + { providerUnavailable: isProviderOutageCode(result.providerErrorCode) } + ) + } + + const updated = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!updated) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data({ credential: toV2CredentialRow(updated, 'admin') }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/credentials/[id] — Delete a credential and revoke what it backed. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + // Gated per credential by `performDeleteCredential`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * A credential the caller cannot see answers 404, matching GET, so a + * workspace member cannot tell an inaccessible credential from a missing one + * and enumerate ids. A credential they *can* see but cannot administer still + * gets the orchestration's 403 — that distinction is not a leak, since GET + * already shows them the credential. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + const result = await performDeleteCredential({ credentialId: id, userId, request }) + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to delete credential' + ) + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts new file mode 100644 index 00000000000..3833eb52c84 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -0,0 +1,384 @@ +/** + * @vitest-environment node + * + * Public v2 credentials list/create: gate ordering, the write-only treatment of + * secret material, and the exclusion of `oauth` from the creatable types. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckWorkspaceAccess, + mockListVisibleWorkspaceCredentials, + mockPerformCreateCredential, + mockGetCredentialActorContext, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListVisibleWorkspaceCredentials: vi.fn(), + mockPerformCreateCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mockPerformCreateCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/credentials/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildVisible(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'admin' as const, + ...overrides, + } +} + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: WORKSPACE_ID, + type: 'env_workspace', + envKey: 'STRIPE_API_KEY', +} + +describe('GET /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) + mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect(res.status).toBe(404) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(403) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public credential shape with no secret material', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WORKSPACE_ID, userId: 'user-1' }) + ) + }) + + it('passes the type and providerId filters through', async () => { + await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ type: 'oauth', providerId: 'slack' }) + ) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList( + `workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: true, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is missing for an env credential', async () => { + const res = await callCreate({ workspaceId: WORKSPACE_ID, type: 'env_workspace' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is not a valid environment variable name', async () => { + const res = await callCreate({ ...VALID_BODY, envKey: 'not-a-valid-name' }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s on an oauth create, which requires the interactive connect flow', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'slack', + accountId: 'acct_1', + displayName: 'Slack', + }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a provider outage to 503 rather than a bad request', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + providerUnavailable: true, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: false, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false }) + + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential.role).toBe('member') + }) + + it('reports admin for a fresh insert without a second access lookup', async () => { + const res = await callCreate(VALID_BODY) + + expect((await res.json()).data.credential.role).toBe('admin') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + + it('creates the credential and never echoes the submitted secret', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'super-secret-value', + orgId: 'acct_123', + }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential).toMatchObject({ + id: 'cred_abc123', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('super-secret-value') + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + expect(mockPerformCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + type: 'service_account', + clientSecret: 'super-secret-value', + }) + ) + }) + + it('accepts and forwards an optional service-account data center', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoho-desk-service-account', + clientId: 'zoho-client-id', + clientSecret: 'zoho-client-secret', + orgId: '600123456', + dataCenter: 'eu', + }) + + expect(res.status).toBe(201) + expect(mockPerformCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ dataCenter: 'eu' }) + ) + expect(JSON.stringify(await res.json())).not.toContain('dataCenter') + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts new file mode 100644 index 00000000000..2b710b275bf --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + toV2Credential, + toV2CredentialRow, + v2CredentialOrchestrationError, +} from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCredentialsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * Credential visibility is per credential, not per workspace: membership + * rows and shared-type admin access decide what this caller sees, so the + * workspace permission is re-read here for the `canAdmin` bit. + */ + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const credentials = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess, + type, + providerId, + search, + sortBy, + sortOrder, + }) + + // The per-workspace credential set is small and bounded → a single full page. + return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing credentials`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/credentials — Create a workspace credential. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCredentialContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateCredential({ ...parsed.data.body, userId, request }) + + if (!result.success || !result.credential) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to create credential', + { providerUnavailable: result.providerUnavailable } + ) + } + + /** + * A fresh insert makes the creator an admin, but an idempotent match against + * an existing source does not — the orchestration admits a caller who is + * only a *member* of that credential. Resolve the real role rather than + * assuming the create case, or the response would advertise administrative + * actions the caller cannot perform. + */ + const actor = result.created + ? { isAdmin: true } + : await getCredentialActorContext(result.credential.id, userId) + const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member') + + /** + * Always 201, including when an existing credential already occupied this + * source. Create is idempotent on the source tuple, and the caller's + * post-condition — "a credential with this source exists, here it is" — is + * the same either way. + */ + return v2Data({ credential }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts new file mode 100644 index 00000000000..b70903da663 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -0,0 +1,75 @@ +import type { NextResponse } from 'next/server' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 credentials surface. + * + * Both projections are written field by field on purpose: a credential row + * carries `encryptedServiceAccountKey`, and spreading the row would put it one + * forgotten `omit` away from the wire. + */ + +export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Projection for a raw credential row, whose caller-role is resolved separately. */ +export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role']): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: Boolean(row.encryptedServiceAccountKey), + role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** + * Renders a credential orchestration failure in the v2 error envelope. + * + * `forbidden` from the orchestration means "not an admin of this credential", + * which is a resource-level denial rather than a workspace one; it stays a 403 + * because the caller already proved workspace access to reach it. + */ +export function v2CredentialOrchestrationError( + errorCode: CredentialOrchestrationErrorCode | undefined, + message: string, + options: { providerUnavailable?: boolean } = {} +): NextResponse { + if (options.providerUnavailable) { + return v2Error('SERVICE_UNAVAILABLE', 'The credential provider is unavailable. Try again.') + } + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Credential not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts new file mode 100644 index 00000000000..5619a64b1cd --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -0,0 +1,308 @@ +/** + * @vitest-environment node + * + * Public v2 custom tool detail: the per-id get/update/delete the internal + * surface never had, and the rename guard that keeps a duplicate title from + * reaching the unique index. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCustomTool, + mockGetWorkspaceCustomToolByTitle, + mockDeleteWorkspaceCustomTool, + mockUpdateWorkspaceCustomTool, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCustomTool: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockDeleteWorkspaceCustomTool: vi.fn(), + mockUpdateWorkspaceCustomTool: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + getWorkspaceCustomTool: mockGetWorkspaceCustomTool, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool, + updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + parameters: { type: 'object', properties: { orderId: { type: 'string' } } }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'tool_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/custom-tools/tool_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/custom-tools/tool_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public tool shape without internal scoping columns', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.customTool).toEqual({ + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockGetWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) + +describe('PATCH /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + + expect(res.status).toBe(404) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(403) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(404) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('409s when renaming onto an existing title', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool({ id: 'tool_other' })) + + const res = await callPatch({ workspaceId: 'workspace-1', title: 'taken' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('merges the partial body against the stored tool', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(200) + expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return 2', + }) + }) + + it('404s rather than orphaning a tool deleted between the read and the write', async () => { + mockUpdateWorkspaceCustomTool.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) + +describe('DELETE /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockDeleteWorkspaceCustomTool.mockResolvedValue(true) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('deletes the tool and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'tool_abc123', deleted: true } }) + expect(mockDeleteWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts new file mode 100644 index 00000000000..e793c31c3ea --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -0,0 +1,197 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCustomToolContract, + v2GetCustomToolContract, + v2UpdateCustomToolContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + deleteWorkspaceCustomTool, + getWorkspaceCustomTool, + getWorkspaceCustomToolByTitle, + updateWorkspaceCustomTool, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') + + /** + * `upsertCustomTools` replaces title/schema/code wholesale and checks for a + * duplicate title only when inserting, so a rename onto an existing title + * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge + * the partial body against the stored row and check the rename here. + */ + if (title !== undefined && title !== current.title) { + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } + } + + const updated = await updateWorkspaceCustomTool({ + workspaceId, + toolId: id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, + }) + if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: updated.id, + resourceName: updated.title, + description: `Updated custom tool "${updated.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + logger.error(`[${requestId}] Error updating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + const deleted = await deleteWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!deleted) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: id, + resourceName: tool.title, + description: `Deleted custom tool "${tool.title}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts new file mode 100644 index 00000000000..7ca46e81b0c --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -0,0 +1,302 @@ +/** + * @vitest-environment node + * + * Public v2 custom tools list/create: gate ordering, contract validation, and + * the workspace-scoped single-resource create that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceCustomTools, + mockGetWorkspaceCustomToolByTitle, + mockUpsertCustomTools, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceCustomTools: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockUpsertCustomTools: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + listWorkspaceCustomTools: mockListWorkspaceCustomTools, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + upsertCustomTools: mockUpsertCustomTools, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/custom-tools/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + description: 'Look up an order by id', + parameters: { + type: 'object', + properties: { orderId: { type: 'string' } }, + required: ['orderId'], + }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/custom-tools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', +} + +describe('GET /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public tool shape in the cursor envelope, workspace-scoped', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=title&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpsertCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the schema is not an OpenAI function declaration', async () => { + const res = await callCreate({ ...VALID_BODY, schema: { type: 'nonsense' } }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callCreate({ ...VALID_BODY, bogus: true }) + expect(res.status).toBe(400) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate title instead of hitting the unique index', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool()) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create loses the title race inside the lib', async () => { + mockUpsertCustomTools.mockRejectedValue( + new Error('A tool with the title "v2_smoke_tool" already exists in this workspace') + ) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('409s when the unique index rejects the loser of a title race', async () => { + const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + mockUpsertCustomTools.mockRejectedValue(pgError) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the tool and returns 201 with the single tool', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.customTool).toMatchObject({ id: 'tool_abc123', title: 'lookup_order' }) + expect(mockUpsertCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + tools: [{ title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }' }], + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts new file mode 100644 index 00000000000..96b678a5078 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -0,0 +1,136 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCustomToolContract, + v2ListCustomToolsContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getWorkspaceCustomToolByTitle, + listWorkspaceCustomTools, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/custom-tools — List custom tools in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCustomToolsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceCustomTools({ workspaceId, search, sortBy, sortOrder }) + + // The per-workspace tool set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing custom tools`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/custom-tools — Create a custom tool. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCustomToolContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Titles are unique per workspace and tools resolve by title at call time, + * so a collision is reported rather than surfacing as a unique-index 500. + */ + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`) + } + + const tools = await upsertCustomTools({ + tools: [{ title, schema, code }], + workspaceId, + userId, + requestId, + }) + const created = tools.find((tool) => tool.title === title) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created.id, + resourceName: created.title, + description: `Created custom tool "${created.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + logger.error(`[${requestId}] Error creating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts new file mode 100644 index 00000000000..516101065ad --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -0,0 +1,46 @@ +import type { customTools } from '@sim/db/schema' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import type { NextResponse } from 'next/server' +import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools' +import { v2Error } from '@/app/api/v2/lib/response' + +/** Shared serialization + error mapping for the v2 custom tool surface. */ + +/** + * Classifies a title collision as a conflict so it surfaces as 409 rather than a + * generic 500. Two distinct failures reach here and both must be covered: + * + * - `upsertCustomTools` throws its own message when its in-transaction duplicate + * `SELECT` finds one. + * - Under a concurrent create or rename, both callers pass that `SELECT` too, and + * the loser is rejected by `custom_tools_workspace_title_unique` as a raw + * Postgres `23505` — whose message matches nothing, which is exactly the race + * the message check alone cannot see. + */ +export function v2CustomToolWriteError(error: unknown): NextResponse | null { + if (getPostgresErrorCode(error) === '23505') { + return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace') + } + const message = getErrorMessage(error, '') + if (/already exists in this workspace/i.test(message)) { + return v2Error('CONFLICT', message) + } + return null +} + +type CustomToolRow = typeof customTools.$inferSelect + +/** + * Public custom tool projection. `workspaceId` and `userId` are internal + * scoping columns and are not exposed. + */ +export function toV2CustomTool(row: CustomToolRow): V2CustomTool { + return { + id: row.id, + title: row.title, + schema: row.schema as V2CustomTool['schema'], + code: row.code, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts new file mode 100644 index 00000000000..3ce03cf3cdd --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdateContent } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformUpdateContent: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performUpdateWorkspaceFileContent: mockPerformUpdateContent, +})) + +import { PUT } from '@/app/api/v2/files/[fileId]/content/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const RECORD = { + id: FILE_ID, + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 8, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-03T00:00:00Z'), +} + +const callPut = (body: unknown, contentLength?: number) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(contentLength === undefined ? {} : { 'Content-Length': String(contentLength) }), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + +describe('PUT /api/v2/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('400s when content is missing', async () => { + const res = await callPut({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('400s on an encoding outside the enum', async () => { + const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('400s malformed base64 in the v2 error envelope', async () => { + const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.code).toBe('BAD_REQUEST') + expect(body.error.message).toBe('content must be valid base64') + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte replacement', async () => { + const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalledWith( + expect.objectContaining({ content: '', encoding: 'base64' }) + ) + }) + + it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { + const res = await callPut( + { workspaceId: WS, content: 'TQ==', encoding: 'base64' }, + 60 * 1024 * 1024 + ) + + expect(res.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalled() + }) + + it('returns an oversized JSON body in the canonical v2 413 envelope', async () => { + const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1) + + expect(res.status).toBe(413) + await expect(res.json()).resolves.toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('replaces the content and returns the updated file', async () => { + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + }) + expect(mockPerformUpdateContent).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + content: 'id,name\n', + encoding: 'utf-8', + request: expect.anything(), + }) + }) + + it('forwards base64 encoding through to the orchestration', async () => { + await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' }) + expect(mockPerformUpdateContent).toHaveBeenCalledWith( + expect.objectContaining({ encoding: 'base64' }) + ) + }) + + it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => { + mockPerformUpdateContent.mockResolvedValue({ + success: false, + error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB', + errorCode: 'payload_too_large', + }) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const body = await res.json() + + expect(res.status).toBe(413) + expect(body.error.code).toBe('PAYLOAD_TOO_LARGE') + expect(body.error.message).toContain('Storage limit exceeded') + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformUpdateContent.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..bb03695aa5f --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performUpdateWorkspaceFileContent, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileContentAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. + * + * A full replace, not an append: `content` becomes the entire body of the file. + * `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at + * 50 MB and still debits the workspace storage quota, so a write that would push + * the payer past its limit fails with 413. + */ +export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-content') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFileContentContract, request, context, { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : parsed.response + } + + const { fileId } = parsed.data.params + const { workspaceId, content, encoding } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateWorkspaceFileContent({ + workspaceId, + fileId, + userId, + content, + encoding, + request, + }) + + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to update file content') + ) + } + + return v2Data(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts new file mode 100644 index 00000000000..309bb0b34c1 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetWorkspaceFile } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceFile: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, +})) + +import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } + +function buildRecord() { + return { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 1024, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + } +} + +const callGet = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx) + +describe('GET /api/v2/files/[fileId]/metadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + }) + + it('400s when workspaceId is missing', async () => { + const response = await callGet('') + + expect(response.status).toBe(400) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(403) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('404s when the workspace-scoped file does not exist', async () => { + mockGetWorkspaceFile.mockResolvedValue(null) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public metadata projection without loading content', async () => { + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, { + throwOnError: true, + }) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts new file mode 100644 index 00000000000..69d1b8ce243 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2GetFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileMetadataAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileMetadataRouteParams { + params: Promise<{ fileId: string }> +} + +/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: FileMetadataRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) return v2Error('NOT_FOUND', 'File not found') + + return v2Data(toV2File(file), { rateLimit }) + } catch (error) { + logger.error('Error fetching file metadata', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..eb0f2b3738a --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -0,0 +1,332 @@ +/** + * @vitest-environment node + * + * Public v2 file detail: download, rename, archive. Covers the orchestration + * error mapping that replaced the route-local status switch. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceFile, + mockFetchWorkspaceFileBuffer, + mockPerformRename, + mockPerformDelete, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), + mockPerformRename: vi.fn(), + mockPerformDelete: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performRenameWorkspaceFile: mockPerformRename, + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildRecord(overrides: Record = {}) { + return { + id: FILE_ID, + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 1024, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } + +const callDownload = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) + +const callRename = (body: unknown) => + PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx + ) + +const callDelete = (query: string) => + DELETE(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) + +describe('GET /api/v2/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDownload(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDownload('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callDownload(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDownload(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('streams the bytes with rate-limit headers', async () => { + const res = await callDownload(`workspaceId=${WS}`) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/csv') + expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(await res.text()).toBe('id,name\n') + }) +}) + +describe('PATCH /api/v2/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRename.mockResolvedValue({ + success: true, + file: buildRecord({ name: 'renamed.csv' }), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + + expect(res.status).toBe(404) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('400s on a name containing a path separator', async () => { + const res = await callRename({ workspaceId: WS, name: 'nested/renamed.csv' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('400s on an unknown body field', async () => { + const res = await callRename({ workspaceId: WS, name: 'renamed.csv', folderId: 'fold_1' }) + expect(res.status).toBe(400) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + expect(res.status).toBe(403) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('renames and returns the public file shape', async () => { + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + id: FILE_ID, + name: 'renamed.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockPerformRename).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + name: 'renamed.csv', + userId: 'user-1', + }) + }) + + it('maps a conflict errorCode to 409 through the shared mapper', async () => { + mockPerformRename.mockResolvedValue({ + success: false, + error: 'A file named "renamed.csv" already exists in this workspace', + errorCode: 'conflict', + }) + + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + const body = await res.json() + + expect(res.status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + expect(body.error.message).toContain('already exists') + }) + + it('hides an unclassified failure behind a generic 500', async () => { + mockPerformRename.mockResolvedValue({ + success: false, + error: 'update "workspace_files" set ... failed', + errorCode: 'internal', + }) + + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + const body = await res.json() + + expect(res.status).toBe(500) + expect(body.error.message).toBe('Internal server error') + }) +}) + +describe('DELETE /api/v2/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 1, folders: 0 } }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callDelete(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('archives the file and acknowledges', async () => { + const res = await callDelete(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ id: FILE_ID, deleted: true }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: [FILE_ID], + request: expect.anything(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callDelete(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts new file mode 100644 index 00000000000..133d5debeb7 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,181 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteFileContract, + v2DownloadFileContract, + v2RenameFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId] — Download file content (binary). + * + * The response carries no JSON envelope, so rate-limit state is surfaced via + * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. + * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DownloadFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') + + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, + 'Content-Length': String(buffer.length), + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/files/[fileId] — Rename a file. + * + * Renaming only; use `POST /api/v2/files/move` to change a file's folder. + * Names that collide within the destination folder are rejected as `CONFLICT` — + * unlike upload, which auto-suffixes on the internal surface. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RenameFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, name } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId }) + + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to rename file') + ) + } + + return v2Data(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — Delete a file. + * + * Delegates to the shared orchestration, which is workspace-scoped and records + * its own audit entry (the request is forwarded so that entry captures client + * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather + * than v1's blanket 500. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + request, + }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete file') + ) + } + + logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) + + return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts new file mode 100644 index 00000000000..25b15d2f47b --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -0,0 +1,271 @@ +/** + * @vitest-environment node + * + * Public v2 file share. The two decisions that separate it from the internal + * route are pinned here: the caller-supplied `token` is rejected, and a bare + * re-enable keeps the token the orchestration already stored. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformGetShare, mockPerformUpsert } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformGetShare: vi.fn(), + mockPerformUpsert: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performGetWorkspaceFileShare: mockPerformGetShare, + performUpsertWorkspaceFileShare: mockPerformUpsert, +})) + +import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const SHARE = { + id: 'shr_1', + token: 'existing-token-abcd', + url: 'https://www.sim.ai/f/existing-token-abcd', + isActive: true, + resourceType: 'file' as const, + resourceId: FILE_ID, + authType: 'public' as const, + hasPassword: false, + allowedEmails: [] as string[], +} + +const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } + +const callGet = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`), ctx) + +const callPut = (body: unknown) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx + ) + +describe('GET /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformGetShare.mockResolvedValue({ success: true, share: SHARE }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callGet(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('reads at workspace read level and returns the share', async () => { + const res = await callGet(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ share: SHARE }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'user-1', WS, 'read') + expect(mockPerformGetShare).toHaveBeenCalledWith({ workspaceId: WS, fileId: FILE_ID }) + }) + + it('returns a null share for a file that was never shared', async () => { + mockPerformGetShare.mockResolvedValue({ success: true, share: null }) + const res = await callGet(`workspaceId=${WS}`) + expect((await res.json()).data).toEqual({ share: null }) + }) +}) + +describe('PUT /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpsert.mockResolvedValue({ success: true, share: SHARE }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPut({ workspaceId: WS, isActive: true }) + + expect(res.status).toBe(404) + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('400s when isActive is missing', async () => { + const res = await callPut({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('rejects a caller-supplied token instead of minting a predictable URL', async () => { + const res = await callPut({ + workspaceId: WS, + isActive: true, + token: 'attacker-chosen-token', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callPut({ workspaceId: WS, isActive: true }) + expect(res.status).toBe(403) + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPut({ workspaceId: WS, isActive: true }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('enables the share at workspace write level and never forwards a token', async () => { + const res = await callPut({ + workspaceId: WS, + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ share: SHARE }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WS, + 'write' + ) + expect(mockPerformUpsert).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + allowedEmails: undefined, + request: expect.anything(), + }) + expect(mockPerformUpsert.mock.calls[0][0]).not.toHaveProperty('token') + }) + + it('preserves the existing token on a bare re-enable', async () => { + const res = await callPut({ workspaceId: WS, isActive: true }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.share.token).toBe('existing-token-abcd') + expect(body.data.share.url).toBe('https://www.sim.ai/f/existing-token-abcd') + // No authType either: the orchestration resolves the stored one, so the + // access-control gate is evaluated against the real mode, not 'public'. + expect(mockPerformUpsert).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, authType: undefined }) + ) + }) + + it('maps a forbidden errorCode from the access-control policy to 403', async () => { + mockPerformUpsert.mockResolvedValue({ + success: false, + error: 'Public file sharing is not allowed based on your permission group settings', + errorCode: 'forbidden', + }) + + const res = await callPut({ workspaceId: WS, isActive: true }) + const body = await res.json() + + expect(res.status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(body.error.message).toContain('not allowed') + }) + + it('maps a validation errorCode to 400', async () => { + mockPerformUpsert.mockResolvedValue({ + success: false, + error: 'Password is required for password-protected shares', + errorCode: 'validation', + }) + + const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe( + 'Password is required for password-protected shares' + ) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..088672432c5 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileShareAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId]/share — Read a file's public share state. + * + * `null` means the file has never been shared. `hasPassword` is the only signal + * carried for a password-gated share; the ciphertext is never exposed. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-share') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFileShareContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to fetch share') + ) + } + + return v2Data({ share: result.share ?? null }, { rateLimit }) + } catch (error) { + logger.error('Error fetching file share', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PUT /api/v2/files/[fileId]/share — Enable or disable a file's public share. + * + * Requires workspace `write`, matching the UI. The share token is always + * server-generated: the internal surface accepts a caller-supplied one so the UI + * can render a link before saving, but over an API key that would mint + * predictable public URLs and collide with the token unique index. + * + * `isActive: false` disables, it does not revoke — the token and the stored + * password / allow-list survive, so re-enabling resurrects the same URL. + */ +export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-share') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpsertFileShareContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, isActive, authType, password, allowedEmails } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpsertWorkspaceFileShare({ + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + request, + }) + + if (!result.success || !result.share) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to update share') + ) + } + + return v2Data({ share: result.share }, { rateLimit }) + } catch (error) { + logger.error('Error updating file share', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts new file mode 100644 index 00000000000..31795501de6 --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformDelete } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformDelete: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { POST } from '@/app/api/v2/files/bulk-delete/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const callDelete = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('POST /api/v2/files/bulk-delete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when the selection is empty', async () => { + const res = await callDelete({ workspaceId: WS, fileIds: [] }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(403) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('deletes the selection and reports the file count', async () => { + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ deletedItems: { files: 3 } }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1'], + request: expect.anything(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts new file mode 100644 index 00000000000..e0bd99a4b89 --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileBulkDeleteAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by + * `/api/v2/files/folders` so this resource operation never accepts folder ids. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-bulk-delete') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2BulkDeleteFilesContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + request, + }) + + if (!result.success || !result.deletedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete files') + ) + } + + return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) + } catch (error) { + logger.error('Error deleting files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts new file mode 100644 index 00000000000..12b73d316f1 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -0,0 +1,172 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateFileFolderContract, + v2DeleteFileFolderContract, + v2ListFileFoldersContract, + v2RelocateFileFolderContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { toFolderPathView } from '@/lib/folders/paths' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, + performRelocateWorkspaceFileFolderByPath, +} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListFileFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'file') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'file', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing file folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + return v2Data( + { folder: toFolderPathView(result.folder, result.path) }, + { rateLimit, status: 201 } + ) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performRelocateWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data({ path, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) +}) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts new file mode 100644 index 00000000000..b6651476b8f --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformMove } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformMove: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performMoveWorkspaceFileItems: mockPerformMove, +})) + +import { POST } from '@/app/api/v2/files/move/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const callMove = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('POST /api/v2/files/move', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformMove.mockResolvedValue({ success: true, movedItems: { files: 2, folders: 0 } }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + + expect(res.status).toBe(404) + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('400s when the selection is empty', async () => { + const res = await callMove({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(403) + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('moves the selection into the target folder', async () => { + const res = await callMove({ + workspaceId: WS, + fileIds: ['wf_1', 'wf_2'], + targetFolderPath: '/Reports', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ movedItems: { files: 2 } }) + expect(mockPerformMove).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1', 'wf_2'], + targetFolderPath: '/Reports', + }) + }) + + it('treats an omitted targetFolderPath as the workspace root', async () => { + await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(mockPerformMove).toHaveBeenCalledWith( + expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' }) + ) + }) + + it('maps a conflict errorCode to 409 without partially applying', async () => { + mockPerformMove.mockResolvedValue({ + success: false, + error: 'A file named "data.csv" already exists in the destination folder', + errorCode: 'conflict', + }) + + const res = await callMove({ + workspaceId: WS, + fileIds: ['wf_1'], + targetFolderPath: '/Reports', + }) + const body = await res.json() + + expect(res.status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + }) +}) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts new file mode 100644 index 00000000000..fa4f8b0053c --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileMoveAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/move — Move files into a folder. + * + * An omitted `targetFolderPath` moves the selection to the + * workspace root. The whole selection moves under one advisory lock, so a name + * collision at the destination fails the request as `CONFLICT` rather than + * partially applying. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-move') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2MoveFileItemsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds, targetFolderPath } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + targetFolderPath: targetFolderPath ?? '/', + }) + + if (!result.success || !result.movedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to move file items') + ) + } + + return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) + } catch (error) { + logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts new file mode 100644 index 00000000000..d4e4cee44ed --- /dev/null +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -0,0 +1,515 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockPerformCreateWorkspaceFile, + mockQueryWorkspaceFiles, + mockResolveWorkspaceAccess, + mockV2ApiGateError, + mockLoadActiveFolderPathIndex, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockPerformCreateWorkspaceFile: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), + mockV2ApiGateError: vi.fn().mockResolvedValue(null), + mockLoadActiveFolderPathIndex: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + queryWorkspaceFiles: mockQueryWorkspaceFiles, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' +import { GET, POST } from '@/app/api/v2/files/route' + +const WS = 'workspace-1' +const FOLDER_ID = 'fold_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildRecord(overrides: Record = {}) { + return { + id: 'wf_1', + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 1024, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + folderId: undefined, + search: undefined, + sortBy: 'uploadedAt', + sortOrder: 'asc', + limit: 100, + after: undefined, +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) + +function createRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/v2/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('GET /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), + pathById: new Map([['fold_1', '/Reports']]), + idByPath: new Map([ + ['/Reports', 'fold_1'], + ['/Fixtures', 'fold_1'], + ]), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('limit=10') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on a scope outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&scope=everything`) + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public file shape including folder and updatedAt', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], + nextKeys: null, + }) + + const res = await callList(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'wf_1', + name: 'data.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/Reports/Q1', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + }) + + it('lists active files only and rejects the removed archived scope', async () => { + await callList(`workspaceId=${WS}`) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + + const res = await callList(`workspaceId=${WS}&scope=archived`) + expect(res.status).toBe(400) + }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + await callList( + `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc` + ) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: FOLDER_ID, + search: 'report', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: null, + }) + }) + + it('400s on a sort field outside the enum instead of passing it toward the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('emits a cursor stamped with the sort and resumes from its keys', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord()], + nextKeys: ['data.csv', 'wf_1'], + }) + + const first = await callList(`workspaceId=${WS}&sortBy=name`) + const { nextCursor } = await first.json() + expect(nextCursor).not.toBeNull() + + await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`) + + expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + sortBy: 'name', + after: ['data.csv', 'wf_1'], + }) + }) + + it('400s when a cursor is replayed under a different sort', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord()], + nextKeys: ['data.csv', 'wf_1'], + }) + + const first = await callList(`workspaceId=${WS}&sortBy=name`) + const { nextCursor } = await first.json() + mockQueryWorkspaceFiles.mockClear() + + const res = await callList( + `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toMatch(/cursor does not match/i) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on a malformed cursor instead of silently restarting from page one', async () => { + const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) + + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s when the cursor carries values the sort cannot hold', async () => { + mockQueryWorkspaceFiles.mockRejectedValue( + new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.') + ) + const cursor = Buffer.from( + JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] }) + ).toString('base64') + + const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('terminates pagination when the query reports no further keys', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + + const res = await callList(`workspaceId=${WS}&search=data`) + + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: true, + file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }), + }) + }) + + it('creates an empty exact-name file with an inferred MIME type', async () => { + const request = createRequest({ workspaceId: WS, name: 'untitled.md' }) + + const response = await POST(request) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toMatchObject({ + data: { id: 'wf_1', name: 'untitled.md', size: 0, type: 'text/markdown' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + name: 'untitled.md', + contentType: 'text/markdown', + folderPath: '/', + content: Buffer.alloc(0), + exactName: true, + request, + }) + }) + + it('decodes initialized base64 content before orchestration', async () => { + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: true, + file: buildRecord({ + name: 'seed.bin', + size: 3, + type: 'application/octet-stream', + folderId: FOLDER_ID, + folderPath: 'Fixtures', + }), + }) + const request = createRequest({ + workspaceId: WS, + name: 'seed.bin', + contentType: 'application/octet-stream', + folderPath: '/Fixtures', + content: Buffer.from([1, 2, 3]).toString('base64'), + encoding: 'base64', + }) + + const response = await POST(request) + + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WS, + name: 'seed.bin', + contentType: 'application/octet-stream', + folderPath: '/Fixtures', + content: Buffer.from([1, 2, 3]), + exactName: true, + }) + ) + }) + + it('rejects malformed base64 before workspace access or orchestration', async () => { + const response = await POST( + createRequest({ + workspaceId: WS, + name: 'seed.bin', + content: 'not-base64!', + encoding: 'base64', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST' }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte file', async () => { + const response = await POST( + createRequest({ workspaceId: WS, name: 'empty.bin', content: '', encoding: 'base64' }) + ) + + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ content: Buffer.alloc(0) }) + ) + }) + + it('returns the canonical v2 envelope when the JSON body exceeds the inline limit', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/files', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(MAX_WORKSPACE_FILE_INLINE_BODY_BYTES + 1), + }, + body: '{}', + }) + + const response = await POST(request) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the canonical v2 envelope for malformed JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not-json', + }) + + const response = await POST(request) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it.each([ + { + label: 'name conflict', + result: { + success: false, + error: 'A file with this name already exists', + errorCode: 'conflict', + }, + status: 409, + code: 'CONFLICT', + message: 'A file with this name already exists', + }, + { + label: 'internal orchestration failure', + result: { success: false, error: 'database connection details', errorCode: 'internal' }, + status: 500, + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }, + ])('maps a $label into the v2 error envelope', async ({ result, status, code, message }) => { + mockPerformCreateWorkspaceFile.mockResolvedValue(result) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(status) + await expect(response.json()).resolves.toMatchObject({ error: { code, message } }) + }) + + it('returns the auth failure before gating, access checks, or orchestration', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + error: 'Invalid API key', + limit: 100, + remaining: 0, + resetAt: RATE_LIMIT_OK.resetAt, + }) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, + }) + expect(mockV2ApiGateError).not.toHaveBeenCalled() + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the v2 gate failure before access checks or orchestration', async () => { + const { v2Error } = await import('@/app/api/v2/lib/response') + mockV2ApiGateError.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(404) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('requires workspace write access before orchestration', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(403) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts new file mode 100644 index 00000000000..09ba08501d3 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,162 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2File, + v2CreateFileContract, + v2ListFilesContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performCreateWorkspaceFile, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { resolveFolderPathId } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CaughtOrchestrationError, + v2CursorList, + v2CursorSortError, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FilesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/files — List files in a workspace with search, sort, and cursor + * pagination. + * + * Filtering, ordering, and the page slice all run inside + * {@link queryWorkspaceFiles}' query. The route only translates the validated + * params and the opaque cursor, so a `search` never costs a full-workspace read. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) + + const items: V2File[] = files.map(toV2File) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null + + return v2CursorList(items, nextCursor, { rateLimit }) + } catch (error) { + // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFileContract, + request, + {}, + { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : parsed.response + } + + const { workspaceId, name, contentType, folderPath, content, encoding } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateWorkspaceFile({ + workspaceId, + userId, + name, + contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), + folderPath: folderPath ?? '/', + content: Buffer.from(content, encoding), + exactName: true, + request, + }) + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to create file') + ) + } + + return v2Data(toV2File(result.file), { rateLimit, status: 201 }) + } catch (error) { + logger.error('Error creating file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..9739994d62d --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CompleteFileUploadAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CompleteFileUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + purpose: 'workspace_file', + uploadToken: parsed.data.headers['upload-token'], + }) + const result = await completeUploadSession({ + session, + completion: parsed.data.body, + finalize: async (claimed) => { + const finalized = await finalizeWorkspaceFileUpload({ + session: claimed, + actor: { id: userId }, + request, + source: 'api', + }) + return { value: finalized.file, completedFileId: finalized.file.id } + }, + }) + return v2Data(toV2FileUpload(result.session, result.value), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to complete file upload', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..f27f759faa7 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadPartsAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateFileUploadPartUrlsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + purpose: 'workspace_file', + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create file upload part URLs', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..9561a1abcfa --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2AbortFileUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + purpose: 'workspace_file', + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortUploadSession(session) + return v2Data(toV2FileUpload(aborted, null), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to abort file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts new file mode 100644 index 00000000000..25f2a733ed0 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCreateUploadSession, + mockLoadActiveFolderPathIndex, + mockWithFolderTreeLock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockWithFolderTreeLock: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/folders/locks', () => ({ + withFolderTreeLock: mockWithFolderTreeLock, +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + createUploadSession: mockCreateUploadSession, +})) + +import { POST } from '@/app/api/v2/files/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} +const UPLOAD_SESSION = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: `${WORKSPACE_ID}/file.csv`, + finalKey: `${WORKSPACE_ID}/file.csv`, + stagingKey: 'upload-sessions/upload-1/file.csv', + storageProvider: 's3', + providerUploadId: null, + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + partSize: null, + partCount: null, + status: 'uploading', + uploadToken: 'signed-upload-token', + metadata: {}, + completedFileId: null, + error: null, + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + createdAt: new Date('2026-08-03T21:00:00.000Z'), + updatedAt: new Date('2026-08-03T21:00:00.000Z'), + completedAt: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'text/csv' }, + }, +} + +function request(body: Record) { + return POST( + new NextRequest('http://localhost:3000/api/v2/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +describe('POST /api/v2/files/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => + operation({}) + ) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map([['/Reports', 'folder-reports']]), + }) + mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION) + }) + + it('creates one signed PUT session for a small file', async () => { + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }) + + expect(response.status).toBe(201) + const { data } = await response.json() + expect(data).toMatchObject({ + session: { id: 'upload-1', status: 'uploading', file: null }, + uploadToken: 'signed-upload-token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, + }) + expect(data.session).not.toHaveProperty('uploadToken') + expect(data.session).not.toHaveProperty('transfer') + expect(data.session).not.toHaveProperty('partSize') + expect(data.session).not.toHaveProperty('partCount') + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + metadata: { folderId: null }, + localOrigin: 'http://localhost:3000', + }) + }) + + it('authorizes workspace write access before creating provider state', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }) + + expect(response.status).toBe(403) + expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('creates an upload session for an empty workspace file', async () => { + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 0, + }) + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + ) + }) + + it('releases the folder tree lock before creating an upload session', async () => { + let lockHeld = false + mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => { + lockHeld = true + try { + return await operation({}) + } finally { + lockHeld = false + } + }) + mockCreateUploadSession.mockImplementationOnce(async () => { + expect(lockHeld).toBe(false) + return UPLOAD_SESSION + }) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + folderPath: '/Reports', + }) + + expect(response.status).toBe(201) + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + WORKSPACE_ID, + 'file', + expect.any(Object) + ) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts new file mode 100644 index 00000000000..b57f128b19a --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -0,0 +1,73 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadSession } from '@/lib/uploads/upload-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFileUploadContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, name, contentType, size, folderPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'file', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + const session = await createUploadSession({ + workspaceId, + userId, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: resolution.folderId }, + localOrigin: request.nextUrl.origin, + }) + return v2Data( + { + session: toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + { rateLimit, status: 201 } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts new file mode 100644 index 00000000000..45cebe61bab --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -0,0 +1,35 @@ +import type { V2FileUpload } from '@/lib/api/contracts/v2/files' +import type { V2UploadStatus } from '@/lib/api/contracts/v2/uploads' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' +import { toV2File } from '@/app/api/v2/files/utils' + +export function toV2FileUpload( + session: UploadSessionRecord, + file: WorkspaceFileRecord | null +): V2FileUpload { + return { + id: session.id, + status: uploadStatus(session.status), + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + file: file ? toV2File(file) : null, + } +} + +function uploadStatus(status: string): V2UploadStatus { + if ( + status !== 'uploading' && + status !== 'finalizing' && + status !== 'completed' && + status !== 'failed' && + status !== 'aborted' && + status !== 'expired' + ) { + throw new Error(`Invalid upload session status: ${status}`) + } + return status +} diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts new file mode 100644 index 00000000000..f5003680ad4 --- /dev/null +++ b/apps/sim/app/api/v2/files/utils.ts @@ -0,0 +1,32 @@ +import type { V2File } from '@/lib/api/contracts/v2/files' +import { buildFolderPath } from '@/lib/folders/paths' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' + +/** Shared serialization for the v2 files surface. */ + +/** + * Public file projection. `workspaceId` (already known to the caller, who + * supplied it) and the internal storage/versioning columns are not exposed. + */ +export function toV2File(record: WorkspaceFileRecord): V2File { + const folderPath = record.folderId + ? buildFolderPath( + (() => { + if (!record.folderPath) throw new Error('File references an unresolved folder') + return record.folderPath.split('/') + })() + ) + : '/' + + return { + id: record.id, + name: record.name, + size: record.size, + type: record.type, + key: record.key, + folderPath, + uploadedBy: record.uploadedBy, + uploadedAt: record.uploadedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts new file mode 100644 index 00000000000..ef9318b1dc6 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,225 @@ +import { db } from '@sim/db' +import { document, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocument, + v2DeleteKnowledgeDocumentContract, + v2GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface DocumentDetailRouteParams { + params: Promise<{ id: string; documentId: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + } + + return v2Data({ document: documentDetail }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts new file mode 100644 index 00000000000..b6adfff3c07 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,295 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getDocuments } from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface DocumentsRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ +export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + // Opaque cursor encodes the underlying offset (upgradeable to keyset later). + const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })) + + const nextCursor = documentsResult.pagination.hasMore + ? encodeCursor({ offset: offset + limit }) + : null + return v2CursorList(documents, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing documents`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. + * + * Authorization runs fully before the multipart body is buffered: the workspace + * is a contract-validated query param (not a form field as in v1), so an + * unauthorized caller never streams a file into memory. Order: rate limit → + * KB ownership (write) → usage gate → buffered multipart read. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + /** + * Gate before storage and indexing. Workspace keys use the billed account + * and immutable payer from one read; personal keys preserve their human actor. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + const contentType = file.type || 'application/octet-stream' + + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) + + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, + document: { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + const newDocument = outcome.document + + const document: V2KnowledgeDocumentSummary = { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + } + + return v2Data({ document }, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + logger.error(`[${requestId}] Error uploading document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts new file mode 100644 index 00000000000..91a07f307f5 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockCompleteUploadSession, + mockFinalizeKnowledgeDocumentUpload, + mockResolveKnowledgeDocumentUploadAccess, + mockResolveKnowledgeDocumentUploadAttribution, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockCompleteUploadSession: vi.fn(), + mockFinalizeKnowledgeDocumentUpload: vi.fn(), + mockResolveKnowledgeDocumentUploadAccess: vi.fn(), + mockResolveKnowledgeDocumentUploadAttribution: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/uploads/upload-session/service', () => ({ + completeUploadSession: mockCompleteUploadSession, +})) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + finalizeKnowledgeDocumentUpload: mockFinalizeKnowledgeDocumentUpload, + getOwnedKnowledgeDocumentUpload: vi.fn(() => SESSION), + resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadAttribution: mockResolveKnowledgeDocumentUploadAttribution, + toV2KnowledgeDocumentUpload: (session: Record, document: unknown) => ({ + ...session, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: '2026-08-04T21:00:00.000Z', + document, + }), +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const FILE_URL = '/api/files/serve/s3/kb%2Fguide.pdf?context=knowledge-base' +const SESSION = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + workflowId: null, + executionId: null, + purpose: 'knowledge_document', + method: 'multipart', + storageContext: 'knowledge-base', + storageKey: 'kb/guide.pdf', + finalKey: 'kb/guide.pdf', + stagingKey: 'upload-sessions/upload-1/guide.pdf', + storageProvider: 's3', + providerUploadId: 'provider-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + uploadToken: 'token', + createdAt: new Date('2026-08-03T21:00:00.000Z'), + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-03T21:00:00.000Z'), +} as const +const DOCUMENT = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: 'guide.pdf', + fileUrl: FILE_URL, + fileSize: 1024, + mimeType: 'application/pdf', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date('2026-08-03T21:01:00.000Z'), +} +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +function request() { + return POST( + new NextRequest( + `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1/complete?workspaceId=${WORKSPACE_ID}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'upload-token': 'token' }, + body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }), + } + ), + { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } + ) +} + +describe('POST knowledge-document multipart completion', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ + kb: { id: 'kb-1', name: 'Docs' }, + }) + mockResolveKnowledgeDocumentUploadAttribution.mockResolvedValue({ actorUserId: 'payer-1' }) + mockFinalizeKnowledgeDocumentUpload.mockResolvedValue({ + value: DOCUMENT, + completedFileId: DOCUMENT.id, + }) + mockCompleteUploadSession.mockImplementation(async ({ session, finalize }) => { + const finalized = await finalize(session) + return { + session: { ...session, status: 'completed', completedFileId: finalized.completedFileId }, + value: finalized.value, + alreadyCompleted: false, + } + }) + }) + + it('delegates completion to the shared finalizer and returns the bound document', async () => { + const response = await request() + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) + expect(mockFinalizeKnowledgeDocumentUpload).toHaveBeenCalledWith( + expect.objectContaining({ + claimed: SESSION, + knowledgeBaseId: 'kb-1', + knowledgeBaseName: 'Docs', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + source: 'api', + }) + ) + expect(mockCompleteUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ + session: SESSION, + completion: { parts: [{ partNumber: 1, etag: 'etag-1' }] }, + }) + ) + }) + + it('resolves the payer lazily, only when the finalizer asks for one', async () => { + await request() + + expect(mockResolveKnowledgeDocumentUploadAttribution).not.toHaveBeenCalled() + + const { resolveAttribution } = mockFinalizeKnowledgeDocumentUpload.mock.calls[0][0] + await resolveAttribution() + + expect(mockResolveKnowledgeDocumentUploadAttribution).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + rateLimit: RATE_LIMIT, + }) + }) + + it('maps an orchestration failure from the finalizer onto its v2 status', async () => { + mockFinalizeKnowledgeDocumentUpload.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded') + ) + + const response = await request() + + expect(response.status).toBe(413) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..0da551ca60d --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,97 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeUploadSession } from '@/lib/uploads/upload-session/service' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + finalizeKnowledgeDocumentUpload, + getOwnedKnowledgeDocumentUpload, + resolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadAttribution, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CompleteKnowledgeDocumentUploadAPI') + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CompleteKnowledgeDocumentUploadContract, + request, + context, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const session = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const result = await completeUploadSession({ + session, + completion: parsed.data.body, + finalize: (claimed) => + finalizeKnowledgeDocumentUpload({ + claimed, + knowledgeBaseId, + knowledgeBaseName: access.kb.name, + workspaceId, + userId, + resolveAttribution: () => + resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId, rateLimit }), + source: 'api', + requestId, + request, + }), + }) + + return v2Data(toV2KnowledgeDocumentUpload(result.session, result.value), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error(`[${requestId}] Failed to complete knowledge-document upload`, { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..e69f1eece4b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + getOwnedKnowledgeDocumentUpload, + resolveKnowledgeDocumentUploadAccess, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentUploadPartsAPI') + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateKnowledgeDocumentUploadPartUrlsContract, + request, + context, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const session = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create knowledge-document upload part URLs', { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..108f4137573 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + abortKnowledgeDocumentUpload, + getOwnedKnowledgeDocumentUpload, + resolveKnowledgeDocumentUploadAccess, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentUploadAPI') + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2AbortKnowledgeDocumentUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const session = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId) + return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to abort knowledge-document upload session', { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts new file mode 100644 index 00000000000..0d0b0462332 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts @@ -0,0 +1,138 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockCreateKnowledgeDocumentUploadSession, + mockResolveKnowledgeDocumentUploadAccess, + mockResolveKnowledgeDocumentUploadBilling, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockCreateKnowledgeDocumentUploadSession: vi.fn(), + mockResolveKnowledgeDocumentUploadAccess: vi.fn(), + mockResolveKnowledgeDocumentUploadBilling: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession, + resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling, + toV2KnowledgeDocumentUpload: (session: Record) => ({ + ...session, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: '2026-08-04T21:00:00.000Z', + document: null, + }), +})) + +import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +function request() { + return POST( + new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }), + }), + { params: Promise.resolve({ id: 'kb-1' }) } + ) +} + +describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ + kb: { id: 'kb-1', name: 'Docs' }, + }) + mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) + mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'uploading', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + uploadToken: 'token', + error: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'application/pdf' }, + }, + }) + }) + + it('authorizes the knowledge base and runs usage billing before accepting storage', async () => { + const response = await request() + + expect(response.status).toBe(201) + expect(mockResolveKnowledgeDocumentUploadAccess).toHaveBeenCalledWith( + expect.objectContaining({ + knowledgeBaseId: 'kb-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + ) + expect(mockResolveKnowledgeDocumentUploadBilling).toHaveBeenCalled() + expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + localOrigin: 'http://localhost:3000', + }) + expect((await response.json()).data).toMatchObject({ + session: { id: 'upload-1', status: 'uploading', document: null }, + uploadToken: 'token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, + }) + expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] + ) + }) + + it('does not run billing or create provider state when knowledge write access is denied', async () => { + mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue( + NextResponse.json({ error: { code: 'FORBIDDEN', message: 'Access denied' } }, { status: 403 }) + ) + + const response = await request() + + expect(response.status).toBe(403) + expect(mockResolveKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() + expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts new file mode 100644 index 00000000000..e736548fd98 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + createKnowledgeDocumentUploadSession, + resolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadBilling, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentUploadsAPI') + +interface KnowledgeDocumentUploadsRouteParams { + params: Promise<{ id: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateKnowledgeDocumentUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const billing = await resolveKnowledgeDocumentUploadBilling({ + workspaceId, + userId, + rateLimit, + }) + if (billing instanceof NextResponse) return billing + + const fileTypeError = validateFileType(name, contentType) + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const session = await createKnowledgeDocumentUploadSession({ + workspaceId, + userId, + knowledgeBaseId, + fileName: name, + contentType, + fileSize: size, + metadata, + localOrigin: request.nextUrl.origin, + }) + return v2Data( + { + session: toV2KnowledgeDocumentUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + { rateLimit, status: 201 } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create knowledge-document upload session', { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts new file mode 100644 index 00000000000..564388df5d2 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts @@ -0,0 +1,254 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +const { + mockAbortUploadSession, + mockCreateUploadSession, + mockFindBoundKnowledgeDocument, + mockPerformUploadKnowledgeDocument, + mockRecordKnowledgeBaseFileOwnership, +} = vi.hoisted(() => ({ + mockAbortUploadSession: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockFindBoundKnowledgeDocument: vi.fn(), + mockPerformUploadKnowledgeDocument: vi.fn(), + mockRecordKnowledgeBaseFileOwnership: vi.fn(), +})) + +vi.mock('@/lib/knowledge/orchestration', () => ({ + performUploadKnowledgeDocument: mockPerformUploadKnowledgeDocument, +})) +vi.mock('@/lib/knowledge/orchestration/documents', () => ({ + findBoundKnowledgeDocument: mockFindBoundKnowledgeDocument, +})) +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: mockAbortUploadSession, + createUploadSession: mockCreateUploadSession, + getOwnedUploadSession: vi.fn(), +})) +vi.mock('@/lib/uploads/server/metadata', () => ({ + recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, +})) + +import { + abortKnowledgeDocumentUpload, + createKnowledgeDocumentUploadSession, + finalizeKnowledgeDocumentUpload, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const CLAIMED: UploadSessionRecord = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + workflowId: null, + executionId: null, + purpose: 'knowledge_document', + method: 'multipart', + storageContext: 'knowledge-base', + storageKey: 'kb/guide.pdf', + finalKey: 'kb/guide.pdf', + stagingKey: 'upload-sessions/upload-1/guide.pdf', + storageProvider: 's3', + providerUploadId: 'provider-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + metadata: { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' } }, + uploadToken: 'token', + createdAt: new Date('2026-08-03T21:00:00.000Z'), + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-03T21:00:00.000Z'), +} +const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf' } + +function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: 'payer-1' })) { + return finalizeKnowledgeDocumentUpload({ + claimed: CLAIMED, + knowledgeBaseId: 'kb-1', + knowledgeBaseName: 'Docs', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + resolveAttribution, + source: 'api', + requestId: 'req-1', + request: new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1'), + }) +} + +function createSession() { + return createKnowledgeDocumentUploadSession({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { tag1: 'product' }, + localOrigin: 'http://localhost:3000', + }) +} + +describe('createKnowledgeDocumentUploadSession', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateUploadSession.mockResolvedValue(CLAIMED) + mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) + mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' }) + }) + + it('records the ownership binding before returning the upload token', async () => { + await expect(createSession()).resolves.toBe(CLAIMED) + + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { tag1: 'product' }, + localOrigin: 'http://localhost:3000', + }) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ + key: 'kb/guide.pdf', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + originalName: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }) + expect(mockCreateUploadSession.mock.invocationCallOrder[0]).toBeLessThan( + mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0] + ) + }) + + it('aborts provider state when the ownership binding cannot be recorded', async () => { + mockRecordKnowledgeBaseFileOwnership.mockRejectedValue(new Error('database unavailable')) + + await expect(createSession()).rejects.toThrow('database unavailable') + expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED) + }) +}) + +describe('toV2KnowledgeDocumentUpload', () => { + it('does not expose reusable upload capabilities after session creation', () => { + const serialized = toV2KnowledgeDocumentUpload(CLAIMED, null) + + expect(serialized).not.toHaveProperty('uploadToken') + expect(serialized).not.toHaveProperty('partSize') + expect(serialized).not.toHaveProperty('partCount') + expect(serialized).not.toHaveProperty('transfer') + }) +}) + +describe('abortKnowledgeDocumentUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' }) + }) + + it('aborts an upload that no document is bound to', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) + + await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).resolves.toMatchObject({ + status: 'aborted', + }) + expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED) + }) + + it('refuses to abort once a document is bound, so committed bytes survive', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT }) + + await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).rejects.toThrow( + 'Upload has already been completed' + ) + expect(mockAbortUploadSession).not.toHaveBeenCalled() + }) +}) + +describe('finalizeKnowledgeDocumentUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: true, + document: DOCUMENT, + created: true, + }) + }) + + it('creates the document, carrying session tags and processing options through', async () => { + const result = await finalize() + + expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) + expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: 'upload-1', + startProcessing: 'queue', + uploadedBy: 'payer-1', + processingOptions: { recipe: 'default', lang: 'en' }, + document: expect.objectContaining({ filename: 'guide.pdf', tag1: 'product' }), + }) + ) + }) + + it('answers a retry from the bound document without resolving a payer', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT }) + const resolveAttribution = vi.fn() + + const result = await finalize(resolveAttribution) + + expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) + expect(resolveAttribution).not.toHaveBeenCalled() + expect(mockPerformUploadKnowledgeDocument).not.toHaveBeenCalled() + }) + + it('retains completed bytes for retry when document creation fails', async () => { + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: false, + errorCode: 'payload_too_large', + error: 'Storage limit exceeded', + }) + + await expect(finalize()).rejects.toThrow('Storage limit exceeded') + expect(mockFindBoundKnowledgeDocument).toHaveBeenCalledTimes(1) + }) + + it('lets a retry converge when the first response fails after the document binds', async () => { + mockFindBoundKnowledgeDocument + .mockResolvedValueOnce({ status: 'absent' }) + .mockResolvedValueOnce({ status: 'bound', document: DOCUMENT }) + mockPerformUploadKnowledgeDocument.mockRejectedValue(new Error('audit sink exploded')) + + await expect(finalize()).rejects.toThrow('audit sink exploded') + await expect(finalize()).resolves.toEqual({ + value: DOCUMENT, + completedFileId: 'upload-1', + }) + expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledTimes(1) + }) + + it('rejects an upload id already bound to a different document without deleting anything', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'conflict' }) + const resolveAttribution = vi.fn() + + await expect(finalize(resolveAttribution)).rejects.toThrow( + 'Upload id is already bound to a different document' + ) + expect(resolveAttribution).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts new file mode 100644 index 00000000000..4e4122221f0 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts @@ -0,0 +1,276 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import type { + V2KnowledgeDocumentSummary, + V2KnowledgeDocumentUpload, +} from '@/lib/api/contracts/v2/knowledge' +import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' +import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' +import { + abortUploadSession, + type CreatedUploadSession, + createUploadSession, + getOwnedUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Error } from '@/app/api/v2/lib/response' + +export async function resolveKnowledgeDocumentUploadAccess(params: { + knowledgeBaseId: string + workspaceId: string + userId: string + rateLimit: RateLimitResult +}): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase( + params.knowledgeBaseId, + params.workspaceId, + params.userId, + params.rateLimit, + 'write' + ) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return v2Error('FORBIDDEN', 'Access denied') +} + +/** + * Resolves the payer for an upload without enforcing usage limits. Completion uses this + * because its bytes were already admitted when the session was created; re-running + * admission there would strand uploaded parts and fail idempotent completion retries. + */ +export async function resolveKnowledgeDocumentUploadAttribution(params: { + workspaceId: string + userId: string + rateLimit: RateLimitResult +}): Promise { + return params.rateLimit.keyType === 'workspace' + ? resolveSystemBillingAttribution(params.workspaceId) + : resolveBillingAttribution({ + actorUserId: params.userId, + workspaceId: params.workspaceId, + }) +} + +/** Admission check for a new upload session. Enforced only at session creation. */ +export async function resolveKnowledgeDocumentUploadBilling(params: { + workspaceId: string + userId: string + rateLimit: RateLimitResult +}): Promise { + const attribution = await resolveKnowledgeDocumentUploadAttribution(params) + const usage = await checkAttributedUsageLimits(attribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + return attribution +} + +export function getOwnedKnowledgeDocumentUpload(params: { + knowledgeBaseId: string + uploadId: string + workspaceId: string + userId: string + uploadToken: string +}): UploadSessionRecord { + return getOwnedUploadSession({ + uploadId: params.uploadId, + workspaceId: params.workspaceId, + userId: params.userId, + purpose: 'knowledge_document', + knowledgeBaseId: params.knowledgeBaseId, + uploadToken: params.uploadToken, + }) +} + +/** + * Creates a knowledge-document upload and records its ownership binding before the token is + * returned. Failed or abandoned sessions can then be reclaimed by the knowledge-base orphan + * sweeper without racing a later document insert. + */ +export async function createKnowledgeDocumentUploadSession(params: { + workspaceId: string + userId: string + knowledgeBaseId: string + fileName: string + contentType: string + fileSize: number + metadata: Record + localOrigin: string +}): Promise { + const session = await createUploadSession({ + ...params, + purpose: 'knowledge_document', + }) + try { + await recordKnowledgeBaseFileOwnership({ + key: session.storageKey, + userId: params.userId, + workspaceId: params.workspaceId, + originalName: params.fileName, + contentType: params.contentType, + size: params.fileSize, + }) + } catch (error) { + await abortUploadSession(session) + throw error + } + return session +} + +export function toV2KnowledgeDocumentSummary( + document: CreatedKnowledgeDocument +): V2KnowledgeDocumentSummary { + return { + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus ?? 'pending', + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + createdAt: serializeDate(document.uploadedAt), + } +} + +export function toV2KnowledgeDocumentUpload( + session: UploadSessionRecord, + document: CreatedKnowledgeDocument | null +): V2KnowledgeDocumentUpload { + if (!session.knowledgeBaseId) { + throw new Error('Knowledge-document upload session is missing its knowledge base') + } + return { + id: session.id, + knowledgeBaseId: session.knowledgeBaseId, + status: session.status, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + document: document ? toV2KnowledgeDocumentSummary(document) : null, + } +} + +export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { + if (session.storageContext !== 'knowledge-base') { + throw new Error('Knowledge-document upload has an invalid storage context') + } + const providerPrefix = session.storageProvider === 'local' ? '' : `${session.storageProvider}/` + return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base` +} + +function knowledgeDocumentInputFor(session: UploadSessionRecord) { + const { processingOptions: _processingOptions, ...documentTags } = + v2KnowledgeDocumentUploadMetadataSchema.parse(session.metadata) + return { + filename: session.fileName, + fileUrl: knowledgeDocumentFileUrl(session), + fileSize: session.fileSize, + mimeType: session.contentType, + ...documentTags, + } +} + +/** + * Aborts an upload session, refusing once a document is bound to it. + * + * Upload sessions are stateless — the signed token always reconstructs as `uploading`, so this + * guard preserves the completed state exposed by the document binding. Provider aborts must + * also remain non-destructive after commit because an in-flight completion is not visible here + * until its document transaction commits. + */ +export async function abortKnowledgeDocumentUpload( + session: UploadSessionRecord, + knowledgeBaseId: string +): Promise { + const bound = await findBoundKnowledgeDocument({ + documentId: session.id, + knowledgeBaseId, + document: knowledgeDocumentInputFor(session), + }) + if (bound.status !== 'absent') { + throw new OrchestrationError('conflict', 'Upload has already been completed') + } + return abortUploadSession(session) +} + +/** + * Binds a completed upload session to its knowledge document. Shared by the public v2 + * and session-authenticated routes so both get identical completion semantics. + * + * Ordering is load-bearing. A retry is answered from the already-bound document before any + * work that can fail independently of the upload runs, so a payer that became unresolvable + * after the session was created cannot turn a valid retry into an error. The ownership binding + * is recorded before the upload token is issued, so failures retain retriable state and the + * delayed orphan sweeper reclaims sessions that never bind to a document. + */ +export async function finalizeKnowledgeDocumentUpload(params: { + claimed: UploadSessionRecord + knowledgeBaseId: string + knowledgeBaseName: string | null + workspaceId: string + userId: string + resolveAttribution: () => Promise + source: 'api' | 'ui' + requestId: string + request: NextRequest + actorName?: string | null + actorEmail?: string | null +}): Promise<{ value: CreatedKnowledgeDocument; completedFileId: string }> { + const { claimed, knowledgeBaseId, workspaceId, requestId } = params + const { processingOptions } = v2KnowledgeDocumentUploadMetadataSchema.parse(claimed.metadata) + const document = knowledgeDocumentInputFor(claimed) + + const bound = await findBoundKnowledgeDocument({ + documentId: claimed.id, + knowledgeBaseId, + document, + }) + if (bound.status === 'bound') { + return { value: bound.document, completedFileId: bound.document.id } + } + if (bound.status === 'conflict') { + throw new OrchestrationError('conflict', 'Upload id is already bound to a different document') + } + + const billingAttribution = await params.resolveAttribution() + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: params.knowledgeBaseName, workspaceId }, + document, + documentId: claimed.id, + startProcessing: 'queue', + processingOptions, + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId: params.userId, + ...(params.actorName ? { actorName: params.actorName } : {}), + ...(params.actorEmail ? { actorEmail: params.actorEmail } : {}), + source: params.source, + requestId, + request: params.request, + }) + if (!outcome.success) { + throw new OrchestrationError(outcome.errorCode, outcome.error) + } + return { value: outcome.document, completedFileId: outcome.document.id } +} diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..a98d877d330 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,227 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface KnowledgeRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and + * renders any failure in the v2 envelope. A `404` (missing KB or workspace + * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as + * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced + * as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const folderIndex = await loadActiveFolderPathIndex( + parsed.data.query.workspaceId, + 'knowledge_base' + ) + + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(result.kb), + folderPath: folderPathForId(folderIndex, result.kb.folderId), + }, + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error getting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const resolution = + folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'knowledge_base', + path: folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId, + userId, + source: 'api', + updates: { name, description, chunkingConfig, folderId: resolution?.folderId }, + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(outcome.knowledgeBase), + folderPath: folderPathForId(folderIndex, outcome.knowledgeBase.folderId), + }, + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error updating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id, name: result.kb.name, workspaceId: parsed.data.query.workspaceId }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts new file mode 100644 index 00000000000..4995d5cc486 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/folders/route.ts @@ -0,0 +1,187 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeFolderContract, + v2DeleteKnowledgeFolderContract, + v2ListKnowledgeFoldersContract, + v2RelocateKnowledgeFolderContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListKnowledgeFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'knowledge_base', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts new file mode 100644 index 00000000000..bdd087b441e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + * + * Public v2 knowledge-base list: the search/filter/sort convention reaching the + * lib rather than being applied over its result. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetKnowledgeBases, + mockLoadActiveFolderPathIndex, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetKnowledgeBases: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mockGetKnowledgeBases, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/knowledge/orchestration', () => ({ + performCreateKnowledgeBase: vi.fn(), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/knowledge/route' + +const WS = 'workspace-1' +const FOLDER_ID = 'fold_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + folderId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'asc', +} + +function buildKnowledgeBase(overrides: Record = {}) { + return { + id: 'kb_1', + userId: 'user-1', + name: 'Support docs', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, + workspaceId: WS, + folderId: null, + docCount: 2, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/knowledge?${query}`)) + +describe('GET /api/v2/knowledge', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()]) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fold_1', { id: 'fold_1', name: 'Support', parentId: null }]]), + pathById: new Map([['fold_1', '/Support']]), + idByPath: new Map([['/Support', 'fold_1']]), + }) + }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + const res = await callList( + `workspaceId=${WS}&search=support&folderPath=${encodeURIComponent('/Support')}&sortBy=name&sortOrder=desc` + ) + + expect(res.status).toBe(200) + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { + folderId: FOLDER_ID, + search: 'support', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + it('defaults to the createdAt ordering when no sort is requested', async () => { + await callList(`workspaceId=${WS}`) + + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS) + }) + + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { + ...DEFAULT_LIST_ARGS, + folderId: null, + }) + }) + + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + + expect(res.status).toBe(400) + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('terminates pagination with a filter applied', async () => { + const res = await callList(`workspaceId=${WS}&search=support`) + + expect((await res.json()).nextCursor).toBeNull() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts new file mode 100644 index 00000000000..12720efaa46 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,158 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases } from '@/lib/knowledge/service' +import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + resolveFolderPathIdentity, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, folderPath, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { + folderId, + search, + sortBy, + sortOrder, + }) + const items = knowledgeBases.map((knowledgeBase) => ({ + ...formatKnowledgeBase(knowledgeBase), + folderPath: folderPathForId(folderIndex, knowledgeBase.folderId), + })) + + // `getKnowledgeBases` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge bases`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/knowledge — Create a new knowledge base. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'knowledge_base', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', + workspaceId, + name, + description, + chunkingConfig, + folderId: resolution.folderId, + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(outcome.knowledgeBase), + folderPath: folderPathForId(resolution.index, outcome.knowledgeBase.folderId), + }, + }, + { rateLimit, status: 201 } + ) + } catch (error) { + logger.error(`[${requestId}] Error creating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts new file mode 100644 index 00000000000..005edb3b919 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,319 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2KnowledgeSearchResult, + v2SearchKnowledgeContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { + generateSearchEmbedding, + getDocumentMetadataByIds, + getQueryStrategy, + handleTagAndVectorSearch, + handleTagOnlySearch, + handleVectorOnlySearch, + type SearchResult, +} from '@/app/api/knowledge/search/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeSearchAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-search') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2SearchKnowledgeContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, topK, query, tagFilters } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * A query incurs hosted embedding (+ optional rerank) cost — gate the + * actor's usage before spending; tag-only search is free. Workspace keys + * resolve their system actor and immutable payer from one workspace read. + */ + const hasBillableQuery = Boolean(query?.trim()) + const billingAttribution = hasBillableQuery + ? rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + : undefined + const billingActorUserId = billingAttribution?.actorUserId ?? userId + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) + ? parsed.data.body.knowledgeBaseIds + : [parsed.data.body.knowledgeBaseIds] + + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') + } + + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { + return v2Error( + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` + ) + } + + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) + } + + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } + + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const queryEmbeddingModel = embeddingModels[0] + + let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null + + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId: billingActorUserId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + billingAttribution, + }) + } + + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) + + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} + + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, + } + }) + + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Knowledge search error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts new file mode 100644 index 00000000000..294bb00771e --- /dev/null +++ b/apps/sim/app/api/v2/lib/folders.ts @@ -0,0 +1,67 @@ +import type { folder } from '@sim/db/schema' +import type { NextResponse } from 'next/server' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { + type FolderPathIndex, + isFolderPathEffectivelyLocked, + ROOT_FOLDER_PATH, + toFolderPathView, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { v2ErrorForOrchestration } from '@/app/api/v2/lib/response' + +type FolderRow = typeof folder.$inferSelect + +export function resolveFolderPathId( + index: FolderPathIndex, + path: string +): string | null | undefined { + return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) +} + +export type ResolvedFolderPathIdentity = + | { found: false } + | { found: true; folderId: string | null; index: FolderPathIndex } + +/** Resolves a path to its stable internal identity under a short-lived folder tree lock. */ +export async function resolveFolderPathIdentity(params: { + workspaceId: string + resourceType: FolderResourceType + path: string +}): Promise { + return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveFolderPathId(index, params.path) + return folderId === undefined ? { found: false } : { found: true, folderId, index } + }) +} + +export function folderPathForId( + index: FolderPathIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Resource references an inactive or missing folder') + return path +} + +export function toV2PathFolder( + row: FolderRow, + index: FolderPathIndex, + includeLocked: boolean +) { + const path = index.pathById.get(row.id) + if (!path) throw new Error('Folder path index is missing a listed folder') + const base = toFolderPathView(row, path) + return includeLocked ? { ...base, locked: isFolderPathEffectivelyLocked(index, row.id) } : base +} + +export function v2FolderPathMutationError( + errorCode: OrchestrationErrorCode | undefined, + message: string +): NextResponse { + return v2ErrorForOrchestration(errorCode, message) +} diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts new file mode 100644 index 00000000000..d9bf214eece --- /dev/null +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -0,0 +1,23 @@ +import type { NextResponse } from 'next/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Rollout gate for the entire `/api/v2` surface. + * + * Exactly one check per request, placed immediately after the route + * authenticates and before it does any work. When the flag is off the route + * answers 404 as if it did not exist, so an ungated caller cannot distinguish + * "not in the rollout cohort" from "no such endpoint". + * + * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would + * have to read membership for a caller-supplied id before authorization has + * run, and its 404-vs-403 split would then leak whether that workspace's org + * is in the cohort — the trap the per-domain table gate has to work around by + * running late. Keyed on the authenticated user, the check is safe to run + * first and is uniform across every v2 route. + */ +export async function v2ApiGateError(userId: string): Promise { + if (await isFeatureEnabled('v2-api', { userId })) return null + return v2Error('NOT_FOUND', 'Not found') +} diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..475f73cb5c5 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,252 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' + +/** + * Runtime response helpers for the v2 API surface. Every v2 route renders its + * output through these so the envelope, error shape, and rate-limit headers stay + * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit + * middleware and the platform domain services — these helpers only standardize + * the HTTP envelope. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'CLIENT_CLOSED_REQUEST' + | 'INTERNAL_ERROR' + | 'SERVICE_UNAVAILABLE' + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + CLIENT_CLOSED_REQUEST: 499, + INTERNAL_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} + +/** + * Every v2 response is authed, per-caller data (ids/filters appear in query + * strings) — keep it out of shared HTTP caches unconditionally. + */ +const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const + +type RateLimitHeaderSource = Pick + +export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { + if (!rateLimit) return {} + return { + 'X-RateLimit-Limit': rateLimit.limit.toString(), + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + } +} + +interface V2SuccessOptions { + rateLimit?: RateLimitHeaderSource + status?: number + headers?: Record +} + +function successHeaders(options: V2SuccessOptions): Record { + return { ...PRIVATE_NO_STORE, ...rateLimitHeaders(options.rateLimit), ...options.headers } +} + +/** `{ data }` (+ rate-limit headers). */ +export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { + return NextResponse.json( + { data }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +/** `{ data, nextCursor }` (+ rate-limit headers). */ +export function v2CursorList( + data: T[], + nextCursor: string | null, + options: V2SuccessOptions = {} +): NextResponse { + return NextResponse.json( + { data, nextCursor }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +interface V2ErrorOptions { + status?: number + details?: unknown + headers?: Record +} + +/** `{ error: { code, message, details? } }`. */ +export function v2Error( + code: V2ErrorCode, + message: string, + options: V2ErrorOptions = {} +): NextResponse { + const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + if (options.details !== undefined) error.details = options.details + return NextResponse.json( + { error }, + { + status: options.status ?? STATUS_BY_CODE[code], + headers: { ...PRIVATE_NO_STORE, ...options.headers }, + } + ) +} + +/** Render a contract `ZodError` as the v2 error envelope. */ +export function v2ValidationError(error: ZodError): NextResponse { + return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { + details: serializeZodIssues(error), + }) +} + +/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */ +export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse { + return v2Error(failure.code, failure.message, { status: failure.status }) +} + +/** + * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error + * envelope: an auth failure becomes 401, a throttle becomes 429 with + * `Retry-After`. + */ +export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse { + const headers = rateLimitHeaders(rateLimit) + if (rateLimit.error) { + return v2Error('UNAUTHORIZED', rateLimit.error, { headers }) + } + const retryAfterSeconds = rateLimit.retryAfterMs + ? Math.ceil(rateLimit.retryAfterMs / 1000) + : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000) + return v2Error('RATE_LIMITED', 'API rate limit exceeded', { + headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() }, + details: { retryAfter: rateLimit.resetAt.toISOString() }, + }) +} + +/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */ +export function encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor>(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T + } catch { + return null + } +} + +/** + * The sort a keyset cursor was minted under, as it is written into the cursor + * payload. Comparing the whole string is what makes a mid-pagination sort + * change detectable. + */ +export function cursorSortKey(sortBy: string, sortOrder: string): string { + return `${sortBy}:${sortOrder}` +} + +interface SortedCursorPayload { + sort: string + keys: CursorKey[] +} + +/** + * A keyset cursor stamped with the sort that produced it. The keys are only + * meaningful under that exact ordering, so the stamp travels with them. + */ +export function encodeSortedCursor(sort: string, keys: CursorKey[]): string { + return encodeCursor({ sort, keys } satisfies SortedCursorPayload) +} + +export type DecodedSortedCursor = + | { status: 'absent' } + | { status: 'ok'; keys: CursorKey[] } + /** Malformed, or minted under a different sort — the page cannot be resumed. */ + | { status: 'invalid' } + +/** + * Reads a keyset cursor back, refusing one that does not belong to the + * requested sort. Resuming a `name`-ordered cursor under `createdAt` would + * compare the wrong column and silently duplicate or skip rows, so a mismatch + * is a client error rather than a best-effort page. A cursor that isn't valid + * base64-JSON is rejected for the same reason: ignoring it would restart from + * page one while the caller believes it is paging forward. + * + * This checks the envelope only. The key VALUES are caller-controlled too, and + * are type-checked against the sort's keys by `keysetAfter`, which is where a + * bad arity or an unparseable timestamp is caught. + */ +export function decodeSortedCursor(cursor: string | undefined, sort: string): DecodedSortedCursor { + if (!cursor) return { status: 'absent' } + const decoded = decodeCursor>(cursor) + if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) { + return { status: 'invalid' } + } + return { status: 'ok', keys: decoded.keys } +} + +/** The 400 for a cursor that cannot be resumed under the request's sort. */ +export function v2CursorSortError(): NextResponse { + return v2Error('BAD_REQUEST', INVALID_CURSOR_MESSAGE) +} + +const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { + validation: 'BAD_REQUEST', + unauthorized: 'UNAUTHORIZED', + forbidden: 'FORBIDDEN', + not_found: 'NOT_FOUND', + conflict: 'CONFLICT', + locked: 'LOCKED', + payload_too_large: 'PAYLOAD_TOO_LARGE', + internal: 'INTERNAL_ERROR', +} + +/** + * Renders a `lib/[resource]/orchestration` failure in the v2 envelope, so every + * v2 route maps a given failure class to the same status without restating the + * mapping. Mirrors `statusForOrchestrationError` for the v1/UI surfaces. + */ +export function v2ErrorForOrchestration( + code: OrchestrationErrorCode | undefined, + message: string, + /** Structured context for the failure — e.g. which lock rejected a write. */ + details?: unknown +): NextResponse { + const v2Code = code ? V2_CODE_BY_ORCHESTRATION_ERROR[code] : 'INTERNAL_ERROR' + return v2Error(v2Code, v2Code === 'INTERNAL_ERROR' ? 'Internal server error' : message, { + ...(details !== undefined ? { details } : {}), + }) +} + +/** + * Renders a thrown domain failure in the v2 envelope, or `null` when the error + * carries no classification and the caller should log it and return its own + * generic 500. The v2 counterpart of `orchestrationErrorResponse`. + */ +export function v2CaughtOrchestrationError(error: unknown): NextResponse | null { + const classified = asOrchestrationError(error) + if (!classified) return null + return v2ErrorForOrchestration(classified.code, classified.message) +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts new file mode 100644 index 00000000000..b9307d353f7 --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,120 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) + + const log = rows[0] + if (!log) return v2Error('NOT_FOUND', 'Log not found') + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') + + const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') + + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + + const detail: V2LogDetail = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName || log.workflowArchivedAt !== null, + }, + executionData, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts new file mode 100644 index 00000000000..5b811960412 --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,79 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ExecutionAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + + const rows = await db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const workflowLog = rows[0] + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') + + const execution: V2Execution = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + }, + } + + return v2Data(execution, { rateLimit }) + } catch (error) { + logger.error('Error fetching execution data', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts new file mode 100644 index 00000000000..ef1eb76e97b --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,197 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveFolderPathId } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folderPaths = params.folderPaths?.split(',').filter(Boolean) + const folderIndex = folderPaths + ? await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + : null + const resolvedFolderIds = folderPaths?.map((path) => resolveFolderPathId(folderIndex!, path)) + if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const nonRootFolderIds = resolvedFolderIds?.filter( + (folderId): folderId is string => typeof folderId === 'string' + ) + const includesRoot = resolvedFolderIds?.includes(null) ?? false + + const filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: nonRootFolderIds, + triggers: params.triggers?.split(',').filter(Boolean), + level: params.level, + startDate: params.startDate ? new Date(params.startDate) : undefined, + endDate: params.endDate ? new Date(params.endDate) : undefined, + executionId: params.executionId, + minDurationMs: params.minDurationMs, + maxDurationMs: params.maxDurationMs, + minCost: params.minCost, + maxCost: params.maxCost, + model: params.model, + cursor: params.cursor + ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined + : undefined, + order: params.order, + } + + const conditions = buildLogFilters(filters) + const rootFolderCondition = folderPaths + ? or( + includesRoot ? isNull(workflow.folderId) : undefined, + nonRootFolderIds && nonRootFolderIds.length > 0 + ? inArray(workflow.folderId, nonRootFolderIds) + : undefined + ) + : undefined + const orderBy = getOrderBy(params.order) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowArchivedAt: workflow.archivedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(and(conditions, rootFolderCondition)) + .orderBy(...orderBy) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const lastLog = data[data.length - 1] + nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) + } + + type LogRow = (typeof data)[number] + const buildItem = (log: LogRow): V2LogListItem => { + const item: V2LogListItem = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + files: (log.files as unknown[] | null) ?? null, + } + if (params.details === 'full') { + item.workflow = { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + deleted: !log.workflowName || log.workflowArchivedAt !== null, + } + } + return item + } + + const needsMaterialize = + params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + + const formattedLogs = needsMaterialize + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + const item = buildItem(log) + if (log.executionData) { + const execData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as Record + if (params.includeFinalOutput && execData.finalOutput) { + item.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + item.traceSpans = execData.traceSpans + } + } + return item + }) + : data.map(buildItem) + + return v2CursorList(formattedLogs, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts new file mode 100644 index 00000000000..1d5b93a53de --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -0,0 +1,350 @@ +/** + * @vitest-environment node + * + * Public v2 MCP server detail: gate ordering, contract validation, workspace + * access, and the thin-wrapper mapping onto `lib/mcp/orchestration`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceMcpServer, + mockPerformUpdateMcpServer, + mockPerformDeleteMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockPerformUpdateMcpServer: vi.fn(), + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performUpdateMcpServer: mockPerformUpdateMcpServer, + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: null, + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: 'encrypted-secret', + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: null, + connectionStatus: 'disconnected', + lastError: null, + statusConfig: {}, + toolCount: 0, + lastToolsRefresh: null, + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'mcp-abc12345' }) }) + +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/mcp-servers/mcp-abc12345?${query}` + +function callGet(query?: string) { + return GET(new NextRequest(url(query)), routeContext()) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/mcp-servers/mcp-abc12345', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +function callDelete(query?: string) { + return DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) +} + +describe('GET /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the server does not exist in the workspace', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public server shape without header values', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: true, + }) + expect(JSON.stringify(body)).not.toContain('super-secret-token') + expect(JSON.stringify(body)).not.toContain('encrypted-secret') + expect(mockGetWorkspaceMcpServer).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + serverId: 'mcp-abc12345', + }) + }) +}) + +describe('PATCH /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body has an unknown field', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', bogus: true }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', url: 'https://{{HOST}}/sse' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformUpdateMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('400s when the url is changed, since the id is derived from it', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://different.example.com/sse', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('url cannot be changed') + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('allows a url that matches the stored one, so a full-object PATCH still works', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://mcp.example.com/sse', + enabled: false, + }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateMcpServer).toHaveBeenCalled() + }) + + it('updates the server and returns the public shape', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer.id).toBe('mcp-abc12345') + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + name: 'Renamed', + enabled: false, + }) + ) + }) +}) + +describe('DELETE /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformDeleteMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callDelete() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('deletes the server and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'mcp-abc12345', deleted: true } }) + expect(mockPerformDeleteMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts new file mode 100644 index 00000000000..22231ef8eba --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -0,0 +1,181 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteMcpServerContract, + v2GetMcpServerContract, + v2UpdateMcpServerContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { getWorkspaceMcpServer } from '@/lib/mcp/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServerDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const server = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!server) return v2Error('NOT_FOUND', 'MCP server not found') + + return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * A server's id is the hash of its workspace + URL, and this surface promises + * that identity. The lib will happily move `url` while the id keeps hashing + * the old one, which both breaks that promise and defeats the duplicate + * check on create (id-keyed, so it would not see the moved URL) — leaving two + * rows on one URL. Re-pointing a server at a different URL is a new server. + */ + if (body.url !== undefined) { + const current = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!current) return v2Error('NOT_FOUND', 'MCP server not found') + if (current.url !== body.url) { + return v2Error( + 'BAD_REQUEST', + 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.' + ) + } + } + + const result = await performUpdateMcpServer({ + workspaceId, + userId, + serverId: id, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.server) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to update server') + } + + return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteMcpServer({ workspaceId, userId, serverId: id, request }) + if (!result.success) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to delete server') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts new file mode 100644 index 00000000000..cb0df3ac683 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -0,0 +1,365 @@ +/** + * @vitest-environment node + * + * Public v2 MCP servers list/create: gate ordering, contract validation, the + * write-only `headers` projection, and the 409-on-duplicate-URL departure from + * the internal upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceMcpServers, + mockGetWorkspaceMcpServer, + mockGetMcpServerIdState, + mockPerformCreateMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceMcpServers: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockGetMcpServerIdState: vi.fn(), + mockPerformCreateMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + listWorkspaceMcpServers: mockListWorkspaceMcpServers, + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, + getMcpServerIdState: mockGetMcpServerIdState, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: mockPerformCreateMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/mcp-servers/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: new Date('2024-01-02T00:00:00Z'), + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 4, + lastToolsRefresh: new Date('2024-01-02T00:00:00Z'), + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/mcp-servers?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/mcp-servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', +} + +describe('GET /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceMcpServers.mockResolvedValue([buildRow()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public server shape in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'mcp-abc12345', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + authType: 'headers', + url: 'https://mcp.example.com/sse', + timeout: 30000, + retries: 3, + enabled: true, + connectionStatus: 'connected', + lastError: null, + toolCount: 4, + lastToolsRefresh: '2024-01-02T00:00:00.000Z', + lastConnected: '2024-01-02T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: false, + }, + ]) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) + }) + + it('never returns configured header values', async () => { + const res = await callList('workspaceId=workspace-1') + const raw = JSON.stringify(await res.json()) + + expect(raw).not.toContain('super-secret-token') + expect(raw).not.toContain('"headers":') + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetMcpServerIdState.mockResolvedValue(null) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: false, + }) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body is missing a required field', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Docs server' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'https://{{MCP_HOST}}/sse' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('{{ENV_VAR}}') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url is not an absolute http(s) URL', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'file:///etc/passwd' }) + expect(res.status).toBe(400) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate URL without letting the lib upsert', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: false }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create made the lib upsert instead of insert', async () => { + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('revives a soft-deleted URL instead of stranding it behind a 409', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: true }) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(201) + expect(mockPerformCreateMcpServer).toHaveBeenCalled() + }) + + it('creates the server and returns 201 with the public shape', async () => { + const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + name: 'Docs server', + hasHeaders: true, + headerNames: ['Authorization'], + }) + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformCreateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer tok' }, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts new file mode 100644 index 00000000000..e9a32f7251f --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateMcpServerContract, + v2ListMcpServersContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateMcpServer } from '@/lib/mcp/orchestration' +import { + getMcpServerIdState, + getWorkspaceMcpServer, + listWorkspaceMcpServers, +} from '@/lib/mcp/queries' +import { generateMcpServerId } from '@/lib/mcp/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListMcpServersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceMcpServers({ workspaceId, search, sortBy, sortOrder }) + + // The per-workspace server set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing MCP servers`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/mcp-servers — Register a new MCP server. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateMcpServerContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * The server id is a deterministic hash of workspace + normalized URL, and + * `performCreateMcpServer` upserts onto it — a second registration of the + * same URL silently overwrites the first. The internal surface and the + * copilot rely on that; a public create must not, so the collision is + * detected here, before the lib is given a chance to clobber the row. + * + * Only a *live* row is a conflict. A soft-deleted one is revived by the lib + * rather than inserted alongside, and reporting it as a duplicate would + * strand that URL for good: the detail routes resolve live rows only, so it + * could be neither fetched, patched, nor re-created. + */ + const serverId = generateMcpServerId(workspaceId, body.url) + const idState = await getMcpServerIdState({ workspaceId, serverId }) + if (idState && !idState.deleted) { + return v2Error( + 'CONFLICT', + 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' + ) + } + const revivingSoftDeleted = idState?.deleted === true + + const result = await performCreateMcpServer({ + workspaceId, + userId, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.serverId) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server') + } + + /** + * `updated` means the lib wrote onto an existing row. Reviving the + * soft-deleted row we already saw is the intended outcome; otherwise a + * concurrent create won the id race between the check above and the write. + */ + if (result.updated && !revivingSoftDeleted) { + return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.') + } + + const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts new file mode 100644 index 00000000000..ba4fec6ee87 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -0,0 +1,50 @@ +import type { NextResponse } from 'next/server' +import { type V2McpServer, v2McpServerSchema } from '@/lib/api/contracts/v2/mcp-servers' +import type { McpServerRow } from '@/lib/mcp/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 MCP server surface. + */ + +/** + * Projects a stored MCP server row onto the public shape. + * + * The row is parsed through {@link v2McpServerSchema}, whose strip behaviour is + * the security boundary: `headers`, `oauthClientSecret`, `statusConfig`, and the + * rest of the row are dropped rather than enumerated by hand, so a column added + * later cannot leak by omission. Header *names* are lifted out explicitly. + */ +export function toV2McpServer(row: McpServerRow): V2McpServer { + const headers = (row.headers ?? {}) as Record + const headerNames = Object.keys(headers) + return v2McpServerSchema.parse({ + ...row, + hasHeaders: headerNames.length > 0, + headerNames, + hasOauthClientSecret: Boolean(row.oauthClientSecret), + }) +} + +/** + * Renders an MCP orchestration failure in the v2 error envelope. + * + * `forbidden` is the domain-allowlist / SSRF rejection and keeps its 403. + * `bad_gateway` is a DNS failure on the caller-supplied hostname — the caller's + * input is at fault, so it surfaces as a 400 rather than implying a Sim outage. + */ +export function v2McpOrchestrationError( + errorCode: string | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'not_found': + return v2Error('NOT_FOUND', 'MCP server not found') + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'bad_gateway': + return v2Error('BAD_REQUEST', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts new file mode 100644 index 00000000000..834191497ff --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -0,0 +1,331 @@ +/** + * @vitest-environment node + * + * Public v2 skill detail: the get-by-id that has no internal equivalent, plus + * the per-id update/delete that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetSkillById, + mockPerformUpdateSkill, + mockPerformDeleteSkill, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetSkillById: vi.fn(), + mockPerformUpdateSkill: vi.fn(), + mockPerformDeleteSkill: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + getSkillById: mockGetSkillById, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performUpdateSkill: mockPerformUpdateSkill, + performDeleteSkill: mockPerformDeleteSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'skl_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/skills/skl_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/skills/skl_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetSkillById.mockResolvedValue(buildSkill()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the skill is not in the workspace', async () => { + mockGetSkillById.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the single skill including its body', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockGetSkillById).toHaveBeenCalledWith({ + skillId: 'skl_abc123', + workspaceId: 'workspace-1', + }) + }) +}) + +describe('PATCH /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateSkill.mockResolvedValue({ + success: true, + skill: buildSkill({ description: 'Updated' }), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('403s when the caller is not a skill editor', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Skill editor access required to modify "refund-policy"', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('400s when the orchestration rejects a built-in skill', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Built-in') + }) + + it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => { + await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('updates the skill and returns the single skill', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.skill.description).toBe('Updated') + expect(Array.isArray(body.data)).toBe(false) + expect(mockPerformUpdateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + description: 'Updated', + source: 'api', + }) + ) + }) +}) + +describe('DELETE /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the skill is a read-only built-in', async () => { + mockPerformDeleteSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + }) + + it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('deletes the skill and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'skl_abc123', deleted: true } }) + expect(mockPerformDeleteSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts new file mode 100644 index 00000000000..2cb7d1f2017 --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteSkillContract, + v2GetSkillContract, + v2UpdateSkillContract, +} from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' +import { getSkillById } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skill = await getSkillById({ skillId: id, workspaceId }) + if (!skill) return v2Error('NOT_FOUND', 'Skill not found') + + return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, content } = parsed.data.body + + /** + * Editing an existing skill is gated per skill, not per workspace: an + * explicit editor grant (or workspace admin) is the authority, and + * `performUpdateSkill` enforces it. Requiring workspace `write` here would + * reject a legitimate skill editor who only holds `read` — stricter than the + * UI and than what this endpoint documents. Creating still needs `write`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: id, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to update skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/skills/[id] — Delete a skill. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + // Gated per skill by `performDeleteSkill`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteSkill({ + workspaceId, + userId, + skillId: id, + source: 'api', + request, + }) + + if (!result.success) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to delete skill') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts new file mode 100644 index 00000000000..8e1c5131c2e --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -0,0 +1,290 @@ +/** + * @vitest-environment node + * + * Public v2 skills list/create: gate ordering, contract validation, and the + * single-resource create that replaced the internal bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListSkills, mockPerformCreateSkill } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListSkills: vi.fn(), + mockPerformCreateSkill: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + listSkills: mockListSkills, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performCreateSkill: mockPerformCreateSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/skills/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/skills?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', +} + +describe('GET /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListSkills.mockResolvedValue([buildSkill()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns summaries without skill bodies in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListSkills).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + search: undefined, + sort: { sortBy: 'createdAt', sortOrder: 'desc' }, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) +}) + +describe('POST /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the body is missing content', async () => { + const res = await callCreate({ + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the name is not kebab-case', async () => { + const res = await callCreate({ ...VALID_BODY, name: 'Refund Policy' }) + expect(res.status).toBe(400) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the orchestration rejects a built-in skill name', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "deploy-workflow" is reserved by a built-in skill', + errorCode: 'validation', + }) + + const res = await callCreate({ ...VALID_BODY, name: 'deploy-workflow' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.code).toBe('BAD_REQUEST') + expect(body.error.message).toContain('built-in') + }) + + it('409s when the skill name is already taken', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "refund-policy" is unavailable in this workspace', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the skill and returns 201 with the single skill, not the workspace list', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformCreateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts new file mode 100644 index 00000000000..1541ca2be7f --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateSkill } from '@/lib/skills/orchestration' +import { listSkills } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/skills — List skills in a workspace, built-ins included. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListSkillsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skills = await listSkills({ workspaceId, search, sort: { sortBy, sortOrder } }) + + // The per-workspace skill set is small and bounded → a single full page. + return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing skills`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/skills — Create a skill. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateSkillContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, content } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateSkill({ + workspaceId, + userId, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to create skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/utils.ts b/apps/sim/app/api/v2/skills/utils.ts new file mode 100644 index 00000000000..a1cc30ceb02 --- /dev/null +++ b/apps/sim/app/api/v2/skills/utils.ts @@ -0,0 +1,48 @@ +import type { skill } from '@sim/db/schema' +import type { NextResponse } from 'next/server' +import type { V2Skill, V2SkillSummary } from '@/lib/api/contracts/v2/skills' +import type { SkillOrchestrationErrorCode } from '@/lib/skills/orchestration' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 skills surface. + */ + +type SkillRow = typeof skill.$inferSelect + +/** List projection — no `content`; skill bodies are fetched per skill. */ +export function toV2SkillSummary(row: SkillRow): V2SkillSummary { + return { + id: row.id, + name: row.name, + description: row.description, + readOnly: isBuiltinSkillId(row.id), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Detail projection — the summary plus the skill body. */ +export function toV2Skill(row: SkillRow): V2Skill { + return { ...toV2SkillSummary(row), content: row.content } +} + +/** Renders a skill orchestration failure in the v2 error envelope. */ +export function v2SkillOrchestrationError( + errorCode: SkillOrchestrationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Skill not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts new file mode 100644 index 00000000000..fed9a372b17 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + * + * Public v2 cancel-runs — stops workflow/enrichment cell runs, as opposed to + * `job/cancel`, which stops an import or delete. The predicate translates to + * storage keys before the cancel so an unknown field 400s rather than becoming + * a cancel that silently matches nothing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockCancelRuns, + mockPredicateToFilter, + mockSignalRowsChanged, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockCancelRuns: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockCancelRuns.mockResolvedValue(4) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { + it('cancels every run under scope "all" and reports the count', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ cancelled: 4 }) + expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, { + filter: undefined, + excludeRowIds: undefined, + }) + // Cancelling clears the affected cells, so open readers must refetch. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('scopes to a single row when asked', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' }) + + expect(res.status).toBe(200) + expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything()) + }) + + it('translates a name-keyed predicate to the storage-keyed filter', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate }) + + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockCancelRuns).toHaveBeenCalledWith( + 'table-1', + undefined, + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of cancelling nothing', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + scope: 'all', + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('400s scope "row" with no rowId', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'row' }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('400s scope "row" combined with a filter', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + scope: 'row', + rowId: 'row-1', + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(403) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(429) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts new file mode 100644 index 00000000000..69fe9094e67 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2BulkPredicateToFilter, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableCancelRunsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. + * + * The counterpart to `POST /columns/run`, and distinct from + * `POST /job/cancel`, which stops an import or delete. `scope: 'all'` cancels + * every running and pending cell (optionally narrowed by `filter`); `row` + * cancels one row's cells. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CancelTableRunsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the runners compile the + // storage-keyed legacy filter. Translating up front makes an unknown field + // a 400 rather than a cancel that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { + filter: legacyFilter, + excludeRowIds, + }) + + // Cancelling clears the affected rows' exec state, so open readers must + // refetch to pick up the cleared cells. + signalTableRowsChanged(tableId) + + logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) + + return v2Data({ cancelled }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error cancelling table runs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts new file mode 100644 index 00000000000..a7d6235dca0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + * + * v2 column update wiring: the route authenticates, scopes, delegates to the + * orchestration function, and maps its failure classes onto the v2 envelope. + * The guards themselves are covered in lib/table/orchestration/columns.test.ts. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformUpdate } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformUpdate: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, +})) + +vi.mock('@/lib/table', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn() })) + +vi.mock('@/lib/table/orchestration', () => ({ + performUpdateTableColumn: mockPerformUpdate, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { PATCH } from '@/app/api/v2/tables/[tableId]/columns/route' + +const COLUMN = { id: 'col-1', name: 'Status', type: 'text' } +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [COLUMN] } } + +function patch(updates: Record = { name: 'State' }) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'ws-1', columnName: 'Status', updates }), + }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('PATCH /api/v2/tables/[tableId]/columns', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockPerformUpdate.mockResolvedValue({ success: true, table: TABLE }) + }) + + it('delegates to the orchestration function with the resolved table and actor', async () => { + const res = await patch() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ columns: [COLUMN] }) + expect(mockPerformUpdate).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, columnName: 'Status', userId: 'user-1' }) + ) + }) + + it.each([ + ['validation', 400, 'BAD_REQUEST'], + ['not_found', 404, 'NOT_FOUND'], + ['locked', 423, 'LOCKED'], + ])('maps a %s failure to %i', async (errorCode, status, code) => { + mockPerformUpdate.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await patch() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) + + it('does not leak an internal failure message', async () => { + mockPerformUpdate.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'connection string leaked', + }) + + const res = await patch() + + expect(res.status).toBe(500) + expect(await res.text()).not.toContain('connection string') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts new file mode 100644 index 00000000000..77a3f1f5e1c --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -0,0 +1,214 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2AddTableColumnContract, + v2DeleteTableColumnContract, + v2UpdateTableColumnContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { addTableColumn, deleteColumn } from '@/lib/table' +import { performUpdateTableColumn } from '@/lib/table/orchestration' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableColumnsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface ColumnsRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ +export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2AddTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error adding column to table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { + return v2TableOrchestrationError(outcome, 'Failed to update column') + } + + return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Error updating column in table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteColumn( + { tableId, columnName: validated.columnName }, + requestId + ) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Deleted column "${validated.columnName}" from table "${table.name}"`, + metadata: { columnName: validated.columnName }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error deleting column from table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts new file mode 100644 index 00000000000..e3dc1b23c0b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + * + * Public v2 column run. The public predicate is column-NAME keyed and the + * dispatcher compiles a storage-keyed legacy filter, so the route translates + * before dispatching — an unknown field must 400 here rather than becoming a + * run that silently matches nothing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockPredicateToFilter, + mockSignalRowsChanged, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/columns/run', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('dispatches the run and returns the dispatch id', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + filter: undefined, + triggeredByUserId: 'user-1', + }) + ) + // The bulk clear is a row change even when the dispatch is a no-op. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('Unknown column "nope"') + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s rowIds and filter together', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s an empty groupIds list', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: [] }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..f534f57f5fd --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + v2BulkPredicateToFilter, + v2TableAccessError, + v2TableLockError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRunColumnAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. + * + * Asynchronous: the response acknowledges the dispatch, not the results. The + * dispatcher walks the scoped rows and writes cells as runs land, so callers + * poll the row endpoints. `dispatchId` is `null` where no background runner is + * configured and cells execute inline. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the dispatcher compiles the + // storage-keyed legacy filter. Translating up front also makes an unknown + // field a 400 here rather than a dispatch that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + filter: legacyFilter, + excludeRowIds, + limit, + requestId, + triggeredByUserId: userId, + }) + + // Starting a run clears the target groups' cells to pending — a row change + // open readers must pick up. + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running table columns`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts new file mode 100644 index 00000000000..63bf970f013 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -0,0 +1,67 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableExportResource, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportsAPI') + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId, format } = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const access = await checkAccess(parsed.data.params.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + const record = await createTableExportResource({ table: access.table, format }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: access.table.id, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount }, + request, + }) + return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts new file mode 100644 index 00000000000..0a847cabcac --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -0,0 +1,437 @@ +/** + * @vitest-environment node + * + * Public v2 workflow-group listing — a read-only projection of the table's + * schema, exposed so a caller can discover the group ids the run endpoints + * take. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGateError, + mockAddWorkflowGroup, + mockUpdateWorkflowGroup, + mockDeleteWorkflowGroup, + mockGetActiveWorkflowContext, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGateError: vi.fn(), + mockAddWorkflowGroup: vi.fn(), + mockUpdateWorkflowGroup: vi.fn(), + mockDeleteWorkflowGroup: vi.fn(), + mockGetActiveWorkflowContext: vi.fn(), + mockSignalSchemaChanged: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table/workflow-groups/service', () => ({ + addWorkflowGroup: mockAddWorkflowGroup, + updateWorkflowGroup: mockUpdateWorkflowGroup, + deleteWorkflowGroup: mockDeleteWorkflowGroup, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowContext: mockGetActiveWorkflowContext, +})) + +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/groups/route' + +const GROUP = { + id: 'group-1', + workflowId: 'wf-1', + name: 'Enrich', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], +} +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [], workflowGroups: [GROUP] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('GET /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + }) + + it('returns the schema groups as one full page', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null }) + }) + + it('returns an empty page for a table with no groups', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } }) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [], nextCursor: null }) + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + }) + + it('400s a request with no workspaceId', async () => { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method: 'GET', + }) + const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(res.status).toBe(400) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) +}) + +const ADD_BODY = { + workspaceId: 'ws-1', + group: { + workflowId: 'wf-1', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], + }, + outputColumns: [{ name: 'summary', type: 'string' }], +} + +const UPDATED_TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ name: 'summary', type: 'string' }], workflowGroups: [GROUP] }, +} + +function callWrite(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const handler = method === 'POST' ? POST : method === 'PATCH' ? PATCH : DELETE + return handler(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) + // Echo back the id the route generated, as the real service does. + mockAddWorkflowGroup.mockImplementation(async (data: { group: { id: string } }) => ({ + ...UPDATED_TABLE, + schema: { + ...UPDATED_TABLE.schema, + workflowGroups: [{ ...GROUP, id: data.group.id }], + }, + })) + }) + + it('creates the group and its columns, returning both', async () => { + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.group).toMatchObject({ workflowId: 'wf-1', name: 'Enrich' }) + expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('500s rather than emitting a body without the group it claims to have written', async () => { + // Write reports success but the group is absent — an internal inconsistency + // must not surface as a 200 with `group: undefined`. + mockAddWorkflowGroup.mockResolvedValue({ + ...UPDATED_TABLE, + schema: { columns: [], workflowGroups: [] }, + }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(500) + expect((await res.json()).error.code).toBe('INTERNAL_ERROR') + }) + + it('server-generates the group id and stamps it onto the output columns', async () => { + await callWrite('POST', ADD_BODY) + + const call = mockAddWorkflowGroup.mock.calls[0][0] + expect(call.group.id).toEqual(expect.any(String)) + expect(call.group.id).not.toBe('') + // The caller never supplies workflowGroupId — it is derived from the group. + expect(call.outputColumns[0].workflowGroupId).toBe(call.group.id) + }) + + it('defaults autoRun to false so one POST cannot fan out a metered backfill', async () => { + await callWrite('POST', ADD_BODY) + expect(mockAddWorkflowGroup.mock.calls[0][0].autoRun).toBe(false) + }) + + it('rejects a workflow from another workspace before persisting it', async () => { + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Workflow not found') + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('rejects an output column that no group output feeds', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + outputColumns: [{ name: 'summry', type: 'string' }], + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('summry') + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s an enrichment group with no enrichmentId', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + group: { ...ADD_BODY.group, workflowId: '', type: 'enrichment' }, + }) + + expect(res.status).toBe(400) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s a workflow group with no workflowId', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + group: { ...ADD_BODY.group, workflowId: '' }, + }) + + expect(res.status).toBe(400) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(404) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(404) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, retryAfterMs: 1000 }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(429) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('surfaces a duplicate-column failure as 400, not 500', async () => { + mockAddWorkflowGroup.mockRejectedValue(new Error('Column "summary" already exists')) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('already exists') + }) +}) + +describe('PATCH /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) + mockUpdateWorkflowGroup.mockResolvedValue(UPDATED_TABLE) + }) + + it('updates the group and returns it with the resulting columns', async () => { + const res = await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + name: 'Renamed', + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.group).toEqual(GROUP) + expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) + expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', groupId: 'group-1', name: 'Renamed' }), + expect.any(String) + ) + }) + + it('re-checks workspace containment when the group is re-pointed', async () => { + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) + + const res = await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + workflowId: 'wf-elsewhere', + }) + + expect(res.status).toBe(400) + expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() + }) + + it('stamps the group id onto any newly added output columns', async () => { + await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + newOutputColumns: [{ name: 'score', type: 'number' }], + }) + + expect(mockUpdateWorkflowGroup.mock.calls[0][0].newOutputColumns[0].workflowGroupId).toBe( + 'group-1' + ) + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(404) + expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() + }) + + it('404s an unknown group rather than reporting a generic failure', async () => { + mockUpdateWorkflowGroup.mockRejectedValue(new Error('Workflow group not found')) + + const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'nope' }) + + expect(res.status).toBe(404) + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + mockDeleteWorkflowGroup.mockResolvedValue({ + ...UPDATED_TABLE, + schema: { columns: [], workflowGroups: [] }, + }) + }) + + it('deletes the group and reports the surviving columns', async () => { + const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(200) + // The group's columns go with it — the caller sees what is left, not a bare ack. + expect(await res.json()).toEqual({ data: { id: 'group-1', deleted: true, columns: [] } }) + expect(mockDeleteWorkflowGroup).toHaveBeenCalledWith( + { tableId: 'table-1', groupId: 'group-1' }, + expect.any(String) + ) + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(404) + expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s a body with no groupId', async () => { + const res = await callWrite('DELETE', { workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts new file mode 100644 index 00000000000..2d96bf9149a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -0,0 +1,367 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + v2AddWorkflowGroupContract, + v2DeleteWorkflowGroupContract, + v2ListWorkflowGroupsContract, + v2UpdateWorkflowGroupContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableDefinition, TableSchema } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + addWorkflowGroup, + deleteWorkflowGroup, + updateWorkflowGroup, +} from '@/lib/table/workflow-groups/service' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableGroupsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. + * + * Read-only: groups are authored in the workflow builder, and the public + * surface exposes them so a caller can discover the `groupIds` the run + * endpoints take. Groups live on the table's schema, so this is a projection of + * the already-loaded definition rather than a second query, and the set is + * bounded per table — one full page, `nextCursor` always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const groups = (result.table.schema as TableSchema).workflowGroups ?? [] + + return v2CursorList(groups, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow groups`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * Renders a group-service failure in the v2 envelope. The service signals + * through thrown `Error` messages rather than classified codes, so the string + * matching mirrors the first-party mapper — the two surfaces must agree on + * which failures are the caller's fault. + */ +function groupMutationError(error: unknown, requestId: string, fallback: string) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + if (error instanceof Error) { + const message = error.message + if (message === 'Table not found' || message.includes('not found')) { + return v2Error('NOT_FOUND', message) + } + if ( + message.includes('Schema validation') || + message.includes('Missing column definition') || + message.includes('already exists') || + message.includes('exceed') + ) { + return v2Error('BAD_REQUEST', message) + } + } + + logger.error(`[${requestId}] ${fallback}`, { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') +} + +/** + * A group persists a `workflowId` that its runs later execute. Without this the + * table becomes a way to invoke workflows the API key cannot otherwise reach, + * so containment is asserted before the id is stored — on create and on any + * update that re-points the group. + */ +async function assertWorkflowInWorkspace(workflowId: string, workspaceId: string) { + const context = await getActiveWorkflowContext(workflowId) + if (!context || context.workspaceId !== workspaceId) { + return v2Error('BAD_REQUEST', 'Workflow not found in this workspace') + } + return null +} + +/** + * `{ group, columns }` for the group a mutation touched. + * + * Throws when the write reports success but the group is absent from the + * returned schema. The contract declares `group` as present, so emitting + * `undefined` there would ship a body no client can parse while reporting 200 — + * an internal inconsistency is worth a 500, not a malformed success. + */ +function groupResponse(table: TableDefinition, groupId: string) { + const schema = table.schema as TableSchema + const group = (schema.workflowGroups ?? []).find((candidate) => candidate.id === groupId) + if (!group) { + throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) + } + return { group, columns: schema.columns.map(normalizeColumn) } +} + +/** + * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the + * table and create the columns its runs populate, in one call. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2AddWorkflowGroupContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.group.workflowId) { + const workflowError = await assertWorkflowInWorkspace( + validated.group.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + /** + * `outputs` and `outputColumns` are two arrays joined by column name, so a + * typo in either silently creates a column nothing feeds. The first-party + * client builds both from one picker and can't desync; a public caller can, + * so the mismatch is rejected rather than persisted. + */ + const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) + const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + return v2Error( + 'BAD_REQUEST', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const groupId = validated.group.id ?? generateId() + + const updatedTable = await addWorkflowGroup( + { + tableId, + group: { ...validated.group, id: groupId }, + // Stamped from the resolved group rather than trusted from the caller. + outputColumns: validated.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: validated.autoRun, + actorUserId: userId, + }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to add workflow group') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/groups — Restructure a group: re-point it, + * add or remove outputs, or change how its runs are scheduled. + * + * Removing an output **deletes that column and its values** — the same + * behavior as `DELETE /columns` on a bound column. There is no detach. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateWorkflowGroupContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.workflowId !== undefined) { + const workflowError = await assertWorkflowInWorkspace( + validated.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + actorUserId: userId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { + newOutputColumns: validated.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: validated.groupId, + })), + } + : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.inputMappings !== undefined + ? { inputMappings: validated.inputMappings } + : {}), + ...(validated.deploymentMode !== undefined + ? { deploymentMode: validated.deploymentMode } + : {}), + ...(validated.type !== undefined ? { type: validated.type } : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), + }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to update workflow group') + } +}) + +/** + * DELETE /api/v2/tables/[tableId]/groups — Remove a group **and every column it + * fed**, along with their values. The surviving column list comes back so a + * caller does not have to re-read the table to see what is left. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteWorkflowGroupContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data( + { + id: validated.groupId, + deleted: true as const, + columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), + }, + { rateLimit } + ) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to delete workflow group') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index 37d8e774d8b..902b8b3bac3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -2,44 +2,40 @@ * @vitest-environment node * * Public v2 query POST: typed predicate name→id translation, bounded-default vs - * explicit-unbounded limit, cursor validation, workspace scoping, - * and name-keyed row output. + * explicit-unbounded limit, cursor validation, workspace scoping, and + * name-keyed row output in the `{ data, nextCursor }` envelope. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' -const { mockCheckAccess, mockQueryRows, mockCheckRateLimit, mockCheckWorkspaceScope, mockGate } = - vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockQueryRows: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockCheckWorkspaceScope: vi.fn(), - mockGate: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', async () => { - const { NextResponse } = await import('next/server') - return { - checkRateLimit: mockCheckRateLimit, - checkWorkspaceScope: mockCheckWorkspaceScope, - createRateLimitResponse: (r: { error?: string }) => - NextResponse.json( - { error: r.error ?? 'Rate limit exceeded' }, - { status: r.error ? 401 : 429 } - ), - } -}) +const { + mockCheckAccess, + mockQueryRows, + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockIsFeatureEnabled, + mockGetWorkspaceOrganizationId, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockQueryRows: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockGetWorkspaceOrganizationId: vi.fn(), +})) -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => - NextResponse.json({ error: 'Access denied' }, { status: result.status }), - tablesV2GateError: mockGate, - } -}) +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) vi.mock('@/lib/table', async () => { const columnKeys = await import('@/lib/table/column-keys') @@ -48,9 +44,32 @@ vi.mock('@/lib/table', async () => { vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, +})) + import { encodeCursor } from '@/lib/table/rows/cursor' + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { POST } from '@/app/api/v2/tables/[tableId]/query/route' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + function buildTable(): TableDefinition { return { id: 'tbl_1', @@ -95,50 +114,24 @@ function callQuery(body: Record) { describe('POST /api/v2/tables/[tableId]/query', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', - }) - mockCheckWorkspaceScope.mockResolvedValue(null) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) mockQueryRows.mockResolvedValue(EMPTY_RESULT) - mockGate.mockResolvedValue(null) + mockIsFeatureEnabled.mockResolvedValue(true) + mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - const { NextResponse } = await import('next/server') - mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(404) - expect(mockQueryRows).not.toHaveBeenCalled() - }) + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(403) - expect(mockGate).not.toHaveBeenCalled() - }) - it('translates a name-keyed predicate to storage ids', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { - all: [ - { field: 'status', op: 'eq', value: 'active' }, - { field: 'wins', op: 'gte', value: 10 }, - ], - }, - }) - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [ - { field: 'col_status', op: 'eq', value: 'active' }, - { field: 'col_wins', op: 'gte', value: 10 }, - ], - }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockQueryRows).not.toHaveBeenCalled() }) it('applies the bounded default limit when omitted', async () => { @@ -174,7 +167,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => { predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, }) expect(res.status).toBe(400) - expect((await res.json()).error).toMatch(/Unknown filter column/) + expect((await res.json()).error.message).toMatch(/Unknown filter column/) }) it('rejects a keyset cursor combined with a custom sort', async () => { @@ -189,7 +182,7 @@ describe('POST /api/v2/tables/[tableId]/query', () => { cursor, }) expect(res.status).toBe(400) - expect((await res.json()).code).toBe('CURSOR_SORT_CONFLICT') + expect((await res.json()).error.details.code).toBe('CURSOR_SORT_CONFLICT') }) it('returns 400 INVALID_CURSOR for a malformed cursor', async () => { @@ -198,23 +191,28 @@ describe('POST /api/v2/tables/[tableId]/query', () => { cursor: Buffer.from('42').toString('base64url'), }) expect(res.status).toBe(400) - expect((await res.json()).code).toBe('INVALID_CURSOR') + expect((await res.json()).error.details.code).toBe('INVALID_CURSOR') }) - it('surfaces a workspace-scope 403 from the middleware', async () => { - const { NextResponse } = await import('next/server') - mockCheckWorkspaceScope.mockResolvedValue( - NextResponse.json({ error: 'not authorized' }, { status: 403 }) - ) + it('surfaces a workspace-scope 403 in the v2 error envelope', async () => { + mockResolveWorkspaceScope.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + }) const res = await callQuery({ workspaceId: 'workspace-1' }) expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') expect(mockQueryRows).not.toHaveBeenCalled() }) - it('rejects a workspace-id mismatch against the table', async () => { + it('masks a workspace-id mismatch against the table as 404', async () => { const res = await callQuery({ workspaceId: 'other-ws' }) - expect(res.status).toBe(400) - expect((await res.json()).error).toBe('Invalid workspace ID') + expect(res.status).toBe(404) + expect((await res.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Table not found', + }) }) it('returns name-keyed row data with no storage internals and a private cache header', async () => { @@ -238,7 +236,9 @@ describe('POST /api/v2/tables/[tableId]/query', () => { }) const res = await callQuery({ workspaceId: 'workspace-1' }) expect(res.headers.get('Cache-Control')).toBe('private, no-store') - expect((await res.json()).data.rows[0]).toEqual({ + const body = await res.json() + expect(body.nextCursor).toBeNull() + expect(body.data[0]).toEqual({ id: 'r1', data: { status: 'active', wins: 12 }, createdAt: '2024-02-02T00:00:00.000Z', @@ -247,7 +247,13 @@ describe('POST /api/v2/tables/[tableId]/query', () => { }) it('returns the rate-limit response when the limiter denies the request', async () => { - mockCheckRateLimit.mockResolvedValue({ allowed: false }) + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) const res = await callQuery({ workspaceId: 'workspace-1' }) expect(res.status).toBe(429) expect(mockCheckAccess).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 4337c0b402c..495c8aeff48 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Sort, TablePredicate, TableSchema } from '@/lib/table' @@ -13,21 +14,23 @@ import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/v import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' -import { accessError, checkAccess, tablesV2GateError } from '@/app/api/table/utils' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - checkRateLimit, - checkWorkspaceScope, - createRateLimitResponse, -} from '@/app/api/v1/middleware' + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableQueryAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Filters may carry user data; keep query responses out of shared caches. */ -const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const - interface QueryRouteParams { params: Promise<{ tableId: string }> } @@ -41,34 +44,35 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query const requestId = generateRequestId() try { - const rateLimit = await checkRateLimit(request, 'v2-table-rows') - if (!rateLimit.allowed) return createRateLimitResponse(rateLimit) + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2QueryRowsContract, request, context, { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, + validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params const { workspaceId, sort, cursor: cursorToken, limit } = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) const accessResult = await checkAccess(tableId, userId, 'read') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + const { table } = accessResult if (workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + return v2Error('NOT_FOUND', 'Table not found') } - // After authz: the gate reads the workspace's org off the primary DB, and its - // 404 would otherwise distinguish "not in the rollout cohort" from "no access". - const gateError = await tablesV2GateError(userId, workspaceId) - if (gateError) return gateError - const schema = table.schema as TableSchema const cursor = cursorToken ? decodeCursor(cursorToken) : undefined @@ -108,44 +112,29 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query limit: effectiveLimit, after: cursor?.after, offset: cursor?.offset, - includeTotal: !cursorToken, + includeTotal: false, withExecutions: false, }, requestId ) - return NextResponse.json( - { - success: true, - data: { - rows: result.rows.map((r) => ({ - id: r.id, - data: toNamedRow(r.data), - createdAt: - r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: - r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: result.rowCount, - totalCount: result.totalCount, - limit: result.limit, - nextCursor: result.nextCursor, - }, - }, - { headers: PRIVATE_NO_STORE } + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + result.nextCursor, + { rateLimit } ) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) - if (validationResponse) return validationResponse + if (isZodError(error)) return v2ValidationError(error) if (error instanceof TableQueryValidationError) { - return NextResponse.json( - { error: error.message, ...(error.code ? { code: error.code } : {}) }, - { status: 400, headers: PRIVATE_NO_STORE } - ) + return v2Error('BAD_REQUEST', error.message, { + details: error.code ? { code: error.code } : undefined, + }) } - logger.error(`[${requestId}] Error querying rows (v2 public):`, error) - return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + logger.error(`[${requestId}] Error querying rows (v2 public)`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') } }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts new file mode 100644 index 00000000000..866165ddb60 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -0,0 +1,451 @@ +/** + * @vitest-environment node + * + * Public v2 table delete and update. Delete hands the actor to the service so + * the audit is emitted there — and only for a delete that actually archived a + * row. Update routes each field to its own orchestration call; lock flags are + * read-only on this surface and a request carrying them is refused outright. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockPerformDeleteTable, + mockPerformRenameTable, + mockPerformMoveTableToFolder, + mockPerformUpdateTableLocks, + mockRecordAudit, + mockGetTableById, + mockLoadActiveFolderPathIndex, + mockGateError, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformDeleteTable: vi.fn(), + mockPerformRenameTable: vi.fn(), + mockPerformMoveTableToFolder: vi.fn(), + mockPerformUpdateTableLocks: vi.fn(), + mockRecordAudit: vi.fn(), + mockGetTableById: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockGateError: vi.fn(), + mockSignalSchemaChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + updateTable: vi.fn(), + getTableById: mockGetTableById, + updateRow: vi.fn(), + rowDataNameToId: vi.fn(), + buildIdByName: vi.fn(), +})) + +vi.mock('@/lib/table/events', () => ({ + signalTableSchemaChanged: mockSignalSchemaChanged, +})) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), +})) + +vi.mock('@/lib/table/orchestration', () => ({ + performDeleteTable: mockPerformDeleteTable, + performRenameTable: mockPerformRenameTable, + performMoveTableToFolder: mockPerformMoveTableToFolder, + performUpdateTableLocks: mockPerformUpdateTableLocks, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const TABLE = { + id: 'table-1', + name: 'Tasks', + workspaceId: 'ws-1', + schema: { columns: [] }, + locks: UNLOCKED, +} +const UPDATED_TABLE = { + ...TABLE, + name: 'Renamed', + description: null, + rowCount: 0, + maxRows: 1000, + folderId: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callDelete() { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', { + method: 'DELETE', + }) + return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +function callPatch(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableById.mockResolvedValue(UPDATED_TABLE) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', { id: 'folder-1', name: 'Reports', parentId: null }]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + mockGateError.mockResolvedValue(null) +}) + +describe('DELETE /api/v2/tables/[tableId]', () => { + it('delegates to the orchestration function with the resolved table and actor', async () => { + mockPerformDeleteTable.mockResolvedValue({ success: true }) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect(mockPerformDeleteTable).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, userId: 'user-1' }) + ) + // The route no longer audits: doing so out here fired TABLE_DELETED even + // when the delete was a no-op on an already-archived table. + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('returns 423 LOCKED for a delete-locked table instead of a 500', async () => { + mockPerformDeleteTable.mockResolvedValue({ + success: false, + errorCode: 'locked', + error: 'Table is locked', + }) + + const res = await callDelete() + + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + }) +}) + +describe('PATCH /api/v2/tables/[tableId]', () => { + it('renames through the orchestration function and returns the re-read table', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'Renamed', + description: null, + schema: { columns: [] }, + rowCount: 0, + maxRows: 1000, + folderPath: '/', + locks: UNLOCKED, + job: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformRenameTable).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, newName: 'Renamed', userId: 'user-1' }) + ) + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('surfaces a running import so an async job is observable, not just startable', async () => { + // `POST /import-async` and `POST /job/cancel` let a caller start and stop an + // import; without this the table never reports that it is running, so there + // is nothing to poll between the two. + mockGetTableById.mockResolvedValue({ + ...UPDATED_TABLE, + jobStatus: 'running', + jobId: 'job-1', + jobType: 'import', + jobRowsProcessed: 250, + jobError: null, + }) + mockPerformRenameTable.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect((await res.json()).data.table.job).toEqual({ + id: 'job-1', + type: 'import', + status: 'running', + rowsProcessed: 250, + error: null, + }) + }) + + it('moves the table only after confirming the folder belongs to the workspace', async () => { + mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Reports' }) + + expect(res.status).toBe(200) + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith('ws-1', 'table', expect.any(Object)) + expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) + ) + }) + + it('404s a folder from outside the workspace without attempting the move', async () => { + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Elsewhere' }) + + expect(res.status).toBe(404) + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('rejects a bad folder without applying the rename that came with it', async () => { + // The three operations are separate transactions, so validation has to run + // before the first write — otherwise a rejected PATCH still renames. + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + folderPath: '/Elsewhere', + }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + expect(mockSignalSchemaChanged).not.toHaveBeenCalled() + }) + + it('reports which operations landed when a later one fails', async () => { + // The three writes commit independently, so rather than pretending + // atomicity the error states what is already live — a caller can reconcile + // instead of re-reading and diffing. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits the applied list when the very first operation fails', async () => { + // `details.applied` present must always mean "these changes are live". + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'taken', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.details).toBeUndefined() + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('still signals collaborators when a later operation fails after an earlier one landed', async () => { + // A mid-write fault can't be rolled back across three transactions, so the + // clients must at least be told to refetch what did apply. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + /** + * Locks are read-only on the public API. A `write`-level API key can already + * mutate the table, so letting it clear a lock would let it undo the guard + * placed there to stop it. The strict body rejects the field outright rather + * than dropping it silently, which would report success for a change that + * never happened. + */ + it('rejects a lock change instead of applying or silently ignoring it', async () => { + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('BAD_REQUEST') + expect(JSON.stringify(body.error)).toContain('locks') + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('rejects a lock change even when paired with an otherwise valid rename', async () => { + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + locks: { deleteLocked: false }, + }) + + expect(res.status).toBe(400) + // The whole request is refused — the rename must not land either. + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + /** + * The re-read runs after the writes have committed, so a failure there must + * still name what landed. Reporting a bare 500 tells the caller nothing took + * effect and it retries into a duplicate-name conflict. + */ + it('reports the applied operations when the final re-read throws', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockGetTableById.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(500) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('reports the applied operations when the re-read finds the table archived', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockGetTableById.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits applied details when the failure happened before any write', async () => { + mockGetTableById.mockRejectedValue(new Error('connection reset')) + + mockLoadActiveFolderPathIndex.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Nope' }) + + // Absence is meaningful: nothing is live, so a retry is safe. + expect((await res.json()).error.details).toBeUndefined() + }) + + it('still reports the stored lock flags on the table it returns', async () => { + // The response is a re-read, so the locked state has to come from there. + mockGetTableById.mockResolvedValue({ + ...UPDATED_TABLE, + locks: { ...UNLOCKED, deleteLocked: true }, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(200) + expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true }) + }) + + it('maps a duplicate-name rename to 409 CONFLICT', async () => { + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A table named "Renamed" already exists', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('rejects a body with nothing to change', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without writing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(429) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts new file mode 100644 index 00000000000..b595920697d --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -0,0 +1,284 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableContract, + v2GetTableContract, + v2UpdateTableContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { getTableById } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, +} from '@/lib/table/orchestration' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' +import { + toApiTable, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableDetailAPI') + +/** + * `details` payload naming the operations of a composite write that committed, + * or `undefined` when none did — so `details.applied` being present always + * means "these changes are live despite the error". + */ +function appliedDetails( + applied: readonly ('name' | 'folderPath')[] +): { applied: readonly string[] } | undefined { + return applied.length > 0 ? { applied } : undefined +} + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** GET /api/v2/tables/[tableId] — Get table details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data( + { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error getting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/tables/[tableId] — Rename and/or move a table. + * + * Each field routes to its own orchestration call so the audit records the + * operation the caller actually performed. + * + * Lock flags are **not** settable here. They are readable on the table resource + * and enforced on every write, but an API key that can mutate a table must not + * also be able to clear the lock placed there to stop it; changing a lock stays + * a first-party admin action. The contract body is `.strict()`, so a request + * carrying `locks` is rejected rather than silently ignored. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + /** + * Hoisted above the `try` so every exit path can report it. Once a write has + * committed, the response must say so even when the failure came *after* the + * writes — a throw in the final re-read, or the re-read finding the table + * archived. Reporting a bare 500 there tells the caller nothing landed, and + * it retries into a duplicate-name conflict or a repeated move. + */ + const applied: ('name' | 'folderPath')[] = [] + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const resolution = + validated.folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: table.workspaceId, + resourceType: 'table', + path: validated.folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } + + if (!failure && validated.folderPath !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: resolution?.folderId ?? null, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderPath') + } else { + failure = { + outcome: + outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, + fallback: 'Failed to move table', + } + } + } + + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } + + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } + + const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') + return v2Data( + { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, + { rateLimit } + ) + } catch (error) { + const details = appliedDetails(applied) + + const lockError = v2TableLockError(error, details) + if (lockError) return lockError + + const classified = asOrchestrationError(error) + if (classified) { + return v2TableOrchestrationError( + { errorCode: classified.code, error: classified.message }, + 'Failed to update table', + details + ) + } + + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + applied, + }) + return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return v2TableOrchestrationError(outcome, 'Failed to delete table') + } + + return v2Data({ id: tableId }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + logger.error(`[${requestId}] Error deleting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts new file mode 100644 index 00000000000..cc1566ce848 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + * + * Public v2 per-row enrichment run — the single-cell case of the column run. + * Naming a specific cell is an explicit re-run, so it dispatches in `all` mode + * and recomputes an already-populated cell. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockSignalRowsChanged, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/rows/row-1/enrichment/group-1', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) + return POST(req, { + params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), + }) +} + +describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('scopes the dispatch to the one row and group in the path', async () => { + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + triggeredByUserId: 'user-1', + }) + ) + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('reports a null dispatch id verbatim rather than inventing one', async () => { + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: null }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: null }) + }) + + it('404s a table in another workspace without dispatching', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s a body with no workspace', async () => { + const res = await callPost({}) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts new file mode 100644 index 00000000000..9f3e7a27b69 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowEnrichmentAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowEnrichmentRouteParams { + params: Promise<{ tableId: string; rowId: string; groupId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] + * + * The single-cell case of `POST /columns/run`: runs one group for one row. + * `mode: 'all'` because naming a specific cell is an explicit re-run request — + * an already-populated cell must recompute rather than be skipped. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: RowEnrichmentRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunRowEnrichmentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId, groupId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds: [groupId], + rowIds: [rowId], + mode: 'all', + requestId, + triggeredByUserId: userId, + }) + + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running row enrichment`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts new file mode 100644 index 00000000000..626dd3ba567 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + * + * Public v2 single-row delete: goes through the row service so the delete lock + * and row-count bookkeeping are enforced, and renders lock/not-found in the v2 + * error envelope. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteRow } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformDeleteRow: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + updateTable: vi.fn(), + getTableById: vi.fn(), + updateRow: vi.fn(), + rowDataNameToId: vi.fn(), + buildIdByName: vi.fn(), +})) + +vi.mock('@/lib/table/orchestration', () => ({ performDeleteTableRow: mockPerformDeleteRow })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +function callDelete() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/rows/row-1?workspaceId=ws-1', + { method: 'DELETE' } + ) + return DELETE(req, { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) }) +} + +describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + }) + + it('delegates to the orchestration function rather than deleting inline', async () => { + mockPerformDeleteRow.mockResolvedValue({ success: true }) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ deletedCount: 1, deletedRowIds: ['row-1'] }) + // The orchestration function routes through the row service, which applies + // the delete lock and the row-count decrement; the raw delete this replaced + // skipped both. + expect(mockPerformDeleteRow).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, rowId: 'row-1' }) + ) + }) + + it.each([ + ['locked', 423, 'LOCKED'], + ['not_found', 404, 'NOT_FOUND'], + ])('maps a %s failure to %i', async (errorCode, status, code) => { + mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await callDelete() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) + + it('names the lock on a 423 that arrived as a classified outcome, not a throw', async () => { + mockPerformDeleteRow.mockResolvedValue({ + success: false, + errorCode: 'locked', + error: 'Row deletes are locked for this table', + lock: 'delete', + }) + + const res = await callDelete() + + expect(res.status).toBe(423) + expect((await res.json()).error.details).toEqual({ lock: 'delete' }) + }) + + it('omits details entirely when the lock kind is unknown', async () => { + // A caller branching on `details.lock` should see absence, not a null. + mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode: 'locked', error: 'nope' }) + + const res = await callDelete() + + expect((await res.json()).error.details).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts new file mode 100644 index 00000000000..026348e69f9 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -0,0 +1,231 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableRowContract, + v2GetTableRowContract, + v2UpdateTableRowContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { performDeleteTableRow } from '@/lib/table/orchestration' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + toApiRow, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowRouteParams { + params: Promise<{ tableId: string; rowId: string }> +} + +/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const [row] = await db + .select({ + id: userTableRows.id, + data: userTableRows.data, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (!row) return v2Error('NOT_FOUND', 'Row not found') + + const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) + return v2Data( + { + row: toApiRow( + { + id: row.id, + data: row.data as RowData, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + toNamedRow + ), + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error getting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const updatedRow = await updateRow( + { + tableId, + rowId, + data: rowDataNameToId(validated.data as RowData, idByName), + workspaceId: validated.workspaceId, + actorUserId: userId, + }, + table, + requestId + ) + // No `cancellationGuard` is passed, so `updateRow` can't return null here. + // Defensive narrowing for TypeScript. + if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') + + return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error updating row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return v2TableOrchestrationError(outcome, 'Failed to delete row') + } + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error(`[${requestId}] Error deleting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts new file mode 100644 index 00000000000..19f38dfb59f --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + * + * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate + * and sort translate down to storage ids on the way in, and the matched column + * id translates back to its name on the way out. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockFindRowMatches, + mockPredicateToFilter, + mockValidateSortSpec, + mockSortSpecNamesToIds, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockFindRowMatches: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockValidateSortSpec: vi.fn(), + mockSortSpecNamesToIds: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table', () => ({ + buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }), + sortSpecNamesToIds: mockSortSpecNamesToIds, +})) +vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches })) +vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' + +const COLUMNS = [ + { id: 'col-1', name: 'status', type: 'string' }, + { id: 'col-2', name: 'name', type: 'string' }, +] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/rows/find', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockFindRowMatches.mockResolvedValue({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }], + truncated: false, + }) + mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) => + spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field })) + ) + mockGateError.mockResolvedValue(null) + }) + + it('reports the matched column by NAME, not its storage id', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], + truncated: false, + }) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: undefined, sort: undefined }, + expect.any(String) + ) + }) + + it('translates the predicate and sort to storage keys before searching', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate, + sort: [{ field: 'name', direction: 'asc' }], + }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockValidateSortSpec).toHaveBeenCalledWith( + [{ field: 'name', direction: 'asc' }], + COLUMNS + ) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } }, + expect.any(String) + ) + }) + + it('surfaces truncation so a caller narrows instead of paging', async () => { + mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'a' }) + + expect((await res.json()).data).toEqual({ matches: [], truncated: true }) + }) + + it('400s an unresolvable predicate field instead of returning zero matches', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('400s an empty search string', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: '' }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(429) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts new file mode 100644 index 00000000000..68d86f3dea5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, Sort, TableSchema } from '@/lib/table' +import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { validateSortSpec } from '@/lib/table/query-builder/validate' +import { findRowMatches } from '@/lib/table/rows/service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsFindAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search + * across every cell, narrowed by the same predicate/sort grammar as + * `POST /query`. + * + * Returns matching CELLS, not rows: each match carries the row's ordinal in the + * same filtered+sorted view a `POST /query` with these arguments would return, + * so a caller can jump straight to the page holding it. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows-find') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2FindTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, q, predicate, sort } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = accessResult + const schema = table.schema as TableSchema + + // The public wire is column-NAME keyed both ways: translate the predicate + // and sort down to storage ids on the way in, and the matched column id + // back to its name on the way out. + let filter: Filter | undefined + if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) + + let sortObj: Sort | undefined + if (sort?.length) { + validateSortSpec(sort, schema.columns) + const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) + sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) + } + + const { matches, truncated } = await findRowMatches( + table, + { q, filter, sort: sortObj }, + requestId + ) + + const toColumnName = columnNameById(schema) + + return v2Data( + { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error finding rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts new file mode 100644 index 00000000000..a2677af979c --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -0,0 +1,420 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest, NextResponse } from 'next/server' +import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' +import { + v2CreateTableRowsContract, + v2DeleteTableRowsContract, + v2ListTableRowsContract, + v2UpdateRowsByFilterContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { + batchInsertRows, + buildIdByName, + deleteRowsByFilter, + deleteRowsByIds, + insertRow, + rowDataNameToId, + updateRowsByFilter, + validateBatchRows, + validateRowData, + validateRowSize, +} from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { TableQueryValidationError } from '@/lib/table/errors' +import { queryRows } from '@/lib/table/rows/service' +import { checkAccess } from '@/app/api/table/utils' +import { + checkRateLimit, + type RateLimitResult, + resolveWorkspaceScope, +} from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + toApiRow, + v2BulkPredicateToFilter, + v2RowValidationError, + v2RowWriteError, + v2TableAccessError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRowsRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * Inserts a validated batch of rows. Authorizes against the table's own + * workspace (IDOR guard) before any write, translates name-keyed row data to + * storage ids, and returns the inserted rows in the canonical v2 envelope. + */ +async function handleBatchInsert( + requestId: string, + tableId: string, + validated: V1BatchInsertTableRowsBody, + userId: string, + rateLimit: RateLimitResult +): Promise { + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // External callers key row data by column name; storage keys by id. + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) + + const validation = await validateBatchRows({ + rows, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + try { + const insertedRows = await batchInsertRows( + { tableId, rows, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data( + { + rows: insertedRows.map((r) => toApiRow(r, toNamedRow)), + insertedCount: insertedRows.length, + }, + { rateLimit } + ) + } catch (error) { + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error batch inserting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} + +/** + * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row + * order. Filtered/sorted reads go through `POST /query`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + + // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying + // offset (upgradeable to keyset later without an interface change). Total row + // count is intentionally omitted here — it's available as `rowCount` on the table. + const offset = validated.cursor + ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) + : 0 + + const result = await queryRows( + table, + { + limit: validated.limit, + offset, + includeTotal: true, + withExecutions: false, + }, + requestId + ) + + const total = result.totalCount ?? 0 + const hasMore = offset + result.rowCount < total + const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null + + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error querying rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ +export const POST = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + + if ('rows' in parsed.data.body) { + const batchValidated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) + } + + const validated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const rowData = rowDataNameToId(validated.data as RowData, idByName) + + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + const row = await insertRow( + { tableId, data: rowData, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data({ row: toApiRow(row, toNamedRow) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error inserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const patchData = rowDataNameToId(validated.data as RowData, idByName) + + const sizeValidation = validateRowSize(patchData) + if (!sizeValidation.valid) { + return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + } + + const result = await updateRowsByFilter( + table, + { + filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), + data: patchData, + limit: validated.limit, + actorUserId: userId, + }, + requestId + ) + + // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it + // on the zero-match branch. + return v2Data( + { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error updating rows by filter`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // id-based and filter-based deletes share one envelope; `requestedCount`/ + // `missingRowIds` are populated only for the id-based delete (which has a + // requested set) and omitted for the filter-based delete. + if (validated.rowIds) { + const result = await deleteRowsByIds( + table, + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return v2Data( + { + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + missingRowIds: result.missingRowIds, + }, + { rateLimit } + ) + } + + const result = await deleteRowsByFilter( + table, + { + filter: v2BulkPredicateToFilter(validated.filter!, table.schema as TableSchema), + limit: validated.limit, + }, + requestId + ) + + return v2Data( + { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error deleting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts new file mode 100644 index 00000000000..a8b4c21593b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' +import { namedRowMapper } from '@/lib/table/cell-format' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableUpsertAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface UpsertRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ +export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + return v2Data( + { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error upserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts new file mode 100644 index 00000000000..25488f0ad26 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -0,0 +1,240 @@ +/** + * @vitest-environment node + * + * Public v2 saved-view detail: read, patch, delete. A view that is not on this + * table is a 404 rather than a silent no-op, so a caller can tell a wrong id + * from a successful write. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableView, + mockUpdateTableView, + mockDeleteTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableView: vi.fn(), + mockUpdateTableView: vi.fn(), + mockDeleteTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + getTableView: mockGetTableView, + updateTableView: mockUpdateTableView, + deleteTableView: mockDeleteTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } + +function callGet() { + return GET( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'GET', + }), + params + ) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + params + ) +} + +function callDelete() { + return DELETE( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'DELETE', + }), + params + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the view scoped to its table', async () => { + mockGetTableView.mockResolvedValue(VIEW) + + const res = await callGet() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS) + }) + + it('404s a view id that belongs to a different table', async () => { + mockGetTableView.mockResolvedValue(null) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('View not found') + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockGetTableView).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => { + it('forwards the patch fields to the service', async () => { + mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(200) + expect((await res.json()).data.view.isDefault).toBe(true) + expect(mockUpdateTableView).toHaveBeenCalledWith({ + viewId: 'view-1', + tableId: 'table-1', + name: undefined, + config: undefined, + configPatch: undefined, + isDefault: true, + columns: COLUMNS, + }) + }) + + it('400s a body that changes nothing', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('400s config and configPatch together', async () => { + const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(403) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the deleted view id', async () => { + mockDeleteTableView.mockResolvedValue(true) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ id: 'view-1' }) + expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1') + }) + + it('404s when nothing was deleted rather than reporting a phantom success', async () => { + mockDeleteTableView.mockResolvedValue(false) + + const res = await callDelete() + + expect(res.status).toBe(404) + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callDelete() + + expect(res.status).toBe(403) + expect(mockDeleteTableView).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts new file mode 100644 index 00000000000..ba29f7665c0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -0,0 +1,183 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableViewContract, + v2GetTableViewContract, + v2UpdateTableViewContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { + deleteTableView, + getTableView, + TableViewValidationError, + updateTableView, +} from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableViewRouteParams { + params: Promise<{ tableId: string; viewId: string }> +} + +/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the + * config, or promote the view to the table's default. + */ +export const PATCH = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await updateTableView({ + viewId, + tableId, + name, + config, + configPatch, + isDefault, + columns: (result.table.schema as TableSchema).columns, + }) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error updating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const deleted = await deleteTableView(viewId, tableId) + if (!deleted) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ id: viewId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts new file mode 100644 index 00000000000..8a789e0de94 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + * + * Public v2 saved views: list and create. A view is presentation state, so the + * read needs only `read` while saving one needs `write`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockListTableViews, + mockCreateTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockListTableViews: vi.fn(), + mockCreateTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + listTableViews: mockListTableViews, + createTableView: mockCreateTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } }, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views', () => { + it('returns every view as one full page with ISO timestamps', async () => { + mockListTableViews.mockResolvedValue([VIEW]) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null }) + // The columns are passed so stale references are pruned from each config. + expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS) + }) + + it('404s a table in another workspace without listing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockListTableViews).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/v2/tables/[tableId]/views', () => { + it('creates the view with the caller as author and answers 201', async () => { + mockCreateTableView.mockResolvedValue(VIEW) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(201) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockCreateTableView).toHaveBeenCalledWith({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Active', + config: {}, + userId: 'user-1', + columns: COLUMNS, + }) + }) + + it('400s a blank view name without touching the service', async () => { + const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} }) + + expect(res.status).toBe(400) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(403) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('surfaces a service-level view validation failure as 400', async () => { + mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty')) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('View name cannot be empty') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts new file mode 100644 index 00000000000..be0dcbe0fa7 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -0,0 +1,127 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/views — Every saved view on the table. + * + * A table carries a bounded set of views, so this is one full page and + * `nextCursor` is always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListTableViewsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) + + return v2CursorList(views.map(toApiView), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing table views`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, name, config } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await createTableView({ + tableId, + workspaceId, + name, + config, + userId, + columns: (result.table.schema as TableSchema).columns, + }) + + return v2Data({ view: toApiView(view) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error creating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts new file mode 100644 index 00000000000..87268ab070f --- /dev/null +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportDownloadAPI') +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +interface TableExportRouteParams { + params: Promise<{ exportId: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2TableExportDownloadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await requireTableExport(parsed.data.params.exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table export not found') + } + const result = tableExportResult(record) + const url = await generatePresignedDownloadUrl( + result.resultKey, + 'workspace', + DOWNLOAD_TTL_SECONDS + ) + return v2Data( + { + url, + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + }, + { rateLimit } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to issue table export download', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts new file mode 100644 index 00000000000..4fa1032e782 --- /dev/null +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CancelTableExportContract, + v2GetTableExportContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + cancelTableExportResource, + requireTableExport, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportAPI') + +interface TableExportRouteParams { + params: Promise<{ exportId: string }> +} + +async function authorizeExport(exportId: string, workspaceId: string, userId: string) { + const record = await requireTableExport(exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) return null + return record +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + if (!record) return v2Error('NOT_FOUND', 'Table export not found') + return v2Data(toV2TableExport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CancelTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + if (!record) return v2Error('NOT_FOUND', 'Table export not found') + return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to cancel table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts new file mode 100644 index 00000000000..c9744bf395b --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -0,0 +1,182 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateTableFolderContract, + v2DeleteTableFolderContract, + v2ListTableFoldersContract, + v2RelocateTableFolderContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListTableFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'table', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing table folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts new file mode 100644 index 00000000000..637e56f8f99 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockGetOwnedTableImportUpload, + mockFindOwnedTableImport, + mockStartUploadedTableImport, + mockToV2TableImport, + mockCompleteUploadSession, + mockValidateUploadCompletion, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockGetOwnedTableImportUpload: vi.fn(), + mockFindOwnedTableImport: vi.fn(), + mockStartUploadedTableImport: vi.fn(), + mockToV2TableImport: vi.fn(), + mockCompleteUploadSession: vi.fn(), + mockValidateUploadCompletion: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/app/api/v2/tables/utils', () => ({ + v2TableLockError: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + findOwnedTableImport: mockFindOwnedTableImport, + getOwnedTableImportUpload: mockGetOwnedTableImportUpload, + startUploadedTableImport: mockStartUploadedTableImport, + toV2TableImport: mockToV2TableImport, +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + completeUploadSession: mockCompleteUploadSession, + validateUploadCompletion: mockValidateUploadCompletion, +})) + +import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} +const UPLOAD = { + id: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', +} + +function request(body: Record = { parts: [{ partNumber: 1, etag: 'etag-1' }] }) { + return POST( + new NextRequest( + `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'upload-token': 'signed-upload-token', + }, + body: JSON.stringify(body), + } + ), + { params: Promise.resolve({ importId: 'import-1' }) } + ) +} + +describe('POST /api/v2/tables/imports/[importId]/complete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD) + }) + + it('returns the existing table job when completion is retried', async () => { + const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' } + const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' } + mockFindOwnedTableImport.mockResolvedValue(existing) + mockToV2TableImport.mockReturnValue(responseBody) + + const response = await request() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: responseBody }) + expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({ + importId: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + uploadToken: 'signed-upload-token', + }) + expect(mockFindOwnedTableImport).toHaveBeenCalledWith({ + importId: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + expect(mockValidateUploadCompletion).toHaveBeenCalledWith(UPLOAD, { + parts: [{ partNumber: 1, etag: 'etag-1' }], + }) + expect(mockCompleteUploadSession).not.toHaveBeenCalled() + expect(mockStartUploadedTableImport).not.toHaveBeenCalled() + }) + + it('validates completion shape before returning an existing table job', async () => { + mockFindOwnedTableImport.mockResolvedValue({ id: 'import-1' }) + mockValidateUploadCompletion.mockImplementationOnce(() => { + throw new OrchestrationError('validation', 'Multipart completion requires parts') + }) + + const response = await request({}) + + expect(response.status).toBe(400) + expect(mockFindOwnedTableImport).not.toHaveBeenCalled() + expect(mockCompleteUploadSession).not.toHaveBeenCalled() + }) + + it.each([ + ['PUT', {}], + ['multipart', { parts: [{ partNumber: 1, etag: 'etag-1' }] }], + ])('forwards a %s completion body and starts the import job', async (_method, completion) => { + const started = { id: 'import-1', tableId: 'table-1', status: 'running' } + const responseBody = { id: 'import-1', tableId: 'table-1', status: 'processing' } + mockFindOwnedTableImport.mockResolvedValue(null) + mockCompleteUploadSession.mockResolvedValue({ + session: UPLOAD, + value: null, + alreadyCompleted: false, + }) + mockStartUploadedTableImport.mockResolvedValue(started) + mockToV2TableImport.mockReturnValue(responseBody) + + const response = await request(completion) + + expect(response.status).toBe(200) + expect(mockCompleteUploadSession).toHaveBeenCalledWith({ + session: UPLOAD, + completion, + finalize: expect.any(Function), + }) + expect(mockStartUploadedTableImport).toHaveBeenCalledWith(UPLOAD) + expect(await response.json()).toEqual({ data: responseBody }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..17860487743 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findOwnedTableImport, + getOwnedTableImportUpload, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { + completeUploadSession, + validateUploadCompletion, +} from '@/lib/uploads/upload-session/service' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2CompleteTableImportAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CompleteTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + validateUploadCompletion(upload, parsed.data.body) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId, + userId: upload.userId, + }) + if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) + const completed = await completeUploadSession({ + session: upload, + completion: parsed.data.body, + finalize: async () => ({ value: null }), + }) + const started = await startUploadedTableImport(completed.session) + return v2Data(await toV2TableImport(started), { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to complete table import upload', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts new file mode 100644 index 00000000000..1d419975ac5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableImportPartsAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateTableImportPartUrlsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const session = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table import part URLs', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts new file mode 100644 index 00000000000..22005ef907e --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CancelTableImportContract, + v2GetTableImportContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + abortTableImportUpload, + cancelTableImportResource, + getOwnedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableImportAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + return v2Data(await toV2TableImport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CancelTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const uploadToken = parsed.data.headers['upload-token'] + const record = uploadToken + ? await abortTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + uploadToken, + }) + : await cancelTableImportResource( + await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + ) + return v2Data(toV2TableImport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to cancel table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts new file mode 100644 index 00000000000..f5c6a5f7541 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -0,0 +1,147 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCreateTableImportResource, + mockToV2CreateTableImport, + mockLoadActiveFolderPathIndex, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCreateTableImportResource: vi.fn(), + mockToV2CreateTableImport: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/app/api/v2/tables/utils', () => ({ + v2TableLockError: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + createTableImportResource: mockCreateTableImportResource, + toV2CreateTableImport: mockToV2CreateTableImport, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +import { POST } from '@/app/api/v2/tables/imports/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +describe('POST /api/v2/tables/imports', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) + }) + + it.each([ + [ + 'upload', + { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + { + session: { id: 'import-1', source: { type: 'upload' } }, + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + }, + ], + [ + 'workspace file', + { type: 'workspace_file', fileId: 'file-1' }, + { + session: { id: 'import-1', source: { type: 'workspace_file', fileId: 'file-1' } }, + uploadToken: null, + transfer: null, + }, + ], + ])('returns the create envelope for a %s source', async (_label, source, responseData) => { + const requestBody = { + workspaceId: WORKSPACE_ID, + source, + target: { type: 'new', name: 'imported_data' }, + } + const created = { record: { id: 'import-1' }, upload: null } + mockCreateTableImportResource.mockResolvedValue(created) + mockToV2CreateTableImport.mockReturnValue(responseData) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/tables/imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + ) + + expect(response.status).toBe(201) + expect(mockCreateTableImportResource).toHaveBeenCalledWith( + requestBody, + 'user-1', + 'http://localhost:3000', + null + ) + expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) + expect(await response.json()).toEqual({ data: responseData }) + }) + + it('accepts native JSON mapping and createColumns values', async () => { + const requestBody = { + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'existing', tableId: 'table-1', mode: 'append' }, + mapping: { email: 'email_address', notes: null }, + createColumns: ['phone'], + } + const created = { record: { id: 'import-1' }, upload: null } + const responseData = { + session: { id: 'import-1', source: { type: 'upload' } }, + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + } + mockCreateTableImportResource.mockResolvedValue(created) + mockToV2CreateTableImport.mockReturnValue(responseData) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/tables/imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + ) + + expect(response.status).toBe(201) + expect(mockCreateTableImportResource).toHaveBeenCalledWith( + requestBody, + 'user-1', + 'http://localhost:3000' + ) + }) +}) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts new file mode 100644 index 00000000000..43ce013fa3b --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableImportResource, + toV2CreateTableImport, +} from '@/lib/table/orchestration/import-resource' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateTableImportContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + let created: Awaited> + if (parsed.data.body.target.type === 'new') { + const resolution = await resolveFolderPathIdentity({ + workspaceId: parsed.data.body.workspaceId, + resourceType: 'table', + path: parsed.data.body.target.folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + created = await createTableImportResource( + parsed.data.body, + userId, + request.nextUrl.origin, + resolution.folderId + ) + } else { + created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin) + } + return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 2880355c8df..b5af7c73822 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -1,45 +1,92 @@ /** * @vitest-environment node * - * Public v2 tables list: auth/scope gating, typed summary output, private cache header. + * Public v2 tables list: auth/scope gating, rollout gate ordering, typed + * summary output in the `{ data, nextCursor }` envelope, private cache header. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' -const { mockListTables, mockCheckRateLimit, mockValidateWorkspaceAccess, mockGate } = vi.hoisted( - () => ({ - mockListTables: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockValidateWorkspaceAccess: vi.fn(), - mockGate: vi.fn(), - }) -) +const { + mockQueryTables, + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockIsFeatureEnabled, + mockGetWorkspaceOrganizationId, + mockLoadActiveFolderPathIndex, + mockResolveFolderPathIdentity, + mockCreateTable, + mockGetWorkspaceTableLimits, +} = vi.hoisted(() => ({ + mockQueryTables: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockGetWorkspaceOrganizationId: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockResolveFolderPathIdentity: vi.fn(), + mockCreateTable: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), +})) -vi.mock('@/app/api/v1/middleware', async () => { - const { NextResponse } = await import('next/server') - return { - checkRateLimit: mockCheckRateLimit, - validateWorkspaceAccess: mockValidateWorkspaceAccess, - createRateLimitResponse: (r: { error?: string }) => - NextResponse.json( - { error: r.error ?? 'Rate limit exceeded' }, - { status: r.error ? 401 : 429 } - ), - } -}) +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) vi.mock('@/lib/table', async () => { const actual = await import('@/lib/table/column-keys') - return { ...actual, listTables: mockListTables } + return { + ...actual, + queryTables: mockQueryTables, + createTable: mockCreateTable, + getWorkspaceTableLimits: mockGetWorkspaceTableLimits, + } }) vi.mock('@/app/api/table/utils', () => ({ normalizeColumn: (col: Record) => col, - tablesV2GateError: mockGate, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), })) -import { GET } from '@/app/api/v2/tables/route' +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/app/api/v2/lib/folders', () => ({ + folderPathForId: (_index: unknown, folderId: string | null | undefined) => + folderId ? '/Reports' : '/', + resolveFolderPathId: ( + index: { idByPath: Map }, + path: string + ): string | null | undefined => (path === '/' ? null : index.idByPath.get(path)), + resolveFolderPathIdentity: mockResolveFolderPathIdentity, +})) + +import { GET, POST } from '@/app/api/v2/tables/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} function buildTable(): TableDefinition { return { @@ -63,69 +110,183 @@ function callList(query: string) { return GET(req) } +function callCreate(body: Record) { + return POST( + new NextRequest('http://localhost:3000/api/v2/tables', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + describe('GET /api/v2/tables', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'workspace' }) - mockValidateWorkspaceAccess.mockResolvedValue(null) - mockListTables.mockResolvedValue([buildTable()]) - mockGate.mockResolvedValue(null) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) + mockIsFeatureEnabled.mockResolvedValue(true) + mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) - it('returns 404 when the tables-v2-api flag is off', async () => { - const { NextResponse } = await import('next/server') - mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(404) - expect(mockListTables).not.toHaveBeenCalled() + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockQueryTables).not.toHaveBeenCalled() }) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - const { NextResponse } = await import('next/server') - mockValidateWorkspaceAccess.mockResolvedValue( - NextResponse.json({ error: 'Access denied' }, { status: 403 }) - ) + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockQueryTables).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) const res = await callList('workspaceId=workspace-1') expect(res.status).toBe(403) - expect(mockGate).not.toHaveBeenCalled() + expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) + expect(mockQueryTables).not.toHaveBeenCalled() }) - it('returns a typed table summary with a private cache header', async () => { + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + expect(res.status).toBe(200) - expect(res.headers.get('Cache-Control')).toBe('private, no-store') + expect((await res.json()).nextCursor).toBeNull() + }) + + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList('workspaceId=workspace-1&folderPath=%2F') + + expect(mockQueryTables).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ folderId: null }) + ) + }) + + it('passes limit and the decoded cursor through to the query', async () => { + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) + + await callList('workspaceId=workspace-1&limit=25&sortBy=name&sortOrder=desc') + + // The slice must happen in the query, not after a full-workspace read. + expect(mockQueryTables).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ limit: 25, sortBy: 'name', sortOrder: 'desc' }) + ) + }) + + it('returns a nextCursor when the query reports another page', async () => { + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) + + const res = await callList('workspaceId=workspace-1&limit=1') const body = await res.json() - expect(body.data.totalCount).toBe(1) - expect(body.data.tables[0]).toMatchObject({ - id: 'tbl_1', - name: 'People', - description: 'A table', - rowCount: 5, - maxRows: 100, - createdAt: '2024-01-01T00:00:00.000Z', - }) - expect(body.data.tables[0].schema.columns[0].name).toBe('name') + + expect(res.status).toBe(200) + expect(body.nextCursor).toEqual(expect.any(String)) }) - it('400s when workspaceId is missing', async () => { - const res = await callList('') + it('rejects a cursor that does not match the requested sort', async () => { + const first = await callList('workspaceId=workspace-1&sortBy=name') + // Encoded under sortBy=name, replayed under sortBy=createdAt. + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) + const paged = await callList('workspaceId=workspace-1&sortBy=name&limit=1') + const cursor = (await paged.json()).nextCursor + + const res = await callList( + `?workspaceId=workspace-1&sortBy=createdAt&cursor=${encodeURIComponent(cursor)}` + ) + expect(res.status).toBe(400) - expect(mockListTables).not.toHaveBeenCalled() + expect(first.status).toBe(200) }) +}) - it('surfaces an access-denied response from the middleware', async () => { - const { NextResponse } = await import('next/server') - mockValidateWorkspaceAccess.mockResolvedValue( - NextResponse.json({ error: 'Access denied' }, { status: 403 }) - ) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect(mockListTables).not.toHaveBeenCalled() +describe('POST /api/v2/tables', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) + mockResolveFolderPathIdentity.mockResolvedValue({ + found: true, + folderId: 'folder-1', + index: { + rowById: new Map(), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }, + }) + mockCreateTable.mockResolvedValue({ ...buildTable(), folderId: 'folder-1' }) }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ allowed: false }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(429) + it('resolves a slashless folder path before creating the table outside the folder lock', async () => { + const res = await callCreate({ + workspaceId: 'workspace-1', + name: 'People', + folderPath: 'Reports', + schema: { columns: [{ name: 'email', type: 'string' }] }, + }) + + expect(res.status).toBe(201) + expect(mockResolveFolderPathIdentity).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + resourceType: 'table', + path: '/Reports', + }) + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }), + expect.any(String) + ) + expect((await res.json()).data.table.folderPath).toBe('/Reports') }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index be0c59c32b6..c3d319bbafd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,78 +1,182 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { v2ListTablesContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listTables, type TableSchema } from '@/lib/table' -import { normalizeColumn, tablesV2GateError } from '@/app/api/table/utils' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' +import { normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { - checkRateLimit, - createRateLimitResponse, - validateWorkspaceAccess, -} from '@/app/api/v1/middleware' + folderPathForId, + resolveFolderPathId, + resolveFolderPathIdentity, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CaughtOrchestrationError, + v2CursorList, + v2CursorSortError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TablesAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Filters/ids can appear in query strings; keep list responses out of shared caches. */ -const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const - -/** GET /api/v2/tables — list all tables in a workspace. */ +/** GET /api/v2/tables — List all tables in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const rateLimit = await checkRateLimit(request, 'v2-tables') - if (!rateLimit.allowed) return createRateLimitResponse(rateLimit) + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! - const parsed = await parseRequest(v2ListTablesContract, request, {}) + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListTablesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) - if (accessError) return accessError + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } - // After authz: the gate reads the workspace's org off the primary DB, and its - // 404 would otherwise distinguish "not in the rollout cohort" from "no access". - const gateError = await tablesV2GateError(userId, workspaceId) - if (gateError) return gateError + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() - const tables = await listTables(workspaceId) + const { tables, nextKeys } = await queryTables(workspaceId, { + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) + + const items = tables.map((table) => + toApiTable(table, folderPathForId(folderIndex, table.folderId)) + ) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null + + return v2CursorList(items, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing tables`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables — Create a new table. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - return NextResponse.json( + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateTableContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const planLimits = await getWorkspaceTableLimits(params.workspaceId) + + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), + } + + const resolution = await resolveFolderPathIdentity({ + workspaceId: params.workspaceId, + resourceType: 'table', + path: params.folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const table = await createTable( { - success: true, - data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { columns: schemaData.columns.map(normalizeColumn) }, - rowCount: t.rowCount, - maxRows: t.maxRows, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - } - }), - totalCount: tables.length, - }, + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + folderId: resolution.folderId, }, - { headers: PRIVATE_NO_STORE } + requestId + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, + request, + }) + + return v2Data( + { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, + { rateLimit, status: 201 } ) } catch (error) { - const validationResponse = validationErrorResponseFromError(error) - if (validationResponse) return validationResponse + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified - logger.error(`[${requestId}] Error listing tables:`, error) - return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) + logger.error(`[${requestId}] Error creating table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') } }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts new file mode 100644 index 00000000000..d3cd6dfe6e5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -0,0 +1,260 @@ +import type { NextResponse } from 'next/server' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import type { MultipartError } from '@/lib/core/utils/multipart' +import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' +import { getColumnId } from '@/lib/table/column-keys' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { predicateToFilter } from '@/lib/table/query-builder/converters' +import { + validatePredicateShape, + validateStoragePredicate, +} from '@/lib/table/query-builder/validate' +import { predicateToStorage } from '@/lib/table/select-values' +import type { Filter, TableLockKind } from '@/lib/table/types' +import type { TableView } from '@/lib/table/views/service' +import { + CSV_IMPORT_PROXY_BODY_CAP_BYTES, + normalizeColumn, + rootErrorMessage, + rowWriteErrorResponse, +} from '@/app/api/table/utils' +import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error helpers for the v2 tables surface. Every v2 + * table/row/column route renders its payloads and access failures through these + * so the public shape, timestamp format, and error envelope stay identical + * across the surface. These reuse the v1 platform services and classifiers — + * only the HTTP envelope is upgraded. + */ + +/** ISO-serializes a `Date | string` timestamp from the table service layer. */ +function toIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : String(value) +} + +/** + * Resolves a public v2 bulk-op predicate to the storage-id-keyed legacy `Filter` + * the row runners consume. The public wire is column-NAME-keyed: shape-check + * first (keying-agnostic), translate names → storage ids (including select + * operand names → option ids), then validate the RESULT against storage keys — + * on a destructive path an unresolved field must 400, not silently match + * nothing. + */ +export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: TableSchema): Filter { + validatePredicateShape(predicate) + const translated = predicateToStorage(predicate, schema) + validateStoragePredicate(translated, schema.columns) + return predicateToFilter(translated) +} + +/** + * Normalized public table shape — the same subset of fields the v1 surface + * exposes, with timestamps serialized to ISO strings. Shared by every v2 table + * endpoint so the table payload is identical across the surface. + */ +export function toApiTable(table: TableDefinition, folderPath: string) { + return { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: (table.schema as TableSchema).columns.map(normalizeColumn), + }, + rowCount: table.rowCount, + maxRows: table.maxRows, + folderPath, + locks: table.locks, + // `jobStatus` is the presence signal — the service leaves the whole group + // null when the table is idle. Without this an async import could be + // started and cancelled but never observed to completion or failure. + job: table.jobStatus + ? { + id: table.jobId ?? null, + type: table.jobType ?? null, + status: table.jobStatus, + rowsProcessed: table.jobRowsProcessed ?? 0, + error: table.jobError ?? null, + } + : null, + createdAt: toIso(table.createdAt), + updatedAt: toIso(table.updatedAt), + } +} + +/** + * Normalized public view shape. Identical to the stored view except that the + * timestamps are ISO strings, matching every other v2 payload. + */ +export function toApiView(view: TableView) { + return { + id: view.id, + tableId: view.tableId, + name: view.name, + config: view.config, + isDefault: view.isDefault, + createdBy: view.createdBy, + createdAt: toIso(view.createdAt), + updatedAt: toIso(view.updatedAt), + } +} + +/** + * Maps a stored column id (the JSONB key that `findRowMatches` reports) back to + * its display name, so cell references on the public wire are name-keyed like + * row `data`. Falls back to the id for a column that no longer exists. + */ +export function columnNameById(schema: TableSchema): (columnId: string) => string { + const nameById = new Map(schema.columns.map((column) => [getColumnId(column), column.name])) + return (columnId) => nameById.get(columnId) ?? columnId +} + +/** + * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow} + * translates it to column names. + */ +interface ApiRowInput { + id: string + data: RowData + createdAt: Date | string + updatedAt: Date | string +} + +/** + * Normalized public row shape: `{ id, data, createdAt, updatedAt }`, no storage + * internals (`position`/`orderKey`/`executions`). Callers pass a + * `namedRowMapper(schema.columns)` so `data` is keyed by column NAME and select + * cells surface their option NAME rather than the stored option id. + */ +export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowData) { + return { + id: row.id, + data: toNamedRow(row.data), + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + } +} + +/** + * Maps a {@link MultipartError} from the streaming CSV reader to the v2 + * envelope. Mirrors v1's {@link multipartErrorResponse} — same classification, + * different envelope. + */ +export function v2MultipartError(error: MultipartError): NextResponse { + if (error.code === 'FILE_TOO_LARGE') { + return v2Error('PAYLOAD_TOO_LARGE', 'CSV import file exceeds maximum size') + } + return error.code === 'NO_FILE' + ? v2Error('BAD_REQUEST', 'CSV file is required') + : v2Error('BAD_REQUEST', `Invalid CSV upload: ${error.message}`) +} + +/** + * 413 when a synchronous CSV upload would exceed the proxy's body cap; `null` + * otherwise. Next buffers the request body for the proxy and silently + * TRUNCATES it past the cap, so an unchecked oversize upload imports a partial + * file and reports success — the failure this exists to prevent. + */ +export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse | null { + const contentLength = Number(request.headers.get('content-length') ?? 0) + if (contentLength <= CSV_IMPORT_PROXY_BODY_CAP_BYTES) return null + return v2Error( + 'PAYLOAD_TOO_LARGE', + 'File too large to import through the server. Upload it to workspace storage and use the async import instead.' + ) +} + +/** + * Renders a failed {@link checkAccess} result on a MUTATION path: a missing + * table stays 404, a missing permission stays 403. Read paths instead mask both + * as 404 inline so cross-workspace resource existence is never leaked. + */ +export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse { + return result.status === 404 + ? v2Error('NOT_FOUND', 'Table not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** + * Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope, + * mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything + * else so the caller falls through to its own classification. + * + * `details.lock` names the flag that rejected the write. A table carries four + * independent locks, so "locked" on its own does not tell a caller which one to + * clear — every 423 on the surface reports it. + */ +export function v2TableLockError( + error: unknown, + /** Merged into `details` — e.g. which operations of a composite write landed. */ + extraDetails?: Record +): NextResponse | null { + if (error instanceof TableLockedError) { + return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } }) + } + return null +} + +/** The failure half of any `lib/table/orchestration` result. */ +export interface OrchestrationOutcome { + errorCode?: OrchestrationErrorCode + error?: string + lock?: TableLockKind +} + +/** + * Renders a `lib/table/orchestration` failure in the v2 envelope, naming the + * lock when one caused it. + * + * A lock rejection reaches a route two different ways — thrown and caught at + * the boundary ({@link v2TableLockError}), or returned as a classified + * `errorCode: 'locked'` outcome — and both must produce the same body. Plain + * {@link v2ErrorForOrchestration} cannot, because the `lock` kind lives on the + * outcome rather than the code, so every table route that renders an + * orchestration result goes through this instead. + */ +export function v2TableOrchestrationError( + outcome: OrchestrationOutcome, + fallback: string, + /** Merged into `details` — e.g. which operations of a composite write landed. */ + extraDetails?: Record +): NextResponse { + // `lock` is omitted rather than sent as null when the kind is unknown — a + // caller branching on `details.lock` should see absence, not a phantom value. + const details = { + ...(outcome.errorCode === 'locked' && outcome.lock ? { lock: outcome.lock } : {}), + ...extraDetails, + } + return v2ErrorForOrchestration( + outcome.errorCode, + outcome.error ?? fallback, + Object.keys(details).length > 0 ? details : undefined + ) +} + +/** + * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 + * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the + * single source of truth for which messages are safe to surface. Returns `null` + * for unrecognized errors so the caller logs and returns a generic 500. + */ +export function v2RowWriteError(error: unknown): NextResponse | null { + if (!rowWriteErrorResponse(error)) return null + return v2Error('BAD_REQUEST', rootErrorMessage(error)) +} + +/** + * Adapts a failed-row validation from the shared `validateRowData` / + * `validateBatchRows` helpers — which bake a v1-shaped `{ error, details }` 400 + * response — into the canonical v2 error envelope while preserving the + * structured `details` (per-field / per-row). The validators expose the failure + * only as a rendered response, so the body is read back rather than + * re-implementing the size/schema/unique checks. + */ +export async function v2RowValidationError(response: NextResponse): Promise { + const body = (await response + .clone() + .json() + .catch(() => ({}))) as { error?: string; details?: unknown } + return v2Error('BAD_REQUEST', body.error ?? 'Invalid row data', { details: body.details }) +} diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts new file mode 100644 index 00000000000..18fe9a6e23f --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockLocalUploadBodyError, + mockExpectedUploadPartSize, + mockVerifyUploadSessionToken, + mockWriteLocalMultipartPart, +} = vi.hoisted(() => { + class MockLocalUploadBodyError extends Error {} + return { + MockLocalUploadBodyError, + mockExpectedUploadPartSize: vi.fn(), + mockVerifyUploadSessionToken: vi.fn(), + mockWriteLocalMultipartPart: vi.fn(), + } +}) + +vi.mock('@/lib/uploads/upload-session/provider', () => ({ + LocalUploadBodyError: MockLocalUploadBodyError, + writeLocalMultipartPart: mockWriteLocalMultipartPart, +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + expectedUploadPartSize: mockExpectedUploadPartSize, + verifyUploadSessionToken: mockVerifyUploadSessionToken, +})) + +import { PUT } from '@/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route' + +const SESSION = { + id: 'upload-1', + storageProvider: 'local', + method: 'multipart', + status: 'uploading', +} as const + +describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyUploadSessionToken.mockReturnValue(SESSION) + mockExpectedUploadPartSize.mockReturnValue(3) + mockWriteLocalMultipartPart.mockResolvedValue(undefined) + }) + + it('streams an exact-size local multipart part', async () => { + const response = await request() + + expect(response.status).toBe(204) + expect(mockVerifyUploadSessionToken).toHaveBeenCalledWith('signed-token') + expect(mockExpectedUploadPartSize).toHaveBeenCalledWith(SESSION, 1) + expect(mockWriteLocalMultipartPart).toHaveBeenCalledWith({ + uploadId: 'upload-1', + partNumber: 1, + body: expect.any(ReadableStream), + expectedSize: 3, + }) + }) + + it('maps a streamed-size failure to 400', async () => { + mockWriteLocalMultipartPart.mockRejectedValue( + new MockLocalUploadBodyError('Part 1 has 2 bytes; expected 3') + ) + + const response = await request({ contentLength: null }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'Part 1 has 2 bytes; expected 3', + }) + }) + + it('rejects PUT sessions before calculating a part size', async () => { + mockVerifyUploadSessionToken.mockReturnValue({ ...SESSION, method: 'put' }) + + const response = await request() + + expect(response.status).toBe(409) + expect(mockExpectedUploadPartSize).not.toHaveBeenCalled() + expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled() + }) +}) + +function request(options?: { contentLength?: string | null }) { + const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) + if (options?.contentLength !== null) { + headers.set('Content-Length', options?.contentLength ?? '3') + } + return PUT( + new NextRequest('http://localhost:3000/api/v2/uploads/upload-1/parts/1?token=signed-token', { + method: 'PUT', + headers, + body: new Uint8Array([1, 2, 3]), + }), + { params: Promise.resolve({ uploadId: 'upload-1', partNumber: '1' }) } + ) +} diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts new file mode 100644 index 00000000000..8e7c8cf4933 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -0,0 +1,69 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + LocalUploadBodyError, + writeLocalMultipartPart, +} from '@/lib/uploads/upload-session/provider' +import { + expectedUploadPartSize, + type UploadSessionRecord, + verifyUploadSessionToken, +} from '@/lib/uploads/upload-session/service' + +interface LocalPartRouteParams { + params: Promise<{ uploadId: string; partNumber: string }> +} + +/** + * Local-storage data plane for signed multipart PUT URLs. Cloud deployments return provider URLs + * instead, so this route is never in the cloud byte path. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: LocalPartRouteParams): Promise => { + const { uploadId } = await context.params + const token = request.nextUrl.searchParams.get('token') ?? '' + let session: UploadSessionRecord + try { + session = verifyUploadSessionToken(token) + } catch { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + const parsed = await parseRequest(localUploadPartContract, request, context) + if (!parsed.success) return parsed.response + + if (session.id !== uploadId || session.storageProvider !== 'local') { + return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + } + if (session.status !== 'uploading') { + return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + } + if (session.method !== 'multipart') { + return NextResponse.json({ error: 'PUT upload sessions do not have parts' }, { status: 409 }) + } + + const { partNumber } = parsed.data.params + const expectedSize = expectedUploadPartSize(session, partNumber) + const contentLength = request.headers.get('content-length') + if (contentLength !== null && Number(contentLength) !== expectedSize) { + return NextResponse.json( + { error: `Part ${partNumber} must contain exactly ${expectedSize} bytes` }, + { status: 400 } + ) + } + if (!request.body) { + return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 }) + } + + try { + await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) + } catch (error) { + if (error instanceof LocalUploadBodyError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + return new NextResponse(null, { status: 204 }) + } +) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts new file mode 100644 index 00000000000..22e18916d89 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockLocalUploadBodyError, mockGetOwnedUploadSession, mockMetadata, mockWriteLocalPut } = + vi.hoisted(() => { + class MockLocalUploadBodyError extends Error {} + return { + MockLocalUploadBodyError, + mockGetOwnedUploadSession: vi.fn(), + mockMetadata: vi.fn(), + mockWriteLocalPut: vi.fn(), + } + }) + +vi.mock('@/lib/uploads/upload-session/provider', () => ({ + LocalUploadBodyError: MockLocalUploadBodyError, + writeLocalPutObject: mockWriteLocalPut, +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + getOwnedUploadSession: mockGetOwnedUploadSession, + uploadSessionObjectMetadata: mockMetadata, +})) + +import { PUT } from '@/app/api/v2/uploads/[uploadId]/route' + +const SESSION = { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: 'user-1', + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/file.bin', + finalKey: 'workspace/workspace-1/file.bin', + stagingKey: 'upload-sessions/upload-1/file.bin', + storageProvider: 'local', + providerUploadId: null, + fileName: 'file.bin', + contentType: 'application/octet-stream', + fileSize: 3, + partSize: null, + partCount: null, + status: 'uploading', + metadata: {}, + uploadToken: 'signed-token', + createdAt: new Date('2026-08-04T12:00:00.000Z'), + expiresAt: new Date('2099-08-05T12:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-04T12:00:00.000Z'), +} as const + +describe('PUT /api/v2/uploads/[uploadId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOwnedUploadSession.mockReturnValue(SESSION) + mockMetadata.mockReturnValue({ uploadId: 'upload-1', purpose: 'workspace_file' }) + mockWriteLocalPut.mockResolvedValue(undefined) + }) + + it('streams the local PUT with the signed session size and canonical metadata', async () => { + const response = await request() + + expect(response.status).toBe(204) + expect(mockGetOwnedUploadSession).toHaveBeenCalledWith({ + uploadId: 'upload-1', + uploadToken: 'signed-token', + }) + expect(mockWriteLocalPut).toHaveBeenCalledWith({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/file.bin', + body: expect.any(ReadableStream), + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + }) + }) + + it('streams an empty local PUT body for an empty workspace-file session', async () => { + mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, fileSize: 0 }) + const response = await request({ contentLength: '0', body: new Uint8Array() }) + + expect(response.status).toBe(204) + expect(mockWriteLocalPut).toHaveBeenCalledWith( + expect.objectContaining({ expectedSize: 0, body: expect.any(ReadableStream) }) + ) + }) + + it('rejects a mismatched Content-Length before opening the local writer', async () => { + const response = await request({ contentLength: '2' }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'Upload must contain exactly 3 bytes', + }) + expect(mockWriteLocalPut).not.toHaveBeenCalled() + }) + + it('rejects a URL whose token names a non-local or multipart session', async () => { + mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, method: 'multipart' }) + + const response = await request() + + expect(response.status).toBe(403) + expect(mockWriteLocalPut).not.toHaveBeenCalled() + }) + + it('maps exact-size streaming failures to a caller error', async () => { + mockWriteLocalPut.mockRejectedValue(new MockLocalUploadBodyError('Upload exceeds 3 bytes')) + + const response = await request({ contentLength: null }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'Upload exceeds 3 bytes' }) + }) +}) + +function request(options?: { contentLength?: string | null; body?: Uint8Array }) { + const headers = new Headers({ + 'Content-Type': 'application/octet-stream', + 'upload-token': 'signed-token', + }) + if (options?.contentLength !== null) { + headers.set('Content-Length', options?.contentLength ?? '3') + } + return PUT( + new NextRequest('http://localhost:3000/api/v2/uploads/upload-1', { + method: 'PUT', + headers, + body: options?.body ?? new Uint8Array([1, 2, 3]), + }), + { params: Promise.resolve({ uploadId: 'upload-1' }) } + ) +} diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..c44c33489a2 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { localPutUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { LocalUploadBodyError, writeLocalPutObject } from '@/lib/uploads/upload-session/provider' +import { + getOwnedUploadSession, + uploadSessionObjectMetadata, +} from '@/lib/uploads/upload-session/service' + +interface LocalPutRouteParams { + params: Promise<{ uploadId: string }> +} + +/** Local-storage data plane for a signed whole-object PUT upload session. */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: LocalPutRouteParams): Promise => { + const parsed = await parseRequest(localPutUploadContract, request, context) + if (!parsed.success) return parsed.response + + let session + try { + session = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + }) + } catch { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + + if (session.storageProvider !== 'local' || session.method !== 'put') { + return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + } + if (session.expiresAt.getTime() <= Date.now()) { + return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + } + + const contentType = request.headers.get('content-type') + if (contentType !== session.contentType) { + return NextResponse.json( + { error: `Content-Type must be ${session.contentType}` }, + { status: 400 } + ) + } + const contentLength = request.headers.get('content-length') + if (contentLength !== null && Number(contentLength) !== session.fileSize) { + return NextResponse.json( + { error: `Upload must contain exactly ${session.fileSize} bytes` }, + { status: 400 } + ) + } + if (!request.body) { + return NextResponse.json({ error: 'Upload body is required' }, { status: 400 }) + } + + try { + await writeLocalPutObject({ + uploadId: session.id, + stagingKey: session.stagingKey, + body: request.body, + expectedSize: session.fileSize, + contentType: session.contentType, + metadata: uploadSessionObjectMetadata(session), + }) + } catch (error) { + if (error instanceof LocalUploadBodyError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + return new NextResponse(null, { status: 204 }) + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..ec2479d09b8 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,176 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to deploy workflow') + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow deploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow undeploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts new file mode 100644 index 00000000000..757b09e7388 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -0,0 +1,404 @@ +/** + * @vitest-environment node + */ + +import { + createMockRequest, + dbChainMockFns, + executionPreprocessingMock, + executionPreprocessingMockFns, + loggingSessionMock, + resetDbChainMock, + setEnv, + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockClaimExecutionId, + mockEnqueue, + mockExecuteWorkflowCore, + mockGenerateId, + mockGetWorkspaceBillingSettings, + mockHasDurableExecutionOwner, + mockReleaseExecutionIdClaim, + mockReleaseExecutionSlot, + mockValidatePublicApiAllowed, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockClaimExecutionId: vi.fn(), + mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'), + mockExecuteWorkflowCore: vi.fn(), + mockGenerateId: vi.fn(() => 'execution-123'), + mockGetWorkspaceBillingSettings: vi.fn(), + mockHasDurableExecutionOwner: vi.fn(), + mockReleaseExecutionIdClaim: vi.fn(), + mockReleaseExecutionSlot: vi.fn(), + mockValidatePublicApiAllowed: vi.fn(), +})) + +vi.mock('@/app/api/v1/auth', () => ({ + authenticateV1Request: mockAuthenticateV1Request, +})) + +vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ + releaseExecutionSlot: mockReleaseExecutionSlot, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {}, + validatePublicApiAllowed: mockValidatePublicApiAllowed, +})) + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) +vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) + +vi.mock('@/lib/workflows/executor/execution-core', () => ({ + executeWorkflowCore: mockExecuteWorkflowCore, +})) + +vi.mock('@/lib/workflows/executor/pause-persistence', () => ({ + handlePostExecutionPauseState: vi.fn(), +})) + +vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ + claimExecutionId: mockClaimExecutionId, + hasDurableExecutionOwner: mockHasDurableExecutionOwner, + releaseExecutionIdClaim: mockReleaseExecutionIdClaim, +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ + enqueue: mockEnqueue, + startJob: vi.fn(), + completeJob: vi.fn(), + markJobFailed: vi.fn(), + }), + shouldExecuteInline: vi.fn().mockReturnValue(false), +})) + +vi.mock('@/background/workflow-execution', () => ({ + executeWorkflowJob: vi.fn(), +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + getCustomBlockRowsForWorkspace: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/blocks/custom/server-overlay', () => ({ + withCustomBlockOverlay: vi.fn(async (_rows: unknown, fn: () => unknown) => fn()), +})) + +vi.mock('@/serializer', () => ({ + Serializer: class { + serializeWorkflow() { + return { blocks: [] } + } + }, +})) + +vi.mock('@/lib/execution/files', () => ({ + processInputFileFields: vi.fn(async (input: unknown) => input), +})) + +vi.mock('@/lib/uploads/utils/user-file-base64.server', () => ({ + hydrateUserFilesWithBase64: vi.fn(async (output: unknown) => output), +})) + +vi.mock('@/lib/execution/payloads/serializer', () => ({ + compactExecutionPayload: vi.fn(async (value: unknown) => value), +})) + +vi.mock(import('@/lib/execution/payloads/large-value-ref'), async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, containsLargeValueRef: vi.fn().mockReturnValue(false) } +}) + +vi.mock('@sim/utils/id', () => ({ + generateId: mockGenerateId, + generateShortId: vi.fn(() => 'mock-short-id'), + isValidUuid: vi.fn((v: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) + ), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { attachExecutionResult } from '@/executor/utils/errors' +import { POST } from './route' + +const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution +const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState + +const billingAttribution = { + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'actor-1', + billingEntity: { type: 'user' as const, id: 'actor-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const workflowRecord = { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + isDeployed: true, + variables: {}, +} + +function callExecute(body: Record, headers: Record = {}) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + ...headers, + }) + return POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) +} + +describe('POST /api/v2/workflows/[id]/execute', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockGenerateId.mockReturnValue('execution-123') + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ + key: `workflow-execution-id:${executionId}`, + token: `token-${executionId}`, + })) + mockHasDurableExecutionOwner.mockResolvedValue(false) + mockPreprocessExecution.mockResolvedValue({ + success: true, + actorUserId: 'actor-1', + workflowRecord, + actorSubscription: { plan: 'pro' }, + billingAttribution, + executionTimeout: { sync: 60_000, async: 300_000 }, + }) + mockLoadDeployedWorkflowState.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) + mockExecuteWorkflowCore.mockResolvedValue({ + success: true, + output: { result: 'done' }, + metadata: { + duration: 42, + startTime: '2026-07-31T00:00:00.000Z', + endTime: '2026-07-31T00:00:01.000Z', + }, + }) + }) + + it('runs sync and returns the execution resource in the v2 envelope', async () => { + const res = await callExecute({ input: { hello: 'world' } }) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Execution-Id')).toBe('execution-123') + const body = await res.json() + expect(body.data).toMatchObject({ + executionId: 'execution-123', + workflowId: 'workflow-1', + status: 'completed', + output: { result: 'done' }, + error: null, + durationMs: 42, + }) + }) + + it('returns status failed with a structured error instead of an HTTP error', async () => { + const error = new Error('Send Email: Invalid credentials') + Object.assign(error, { blockId: 'block-9', blockName: 'Send Email', blockType: 'gmail' }) + attachExecutionResult(error, { + success: false, + output: { partial: true }, + metadata: { duration: 10, startTime: 's', endTime: 'e' }, + }) + mockExecuteWorkflowCore.mockRejectedValue(error) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('failed') + expect(body.data.executionId).toBe('execution-123') + expect(body.data.output).toEqual({ partial: true }) + expect(body.data.error).toEqual({ + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'block-9', + blockName: 'Send Email', + blockType: 'gmail', + }) + }) + + it('queues async runs and returns a 202 receipt with the v2 executions statusUrl', async () => { + const res = await callExecute({ input: {}, async: true }) + + expect(res.status).toBe(202) + const body = await res.json() + expect(body.data).toEqual({ + executionId: 'execution-123', + statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/executions/execution-123', + }) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'async' }) + ) + }) + + it('404s the whole surface when the v2-api flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('rejects unknown body keys (strict contract)', async () => { + const res = await callExecute({ input: {}, triggerType: 'manual' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('rejects async combined with stream or output-shaping options', async () => { + expect((await callExecute({ async: true, stream: true })).status).toBe(400) + expect((await callExecute({ async: true, selectedOutputs: ['a.b'] })).status).toBe(400) + expect((await callExecute({ async: true, includeFileBase64: true })).status).toBe(400) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('masks a workspace-key/workflow mismatch as 404', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'other-workspace', + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects personal keys when the workspace disallows them', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'personal', + }) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(403) + }) + + it('returns 409 CONFLICT for a reused X-Execution-Id', async () => { + mockClaimExecutionId.mockResolvedValue(null) + + const res = await callExecute( + { input: {} }, + { 'X-Execution-Id': '11111111-1111-4111-8111-111111111111' } + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('CONFLICT') + expect(body.error.details).toMatchObject({ + code: 'EXECUTION_ID_CONFLICT', + executionId: '11111111-1111-4111-8111-111111111111', + }) + }) + + it('surfaces the rate-limit failure with Retry-After', async () => { + mockPreprocessExecution.mockResolvedValue({ + success: false, + error: { + message: 'Rate limit exceeded. Please try again later.', + statusCode: 429, + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: 12_000, + }, + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('12') + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('runs the anonymous public path sync but refuses async', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + + const okRes = await callExecute({ input: {} }) + expect(okRes.status).toBe(200) + + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + const asyncRes = await callExecute({ input: {}, async: true }) + expect(asyncRes.status).toBe(400) + }) + + it('401s non-public workflows without a key', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(401) + expect((await res.json()).error.code).toBe('UNAUTHORIZED') + }) + + it('releases the unused execution-id claim after a failed preprocess', async () => { + mockPreprocessExecution.mockResolvedValue({ + success: false, + error: { message: 'Workflow not found', statusCode: 404 }, + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts new file mode 100644 index 00000000000..ebe26b9ac2e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -0,0 +1,290 @@ +import { db } from '@sim/db' +import { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2ExecuteWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { executionIdSchema, WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { tryAdmit } from '@/lib/core/admission/gate' +import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' +import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type ExecuteWorkflowServiceFailure, + executeWorkflowService, +} from '@/lib/workflows/executor/execute-service' +import { + AGENT_STREAM_PROTOCOL_HEADER_LABEL, + AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, + hasAgentStreamPolicy, +} from '@/lib/workflows/streaming/agent-stream-protocol' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { + PublicApiNotAllowedError, + validatePublicApiAllowed, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('V2WorkflowExecuteAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const FAILURE_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 402: 'USAGE_LIMIT_EXCEEDED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 408: 'BAD_REQUEST', + 409: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', + 429: 'RATE_LIMITED', + 499: 'CLIENT_CLOSED_REQUEST', + 503: 'SERVICE_UNAVAILABLE', +} + +function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { + const code = FAILURE_CODE_BY_STATUS[failure.statusCode] ?? 'INTERNAL_ERROR' + const headers: Record = {} + if (failure.retryAfterMs !== undefined) { + headers['Retry-After'] = Math.max(1, Math.ceil(failure.retryAfterMs / 1000)).toString() + } + if (failure.executionId) { + headers[WORKFLOW_EXECUTION_ID_HEADER] = failure.executionId + } + return v2Error(code, failure.message, { + status: failure.statusCode, + headers, + details: + failure.code || failure.executionId + ? { + ...(failure.code ? { code: failure.code } : {}), + ...(failure.executionId ? { executionId: failure.executionId } : {}), + } + : undefined, + }) +} + +/** + * POST /api/v2/workflows/[id]/execute — syntactic sugar over + * {@link executeWorkflowService}. + * + * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for + * workflows deployed with `isPublicApi` (actor = owner; sync/stream only). + * - `async: true` (body flag — v2 has no mode headers) → 202 + * `{ data: { executionId, statusUrl } }`; poll the v2 executions resource. + * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames). + * - Sync → 200 execution resource with the status enum and structured error; + * an in-band run failure is `status: 'failed'`, never an HTTP error. A + * Response block's declared payload stays inside `output` — v2 never lets a + * workflow author control response status or headers on this origin. + * - Rate limiting: the execution `sync`/`async` buckets via preprocessing — + * deliberately NOT the shared `api-endpoint` bucket, and async runs debit + * the async bucket (unlike v1's known sync-bucket bug). + */ +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: workflowId } = await context.params + + let userId: string + let isPublicApiAccess = false + let apiKeyType: 'personal' | 'workspace' | undefined + let apiKeyWorkspaceId: string | undefined + + const auth = await authenticateV1Request(req) + if (auth.authenticated && auth.userId) { + userId = auth.userId + apiKeyType = auth.keyType + apiKeyWorkspaceId = auth.workspaceId + } else { + if (req.headers.has('x-api-key')) { + return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') + } + const [wf] = await db + .select({ + isPublicApi: workflowTable.isPublicApi, + isDeployed: workflowTable.isDeployed, + userId: workflowTable.userId, + workspaceId: workflowTable.workspaceId, + }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) { + return v2Error('UNAUTHORIZED', 'Unauthorized') + } + try { + await validatePublicApiAllowed(wf.userId, wf.workspaceId) + } catch (err) { + if (err instanceof PublicApiNotAllowedError) { + return v2Error('UNAUTHORIZED', 'Unauthorized') + } + throw err + } + userId = wf.userId + isPublicApiAccess = true + } + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const ticket = tryAdmit() + if (!ticket) { + return v2Error('RATE_LIMITED', 'Server is at capacity. Please retry shortly.', { + headers: { + 'Retry-After': ADMISSION_ERROR_DESCRIPTOR.GATE_CAPACITY.retryAfterSeconds.toString(), + }, + }) + } + + try { + const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { + maxBodyBytes: 10 * 1024 * 1024, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + if (body.async && isPublicApiAccess) { + return v2Error('BAD_REQUEST', 'Async execution requires an API key') + } + if (body.async && body.stream) { + return v2Error('BAD_REQUEST', 'async and stream cannot be combined') + } + if ( + body.async && + (body.selectedOutputs?.length || + body.includeThinking || + body.includeToolCalls || + body.includeFileBase64 !== undefined || + body.base64MaxBytes !== undefined) + ) { + return v2Error( + 'BAD_REQUEST', + 'Async execution does not support streaming or output-shaping options' + ) + } + if ( + hasAgentStreamPolicy({ + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) && + !clientAcceptsAgentStreamProtocol(req.headers) + ) { + return v2Error( + 'BAD_REQUEST', + `includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.` + ) + } + + /** Idempotent execution ids are a keyed-caller feature; anonymous callers must not probe the claim table. */ + let requestedExecutionId: string | undefined + const executionIdHeader = req.headers.get(WORKFLOW_EXECUTION_ID_HEADER) + if (executionIdHeader !== null && !isPublicApiAccess) { + const headerValidation = executionIdSchema.safeParse(executionIdHeader) + if (!headerValidation.success) { + return v2Error('BAD_REQUEST', 'Invalid execution ID header') + } + requestedExecutionId = headerValidation.data + } + + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + // Mask authorization failures as 404 so cross-workspace existence never leaks. + if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + const workflowRecord = workflowAuthorization.workflow + + if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + if (apiKeyType === 'personal' && workflowRecord.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId) + if (!settings?.allowPersonalApiKeys) { + return v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace') + } + } + + const result = await executeWorkflowService({ + workflowId, + userId, + input: body.input ?? {}, + triggerType: 'api', + requestId, + executionId: requestedExecutionId, + useAuthenticatedUserAsActor: apiKeyType === 'personal', + workflowRecord, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + rateLimitCounter: body.async ? 'async' : 'sync', + abortSignal: req.signal, + mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) + + if (!result.ok) { + return serviceFailureResponse(result.failure) + } + + if ('stream' in result) { + // SSE: pass the stream through byte-for-byte with its own headers. + return result.stream + } + + if ('queued' in result) { + return v2Data( + { + executionId: result.executionId, + statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${result.executionId}`, + }, + { status: 202, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + ) + } + + if (result.aborted === 'client') { + return v2Error('CLIENT_CLOSED_REQUEST', 'Client cancelled request', { + details: { executionId: result.executionId }, + }) + } + + return v2Data( + { + executionId: result.executionId, + workflowId: result.workflowId, + status: result.status, + output: result.output ?? null, + error: result.error, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.durationMs, + }, + { headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + ) + } catch (error) { + logger.error(`[${requestId}] v2 execute failed`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + ticket.release() + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts new file mode 100644 index 00000000000..2d3ebe1166e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' + +const logger = createLogger('V2CancelExecutionAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** POST /api/v2/workflows/[id]/executions/[executionId]/cancel */ +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => { + const parsed = await parseRequest(v2CancelWorkflowExecutionContract, req, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + + const access = await resolveV2WorkflowAccess(req, workflowId, 'write') + if (!access.ok) return access.response + + try { + logger.info('Cancel execution requested', { workflowId, executionId, userId: access.userId }) + + const result = await cancelWorkflowExecution({ + executionId, + workflowId, + userId: access.userId, + workspaceId: access.workflow.workspaceId ?? undefined, + }) + + return v2Data(result) + } catch (error) { + logger.error('Failed to cancel execution', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts new file mode 100644 index 00000000000..c0e96fc8080 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } = + vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetJob: vi.fn(), + mockGetWorkflowExecutionStatus: vi.fn(), + mockCancel: vi.fn(), + })) + +vi.mock('@/app/api/v1/auth', () => ({ + authenticateV1Request: mockAuthenticateV1Request, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }), +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus, +})) + +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: mockCancel, +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { POST as cancelPost } from './cancel/route' +import { GET } from './route' + +const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission + +const workflowRecord = { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', +} + +function callStatus(query = '') { + const req = createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/v2/workflows/workflow-1/executions/exec-1${query}` + ) + return GET(req, { params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }) }) +} + +describe('v2 executions status + cancel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + }) + + it('returns the execution resource with a structured error', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue({ + executionId: 'exec-1', + workflowId: 'workflow-1', + status: 'failed', + trigger: 'api', + level: 'error', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: '2026-07-31T00:00:05.000Z', + totalDurationMs: 5000, + paused: null, + cost: { total: 0.02 }, + error: 'Send Email: Invalid credentials', + finalOutput: null, + blockOutputs: null, + }) + + const res = await callStatus() + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('failed') + expect(body.data.error.code).toBe('EXECUTION_FAILED') + expect(body.data.error.message).toBe('Send Email: Invalid credentials') + expect(body.data.durationMs).toBe(5000) + }) + + it('backfills queued status from the job queue before the log row exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue(null) + mockGetJob.mockResolvedValue({ + status: 'pending', + metadata: { workflowId: 'workflow-1' }, + }) + + const res = await callStatus() + + expect(res.status).toBe(200) + expect((await res.json()).data.status).toBe('queued') + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1') + }) + + it('404s when neither a log row nor a matching job exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue(null) + mockGetJob.mockResolvedValue(null) + + const res = await callStatus() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks cross-workspace access as 404', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'other-workspace', + }) + + const res = await callStatus() + + expect(res.status).toBe(404) + expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled() + }) + + it('cancels through the shared lib and returns the tightened result', async () => { + mockCancel.mockResolvedValue({ + success: true, + executionId: 'exec-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + + const req = createMockRequest('POST', undefined, {}) + const res = await cancelPost(req, { + params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toMatchObject({ success: true, reason: 'recorded' }) + expect(mockCancel).toHaveBeenCalledWith({ + executionId: 'exec-1', + workflowId: 'workflow-1', + userId: 'key-user-1', + workspaceId: 'workspace-1', + }) + }) + + it('401s without an API key (no session/anonymous path on executions)', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + + const res = await callStatus() + + expect(res.status).toBe(401) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts new file mode 100644 index 00000000000..d38da03a1b5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowExecutionStatus, + v2GetWorkflowExecutionContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { getJobQueue } from '@/lib/core/async-jobs' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, + FunctionalOutputsUnavailableError, +} from '@/lib/logs/execution/functional-outputs' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' +import { classifyExecutionError } from '@/executor/utils/errors' + +const logger = createLogger('V2WorkflowExecutionStatusAPI') + +export const dynamic = 'force-dynamic' + +/** + * Maps the async job's phase onto the execution status enum for the window + * before the worker writes the durable log row. + */ +function jobStatusToExecutionStatus(jobStatus: string): V2WorkflowExecutionStatus['status'] | null { + switch (jobStatus) { + case 'pending': + return 'queued' + case 'processing': + return 'running' + case 'failed': + return 'failed' + case 'completed': + return 'completed' + default: + return null + } +} + +/** + * GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL + * for both sync and async runs. When no log row exists yet, the async job + * queue is consulted (deterministic job id) so a freshly-queued run reports + * `queued` instead of 404. + */ +export const GET = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ id: string; executionId: string }> } + ) => { + const parsed = await parseRequest(v2GetWorkflowExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + const { includeOutput, selectedOutputs } = parsed.data.query + + const access = await resolveV2WorkflowAccess(request, workflowId, 'read') + if (!access.ok) return access.response + + try { + const status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput, + selectedOutputs, + }) + + if (status) { + return v2Data({ + executionId: status.executionId, + workflowId: status.workflowId, + status: status.status, + trigger: status.trigger ?? null, + startedAt: status.startedAt, + endedAt: status.endedAt, + durationMs: status.totalDurationMs, + paused: status.paused, + cost: status.cost, + error: status.error ? classifyExecutionError(new Error(status.error)) : null, + output: status.finalOutput, + blockOutputs: status.blockOutputs, + }) + } + + // No log row yet — a queued/just-started async run. Backfilled from the + // job queue via the deterministic id; authz already ran above. + const jobQueue = await getJobQueue() + const job = await jobQueue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) + const jobWorkflowId = + job?.metadata && typeof job.metadata === 'object' + ? (job.metadata as { workflowId?: string }).workflowId + : undefined + const mapped = job ? jobStatusToExecutionStatus(job.status) : null + if (!job || jobWorkflowId !== workflowId || !mapped) { + return v2Error('NOT_FOUND', 'Execution not found') + } + + return v2Data({ + executionId, + workflowId, + status: mapped, + trigger: 'api', + startedAt: null, + endedAt: null, + durationMs: null, + paused: null, + cost: null, + error: + mapped === 'failed' && job.error ? classifyExecutionError(new Error(job.error)) : null, + output: null, + blockOutputs: null, + }) + } catch (error) { + if (error instanceof FunctionalOutputsUnavailableError) { + return v2Error('CONFLICT', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) + } + logger.error('Failed to fetch execution status', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts new file mode 100644 index 00000000000..c4caa0ec7d8 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -0,0 +1,102 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowExportAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/export + * + * Exports a workflow as a portable JSON envelope that + * `POST /api/v2/workflows/import` accepts verbatim. Payload assembly and the + * sanitization guarantees are documented on the shared + * {@link buildWorkflowExportPayload}; this route authenticates and renders the + * v2 envelope. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ExportWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const payload = await buildWorkflowExportPayload(workflowData) + if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const folderPath = folderPathForId(folderIndex, workflowData.folderId) + + recordAudit({ + workspaceId: workflowData.workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowData.id, + resourceName: workflowData.name, + description: `Exported workflow "${workflowData.name}" via the API`, + metadata: { + workspaceId: workflowData.workspaceId, + folderPath, + blocksCount: Object.keys(payload.state.blocks).length, + edgesCount: payload.state.edges.length, + }, + request, + }) + + return v2Data( + { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, + folderPath, + }, + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Workflow export error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..8d1cea75788 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return v2Error('BAD_REQUEST', message) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + requestId, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to roll back workflow') + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow rollback error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts new file mode 100644 index 00000000000..33de9864022 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -0,0 +1,348 @@ +/** + * @vitest-environment node + * + * Public v2 workflow update/delete: the 404 mask on an access failure (the + * caller never names a workspace, so a 403 would confirm the workflow exists), + * the 423 a workflow mutation lock produces, and the orchestration failure + * codes rendered in the v2 error envelope. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockPerformUpdateWorkflow, + mockPerformDeleteWorkflow, + mockAssertWorkflowMutable, + mockAssertFolderMutable, + mockLoadActiveFolderPathIndex, + WorkflowLockedErrorMock, + FolderLockedErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockPerformUpdateWorkflow: vi.fn(), + mockPerformDeleteWorkflow: vi.fn(), + mockAssertWorkflowMutable: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + WorkflowLockedErrorMock: class WorkflowLockedError extends Error { + status = 423 + }, + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performUpdateWorkflow: mockPerformUpdateWorkflow, + performDeleteWorkflow: mockPerformDeleteWorkflow, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, + assertWorkflowMutable: mockAssertWorkflowMutable, + assertFolderMutable: mockAssertFolderMutable, + WorkflowLockedError: WorkflowLockedErrorMock, + FolderLockedError: FolderLockedErrorMock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/workflows/input-format', () => ({ + extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, PATCH } from '@/app/api/v2/workflows/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: null, + workspaceId: 'workspace-1', + isDeployed: true, + deployedAt: new Date('2024-01-03T00:00:00Z'), + runCount: 12, + lastRunAt: new Date('2024-01-04T00:00:00Z'), + locked: false, + forkSyncExcluded: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), +} + +const UPDATED = { + id: 'wf-1', + name: 'Support Agent v2', + description: 'Handles tickets', + workspaceId: 'workspace-1', + folderId: null, + sortOrder: 0, + locked: false, + forkSyncExcluded: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-05T00:00:00Z'), + archivedAt: null, +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +const callDelete = () => + DELETE( + new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { method: 'DELETE' }), + routeContext() + ) + +describe('PATCH /api/v2/workflows/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), + pathById: new Map([['fld-1', '/Locked']]), + idByPath: new Map([['/Locked', 'fld-1']]), + }) + mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ name: 'Support Agent v2' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('423s the denial when the workflow is locked rather than failing with a 500', async () => { + mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('423s when the destination folder is locked', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPatch({ folderPath: '/Locked' }) + expect(res.status).toBe(423) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('404s a path outside the workspace without ever reading its lock state', async () => { + const res = await callPatch({ folderPath: '/Elsewhere' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('resolves the canonical path against the workflow workspace before mutability', async () => { + await callPatch({ folderPath: '/Locked' }) + + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'workspace-1', + 'workflow', + expect.any(Object) + ) + expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') + }) + + it('skips the containment check on a rename that does not move the workflow', async () => { + await callPatch({ name: 'Support Agent v2' }) + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + }) + + it('409s when the target name is taken in the destination folder', async () => { + mockPerformUpdateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Support Agent v2" already exists in this folder', + errorCode: 'conflict', + }) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('updates the workflow and carries the untouched deployment counters through', async () => { + const res = await callPatch({ name: 'Support Agent v2' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: { + id: 'wf-1', + name: 'Support Agent v2', + description: 'Handles tickets', + folderPath: '/', + workspaceId: 'workspace-1', + isDeployed: true, + deployedAt: '2024-01-03T00:00:00.000Z', + runCount: 12, + lastRunAt: '2024-01-04T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-05T00:00:00.000Z', + }, + }) + expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + userId: 'user-1', + workspaceId: 'workspace-1', + currentName: 'Support Agent', + currentFolderId: null, + name: 'Support Agent v2', + }) + ) + }) +}) + +describe('DELETE /api/v2/workflows/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is already archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('423s the denial when the workflow is locked rather than failing with a 500', async () => { + mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) + const res = await callDelete() + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('400s when it is the last workflow in the workspace', async () => { + mockPerformDeleteWorkflow.mockResolvedValue({ + success: false, + error: 'Cannot delete the only workflow in the workspace', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('only workflow') + }) + + it('archives the workflow and acknowledges the delete', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } }) + expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts new file mode 100644 index 00000000000..f184af03c30 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,260 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + getActiveWorkflowRecord, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowDetail, + type V2WorkflowListItem, + v2DeleteWorkflowContract, + v2GetWorkflowContract, + v2UpdateWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderPath: folderPathForId(folderIndex, workflowData.folderId), + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { name, description, folderPath } = parsed.data.body + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const resolution = + folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: workflowData.workspaceId, + resourceType: 'workflow', + path: folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const folderId = resolution?.folderId + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) + + const result = await performUpdateWorkflow({ + workflowId: id, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') + } + + const updated = result.workflow + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + /** + * Deployment and run counters are untouched by a metadata update, so they + * come from the record read above rather than a second query. + */ + const item: V2WorkflowListItem = { + id: updated.id, + name: updated.name, + description: updated.description, + folderPath: folderPathForId(folderIndex, updated.folderId), + workspaceId: updated.workspaceId ?? workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + } + + return v2Data(item, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return v2Error('LOCKED', error.message) + } + + logger.error(`[${requestId}] Workflow update error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + await assertWorkflowMutable(id) + + const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to delete workflow') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Workflow delete error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts new file mode 100644 index 00000000000..72e3811cb6e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + * + * Public v2 deployment-version detail: the 404 mask on an access failure, the + * coerced numeric version param, and the pinned workflow state it serves. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockGetWorkflowDeploymentVersion, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockGetWorkflowDeploymentVersion: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } + +const DEPLOYED_STATE = { blocks: {}, edges: [], loops: {}, parallels: {} } + +const VERSION_ROW = { + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: new Date('2024-01-03T00:00:00Z'), + state: DEPLOYED_STATE, +} + +const routeContext = (version = '3') => ({ params: Promise.resolve({ id: 'wf-1', version }) }) +const callGet = (version = '3') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions/${version}`), + routeContext(version) + ) + +describe('GET /api/v2/workflows/[id]/versions/[version]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockGetWorkflowDeploymentVersion.mockResolvedValue(VERSION_ROW) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('400s on a non-numeric version', async () => { + const res = await callGet('latest') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('404s when the version does not exist on this workflow', async () => { + mockGetWorkflowDeploymentVersion.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('Deployment version not found') + }) + + it('returns the version with the workflow state it pins', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: { + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: '2024-01-03T00:00:00.000Z', + state: DEPLOYED_STATE, + }, + }) + expect(mockGetWorkflowDeploymentVersion).toHaveBeenCalledWith('wf-1', 3) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts new file mode 100644 index 00000000000..d8096bf5ea5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowVersionDetail, + v2GetWorkflowVersionContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowVersionDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version + * and the workflow state it pins. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-version-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkflowVersionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id, version } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const row = await getWorkflowDeploymentVersion(id, version) + if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') + + const detail: V2WorkflowVersionDetail = { + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + state: row.state as V2WorkflowVersionDetail['state'], + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow version fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts new file mode 100644 index 00000000000..53025c2d07d --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment node + * + * Public v2 deployment-version listing: the 404 mask on an access failure, the + * public projection (no raw `createdBy` user id), and the version-keyed cursor. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockListWorkflowVersions, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockListWorkflowVersions: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: mockListWorkflowVersions, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/[id]/versions/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } + +function buildVersion(version: number, overrides: Record = {}) { + return { + id: `dv-${version}`, + version, + name: null, + description: null, + isActive: false, + createdAt: new Date(`2024-01-0${version}T00:00:00Z`), + createdBy: 'user-9', + deployedByName: 'Ada Lovelace', + latestOperationStatus: null, + ...overrides, + } +} + +const ALL_VERSIONS = [ + buildVersion(3, { isActive: true, name: 'Escalation branch', latestOperationStatus: 'active' }), + buildVersion(2), + buildVersion(1), +] + +const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) +const callGet = (query = '') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions${query}`), + routeContext() + ) + +describe('GET /api/v2/workflows/[id]/versions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + /** + * Stands in for the keyset query the helper now runs, so the route's + * has-more probe and cursor round-trip are exercised against realistic + * `limit`/`afterVersion` behavior rather than a fixed array. + */ + mockListWorkflowVersions.mockImplementation( + async (_workflowId: string, options: { limit?: number; afterVersion?: number } = {}) => { + let versions = ALL_VERSIONS + if (options.afterVersion !== undefined) { + versions = versions.filter((row) => row.version < options.afterVersion!) + } + if (options.limit !== undefined) versions = versions.slice(0, options.limit) + return { versions } + } + ) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('400s on an out-of-range limit', async () => { + const res = await callGet('?limit=0') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('returns the public version shape newest-first, without the raw creator id', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toHaveLength(3) + expect(body.data[0]).toEqual({ + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: '2024-01-03T00:00:00.000Z', + deployedBy: 'Ada Lovelace', + latestOperationStatus: 'active', + }) + expect(body.data[0]).not.toHaveProperty('createdBy') + // Paging is pushed into the helper — the route never reads the full set. + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { + limit: 51, + afterVersion: undefined, + }) + }) + + it('bounds the read to one page plus the has-more probe', async () => { + await callGet('?limit=2') + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { + limit: 3, + afterVersion: undefined, + }) + }) + + it('pushes the cursor down to the helper as a keyset bound', async () => { + const cursor = Buffer.from(JSON.stringify({ version: 3 })).toString('base64') + await callGet(`?limit=2&cursor=${encodeURIComponent(cursor)}`) + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { limit: 3, afterVersion: 3 }) + }) + + it('400s a structurally invalid cursor instead of silently truncating the list', async () => { + // Decodes to valid JSON with no numeric `version` — the shape that would + // otherwise filter every row out and report a clean end-of-list. + const bogus = Buffer.from(JSON.stringify({ offset: 2 })).toString('base64') + const res = await callGet(`?cursor=${encodeURIComponent(bogus)}`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('400s a cursor that is not decodable at all', async () => { + const res = await callGet('?cursor=not-a-cursor') + expect(res.status).toBe(400) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('pages with a version-keyed cursor', async () => { + const first = await callGet('?limit=2') + const firstBody = await first.json() + + expect(firstBody.data.map((v: { version: number }) => v.version)).toEqual([3, 2]) + expect(firstBody.nextCursor).toEqual(expect.any(String)) + + const second = await callGet(`?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`) + const secondBody = await second.json() + + expect(secondBody.data.map((v: { version: number }) => v.version)).toEqual([1]) + expect(secondBody.nextCursor).toBeNull() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts new file mode 100644 index 00000000000..82c26667795 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -0,0 +1,111 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowVersion, + v2ListWorkflowVersionsContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowVersionsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor over the dense, strictly-descending version number. */ +interface WorkflowVersionCursor { + version: number +} + +/** + * GET /api/v2/workflows/[id]/versions — List a workflow's deployment versions, + * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback` + * accepts, so a caller no longer has to guess a version number. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-versions') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkflowVersionsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { limit, cursor } = parsed.data.query + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + /** + * A cursor that decodes to anything other than a version number is + * rejected rather than ignored: comparing every row against a missing + * `version` yields an empty page with `nextCursor: null`, which reads to + * the caller as a clean end-of-list while versions are still pending. + */ + const after = cursor ? decodeCursor(cursor) : null + if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + return v2Error('BAD_REQUEST', 'Invalid cursor') + } + + // One extra row is the has-more probe, matching the other v2 cursor lists. + const { versions: rows } = await listWorkflowVersions(id, { + limit: limit + 1, + afterVersion: after?.version, + }) + + const hasMore = rows.length > limit + const page = rows.slice(0, limit) + + const data: V2WorkflowVersion[] = page.map((row) => ({ + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + deployedBy: row.deployedByName, + // The shared helper widens the operation-status pg enum to `string`. + latestOperationStatus: + row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + })) + + const nextCursor = + hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow versions fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts new file mode 100644 index 00000000000..696286333ed --- /dev/null +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockLoadActiveFolderPathIndex, + mockListActiveFolderRows, + mockCreateFolderAtPath, + mockRelocateFolderByPath, + mockDeleteFolderByPath, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockListActiveFolderRows: vi.fn(), + mockCreateFolderAtPath: vi.fn(), + mockRelocateFolderByPath: vi.fn(), + mockDeleteFolderByPath: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, + listActiveFolderRows: mockListActiveFolderRows, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPath: mockCreateFolderAtPath, + relocateFolderByPath: mockRelocateFolderByPath, + deleteFolderByPath: mockDeleteFolderByPath, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/workflows/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const FOLDER_ID = 'internal-folder-id' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const folder = { + id: FOLDER_ID, + resourceType: 'workflow' as const, + name: 'Reports', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, +} + +function pathIndex(path = '/Reports') { + return { + rowById: new Map([[FOLDER_ID, folder]]), + pathById: new Map([[FOLDER_ID, path]]), + idByPath: new Map([[path, FOLDER_ID]]), + } +} + +function request(method: string, path: string, body?: Record) { + return new NextRequest(`http://localhost:3000${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) +} + +describe('/api/v2/workflows/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex()) + mockListActiveFolderRows.mockResolvedValue([folder]) + mockCreateFolderAtPath.mockResolvedValue({ + success: true, + folder, + path: '/Reports', + }) + mockRelocateFolderByPath.mockResolvedValue({ + success: true, + folder, + path: '/Reports', + }) + mockDeleteFolderByPath.mockResolvedValue({ + success: true, + path: '/Reports', + deletedItems: { folders: 1, workflows: 2 }, + }) + }) + + it('lists only root children when parentPath is root and never exposes database ids', async () => { + const response = await GET( + request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2F`) + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { + parentId: null, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }) + expect(body.data).toEqual([ + { + name: 'Reports', + path: '/Reports', + parentPath: '/', + locked: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + }) + + it('omits the parent filter to list folders from the whole tree', async () => { + await GET(request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}`)) + + expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { + parentId: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }) + }) + + it('creates a folder from a canonical path and rejects internal ids', async () => { + const created = await POST( + request('POST', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + }) + ) + + expect(created.status).toBe(201) + expect(mockCreateFolderAtPath).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + path: '/Reports', + }) + + const rejected = await POST( + request('POST', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + folderId: FOLDER_ID, + }) + ) + expect(rejected.status).toBe(400) + }) + + it('relocates one folder by source and destination paths', async () => { + mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex('/Archive')) + const response = await PATCH( + request('PATCH', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive', + }) + ) + + expect(response.status).toBe(200) + expect(mockRelocateFolderByPath).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + path: '/Reports', + destinationPath: '/Archive', + }) + }) + + it('requires an explicit recursive delete choice', async () => { + const missing = await DELETE( + request('DELETE', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports`) + ) + expect(missing.status).toBe(400) + expect(mockDeleteFolderByPath).not.toHaveBeenCalled() + + const deleted = await DELETE( + request( + 'DELETE', + `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ) + ) + expect(deleted.status).toBe(200) + expect(await deleted.json()).toEqual({ + data: { + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, workflows: 2 }, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts new file mode 100644 index 00000000000..bb791ec6dce --- /dev/null +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -0,0 +1,186 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateWorkflowFolderContract, + v2DeleteWorkflowFolderContract, + v2ListWorkflowFoldersContract, + v2RelocateWorkflowFolderContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListWorkflowFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'workflow', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, true)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts new file mode 100644 index 00000000000..e70da75d915 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + importWorkflowIntoWorkspace, + MAX_IMPORT_BODY_BYTES, +} from '@/lib/workflows/operations/import-workflow' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + type V2ErrorCode, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowImportAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const ERROR_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 423: 'LOCKED', + 500: 'INTERNAL_ERROR', +} + +/** + * POST /api/v2/workflows/import + * + * Creates a new workflow in the target workspace from an export payload + * produced by `GET /api/v2/workflows/{id}/export`. The shared + * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this + * route authenticates and renders the v2 envelope. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ImportWorkflowContract, + request, + {}, + { + maxBodyBytes: MAX_IMPORT_BODY_BYTES, + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, folderPath, name, description } = parsed.data.body + + logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { + userId, + folderPath, + }) + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'workflow', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const result = await importWorkflowIntoWorkspace({ + workspaceId, + folderId: resolution.folderId ?? undefined, + name, + description, + workflow: parsed.data.body.workflow, + userId, + requestId, + }) + + if (!result.success) { + return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { + status: result.status, + details: result.details, + }) + } + + return v2Data( + { + id: result.workflow.id, + name: result.workflow.name, + description: result.workflow.description, + workspaceId: result.workflow.workspaceId, + folderPath: folderPathForId(resolution.index, result.workflow.folderId), + createdAt: result.workflow.createdAt.toISOString(), + updatedAt: result.workflow.updatedAt.toISOString(), + }, + { rateLimit, status: 201 } + ) + } catch (error) { + logger.error(`[${requestId}] Workflow import error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts new file mode 100644 index 00000000000..404b820ac89 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -0,0 +1,63 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { NextRequest, NextResponse } from 'next/server' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Error } from '@/app/api/v2/lib/response' + +type WorkflowRecord = typeof workflowTable.$inferSelect + +export type V2WorkflowAccess = + | { + ok: true + userId: string + keyType: 'personal' | 'workspace' | undefined + workflow: WorkflowRecord + } + | { ok: false; response: NextResponse } + +/** + * X-API-Key auth + workflow authorization for the v2 execution sub-resources. + * Authorization failures and workspace-key scope mismatches are masked as 404 + * so cross-workspace workflow existence never leaks; personal keys honor the + * workspace's `allowPersonalApiKeys` setting. + */ +export async function resolveV2WorkflowAccess( + request: NextRequest, + workflowId: string, + action: 'read' | 'write' +): Promise { + const auth = await authenticateV1Request(request) + if (!auth.authenticated || !auth.userId) { + return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } + } + + const gate = await v2ApiGateError(auth.userId) + if (gate) return { ok: false, response: gate } + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action, + }) + if (!authorization.allowed || !authorization.workflow) { + return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } + } + const workflow = authorization.workflow as WorkflowRecord + + if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) { + return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } + } + if (auth.keyType === 'personal' && workflow.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflow.workspaceId) + if (!settings?.allowPersonalApiKeys) { + return { + ok: false, + response: v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace'), + } + } + } + + return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow } +} diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts new file mode 100644 index 00000000000..9ab8c6575ae --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -0,0 +1,434 @@ +/** + * @vitest-environment node + * + * Public v2 workflow list: the search/sort/filter convention, and the keyset + * cursor's binding to the sort it was minted under. The assertions look at the + * WHERE/ORDER BY the route hands drizzle, because that is the whole point of + * the change — a search must narrow the query, not the result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockPerformCreateWorkflow, + mockAssertFolderMutable, + mockLoadActiveFolderPathIndex, + FolderLockedErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformCreateWorkflow: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mockPerformCreateWorkflow, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: FolderLockedErrorMock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/workflows/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + name: 'Daily digest', + description: null, + folderId: null, + workspaceId: WS, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`)) + +/** The condition nodes the route passed to `.where()` on the last query. */ +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +/** + * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw + * column, so the mocked `sql` fragment carries the column in its interpolated + * values rather than being the column itself. + */ +const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0] + +describe('GET /api/v2/workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) + }) + + it('narrows the query with a case-insensitive substring match on the name', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + const res = await callList(`workspaceId=${WS}&search=digest`) + + expect(res.status).toBe(200) + const search = lastConditions().find((c) => c.type === 'ilike') + expect(search).toMatchObject({ column: schemaMock.workflow.name, pattern: '%digest%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + queueTableRows(schemaMock.workflow, []) + + await callList(`workspaceId=${WS}&search=${encodeURIComponent('100%_x')}`) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%100\\%\\_x%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}`) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect( + lastConditions().some( + (condition) => + condition.type === 'isNull' && condition.column === schemaMock.workflow.folderId + ) + ).toBe(true) + }) + + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('defaults to the workspace position ordering', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}`) + + const orderBy = lastOrderBy() + expect(orderBy.map((e: { type: string }) => e.type)).toEqual(['asc', 'asc', 'asc']) + expect(orderBy[0].column).toBe(schemaMock.workflow.sortOrder) + expect(truncatedColumnOf(orderBy[1])).toBe(schemaMock.workflow.createdAt) + expect(orderBy[2].column).toBe(schemaMock.workflow.id) + }) + + it('orders by the requested field and direction', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}&sortBy=name&sortOrder=desc`) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workflow.name }, + { type: 'desc', column: schemaMock.workflow.id }, + ]) + }) + + it('combines a filter with a cursor into one consistent page', async () => { + queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2', name: 'Zebra' })]) + + const first = await callList(`workspaceId=${WS}&search=a&sortBy=name&limit=1`) + const body = await first.json() + + expect(body.data).toHaveLength(1) + expect(body.nextCursor).not.toBeNull() + + queueTableRows(schemaMock.workflow, [buildRow({ id: 'wf_2', name: 'Zebra' })]) + const second = await callList( + `workspaceId=${WS}&search=a&sortBy=name&limit=1&cursor=${encodeURIComponent(body.nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = lastConditions() + // The filter survives the cursor page, and the keyset resumes from the last row. + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%a%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + it('terminates pagination once a filtered page is not full', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + const res = await callList(`workspaceId=${WS}&search=digest&limit=50`) + + expect((await res.json()).nextCursor).toBeNull() + }) + + it('400s when a cursor is replayed under a different sort', async () => { + queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2' })]) + + const first = await callList(`workspaceId=${WS}&sortBy=name&limit=1`) + const { nextCursor } = await first.json() + vi.clearAllMocks() + + const res = await callList( + `workspaceId=${WS}&sortBy=createdAt&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on a malformed cursor instead of silently restarting from page one', async () => { + const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) +}) + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const CREATED = { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + workspaceId: 'workspace-1', + folderId: null, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + startBlockId: 'block-1', + subBlockValues: {}, +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'Support Agent', + description: 'Handles tickets', +} + +function callPost(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/workflows', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +describe('POST /api/v2/workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), + pathById: new Map([['fld-1', '/Locked']]), + idByPath: new Map([['/Locked', 'fld-1']]), + }) + mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPost(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s when name is missing', async () => { + const res = await callPost({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s on an unknown body field', async () => { + const res = await callPost({ ...VALID_BODY, sortOrder: 3 }) + expect(res.status).toBe(400) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('requires write access on the target workspace', async () => { + await callPost(VALID_BODY) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'write' + ) + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('423s when the destination folder is locked', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPost({ ...VALID_BODY, folderPath: '/Locked' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('404s a path outside the workspace without ever reading its lock state', async () => { + const res = await callPost({ ...VALID_BODY, folderPath: '/Elsewhere' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('resolves the canonical path before checking mutability', async () => { + await callPost({ ...VALID_BODY, folderPath: '/Locked' }) + + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'workspace-1', + 'workflow', + expect.any(Object) + ) + expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') + }) + + it('skips the containment check when no folder is supplied', async () => { + await callPost(VALID_BODY) + expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) + }) + + it('409s when the name is already taken in the target folder', async () => { + mockPerformCreateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Support Agent" already exists in this folder', + errorCode: 'conflict', + }) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the workflow and returns 201 with the public shape', async () => { + const res = await callPost(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body).toEqual({ + data: { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + folderPath: '/', + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + }) + expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: null, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts new file mode 100644 index 00000000000..b314a1500de --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,272 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowListItem, + type V2WorkflowSortBy, + v2CreateWorkflowContract, + v2ListWorkflowsContract, +} from '@/lib/api/contracts/v2/workflows' +import { + encodeKeyset, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + resolveFolderPathIdentity, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type WorkflowRow = { + id: string + name: string + sortOrder: number + runCount: number + createdAt: Date + updatedAt: Date +} + +/** + * The keysets behind the sortable workflow fields. `satisfies` makes the map + * total over the contract enum, so a new sortable field cannot ship without an + * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which + * is what keeps a page boundary inside a run of equal values stable. + * + * `position` keeps its historical three-part ordering: workflows share a + * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle + * every workspace's default list. + */ +const workflowId = textKey(workflow.id, (row) => row.id) +const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt) + +const WORKFLOW_SORTS = { + position: [ + numberKey(workflow.sortOrder, (row) => row.sortOrder), + workflowCreatedAt, + workflowId, + ], + name: [textKey(workflow.name, (row) => row.name), workflowId], + createdAt: [workflowCreatedAt, workflowId], + updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId], + runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId], +} satisfies Record[]> + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folderIndex = await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + const folderId = + params.folderPath === undefined + ? undefined + : resolveFolderPathId(folderIndex, params.folderPath) + if (params.folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const sortKey = cursorSortKey(params.sortBy, params.sortOrder) + const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] + const decoded = decodeSortedCursor(params.cursor, sortKey) + if (decoded.status === 'invalid') return v2CursorSortError() + + // `null` here is a cursor whose values don't fit this sort — a client error, not an empty page. + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(keys, decoded.keys, params.sortOrder) : undefined + if (resumeAfter === null) return v2CursorSortError() + + const conditions = [ + eq(workflow.workspaceId, params.workspaceId), + isNull(workflow.archivedAt), + params.folderPath === undefined + ? undefined + : folderId === null + ? isNull(workflow.folderId) + : folderId === undefined + ? undefined + : eq(workflow.folderId, folderId), + params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, + searchFilter(workflow.name, params.search), + resumeAfter, + ] + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(...conditions)) + .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + const last = data.at(-1) + const nextCursor = + hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + folderPath: folderPathForId(folderIndex, w.folderId), + workspaceId: w.workspaceId ?? params.workspaceId, + isDeployed: w.isDeployed, + deployedAt: w.deployedAt?.toISOString() ?? null, + runCount: w.runCount, + lastRunAt: w.lastRunAt?.toISOString() ?? null, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + })) + + return v2CursorList(formatted, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflows fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/workflows — Create an empty workflow in a workspace. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateWorkflowContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, folderPath } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'workflow', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + await assertFolderMutable(resolution.folderId) + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId: resolution.folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') + } + + const created = result.workflow + const item: V2WorkflowListItem = { + id: created.id, + name: created.name, + description: created.description ?? null, + folderPath: folderPathForId(resolution.index, created.folderId), + workspaceId: created.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + } + + return v2Data(item, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Workflow create error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 4c7e1027161..4fb34919b76 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -15,7 +16,6 @@ import { performFullDeploy, performFullUndeploy, } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index d3c3337e62f..5d5300ec13d 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,10 +4,10 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getWorkflowDeploymentVersion, updateDeploymentVersionMetadata, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 476e600112c..72d0f71e04d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -1887,4 +1887,60 @@ describe('workflow execute async route', () => { : executionCall.snapshot expect(snapshot.metadata.enforceCredentialAccess).toBe(true) }) + describe('triggerType override gate', () => { + it.each([ + ['personal API key', EXECUTION_CALLERS[1]], + ['workspace API key', EXECUTION_CALLERS[2]], + ['public API', EXECUTION_CALLERS[3]], + ] as const)( + 'rejects caller-supplied triggerType "manual" from %s callers', + async (_name, caller) => { + configureExecutionCaller(caller) + const req = createMockRequest( + 'POST', + { hello: 'world', triggerType: 'manual' }, + { 'Content-Type': 'application/json', ...caller.headers } + ) + + const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'External callers cannot override triggerType', + }) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + } + ) + + it('accepts the redundant explicit "api" triggerType from API-key callers', async () => { + const caller = EXECUTION_CALLERS[1] + configureExecutionCaller(caller) + const req = createMockRequest( + 'POST', + { hello: 'world', triggerType: 'api' }, + { 'Content-Type': 'application/json', ...caller.headers, 'X-Execution-Mode': 'async' } + ) + + const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) + + expect(response.status).toBe(202) + }) + + it('still allows internal JWT callers to set triggerType', async () => { + const caller = EXECUTION_CALLERS[4] + configureExecutionCaller(caller) + const req = createMockRequest( + 'POST', + { hello: 'world', triggerType: 'workflow' }, + { 'Content-Type': 'application/json', ...caller.headers, 'X-Execution-Mode': 'async' } + ) + + const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) + + expect(response.status).toBe(202) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ triggerType: 'workflow' }) + ) + }) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 466aab44d48..9817acc81b0 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -28,8 +28,6 @@ import { } from '@/lib/copilot/async-runs/repository' import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' -import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' -import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' import { createTimeoutAbortController, getTimeoutErrorMessage, @@ -84,6 +82,7 @@ import { hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { @@ -120,7 +119,6 @@ import { } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { PublicApiNotAllowedError, @@ -148,8 +146,6 @@ import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/ const logger = createLogger('WorkflowExecuteAPI') const MAX_WORKFLOW_EXECUTE_BODY_BYTES = 10 * 1024 * 1024 const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3 -const ASYNC_ENQUEUE_ATTEMPTS = 2 -const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -405,170 +401,38 @@ function requirePreprocessedExecutionContext( } async function handleAsyncExecution(params: AsyncExecutionParams): Promise { - const { - requestId, - workflowId, - userId, - billingAttribution, - workspaceId, - input, - triggerType, - executionId, - callChain, - } = params - const asyncLogger = logger.withMetadata({ - requestId, - workflowId, - workspaceId, - userId, - executionId, - }) - - const correlation = { - executionId, - requestId, - source: 'workflow' as const, - workflowId, - triggerType, - } - - const payload: WorkflowExecutionPayload = { - workflowId, - userId, - billingAttribution, - workspaceId, - input, - triggerType, - executionId, - requestId, - correlation, - callChain, - executionMode: 'async', - admissionCompleted: true, - } + const enqueue = await enqueueWorkflowExecution(params) - let jobQueue: Awaited> - try { - jobQueue = await getJobQueue() - } catch (error) { - asyncLogger.error('Failed to initialize async execution queue', { - error: toError(error).message, - }) - await releaseExecutionSlot(executionId) + if (enqueue.outcome === 'rejected') { return { response: NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 }), retainExecutionClaim: false, } } - const deterministicJobId = `${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}` - const enqueueOptions = { - jobId: deterministicJobId, - metadata: { workflowId, workspaceId, userId, correlation }, - } - let jobId: string | undefined - let enqueueError: unknown - let acceptanceCouldBeUnknown = false - - for (let attempt = 1; attempt <= ASYNC_ENQUEUE_ATTEMPTS; attempt++) { - try { - jobId = await jobQueue.enqueue('workflow-execution', payload, enqueueOptions) - enqueueError = undefined - break - } catch (error) { - enqueueError = error - const classifiedError = isAsyncJobEnqueueError(error) ? error : undefined - const attemptAcceptance = classifiedError?.acceptance ?? 'unknown' - acceptanceCouldBeUnknown ||= attemptAcceptance === 'unknown' - asyncLogger.warn('Async workflow enqueue attempt failed', { - acceptance: attemptAcceptance, - attempt, - error: toError(error).message, - jobId: deterministicJobId, - }) - if (classifiedError?.retryable === false || attempt === ASYNC_ENQUEUE_ATTEMPTS) { - break - } - } - } - - if (!jobId) { - const acceptance = acceptanceCouldBeUnknown - ? 'unknown' - : isAsyncJobEnqueueError(enqueueError) - ? enqueueError.acceptance - : 'unknown' - asyncLogger.error('Failed to queue async execution', { - acceptance, - error: toError(enqueueError).message, - jobId: deterministicJobId, - }) - - if (acceptance === 'rejected') { - await releaseExecutionSlot(executionId) - return { - response: NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 }), - retainExecutionClaim: false, - } - } - + if (enqueue.outcome === 'ambiguous') { return { response: NextResponse.json( { error: 'Async execution queue acceptance could not be confirmed', code: 'ASYNC_ENQUEUE_AMBIGUOUS', - executionId, + executionId: enqueue.executionId, }, - { status: 503, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: executionId } } + { status: 503, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: enqueue.executionId } } ), retainExecutionClaim: true, } } - asyncLogger.info('Queued async workflow execution', { jobId }) - - if (shouldExecuteInline()) { - void (async () => { - let workerOwnsReservation = false - try { - await jobQueue.startJob(jobId) - workerOwnsReservation = true - const output = await executeWorkflowJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = toError(error).message - asyncLogger.error('Async workflow execution failed', { - jobId, - error: errorMessage, - }) - /** - * Before worker ownership transfers, no LoggingSession exists to - * release the route's reservation. - */ - if (!workerOwnsReservation) { - await releaseExecutionSlot(executionId) - } - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - asyncLogger.error('Failed to mark job as failed', { - jobId, - error: toError(markFailedError).message, - }) - } - } - })() - } - return { response: NextResponse.json( { success: true, async: true, - jobId, - executionId, + jobId: enqueue.jobId, + executionId: enqueue.executionId, message: 'Workflow execution queued', - statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`, + statusUrl: `${getBaseUrl()}/api/jobs/${enqueue.jobId}`, }, { status: 202 } ), @@ -810,6 +674,24 @@ async function handleExecutePost( ) } + /** + * External callers may not override the trigger type: `manual`/`chat` turn + * rate limiting off entirely (`preprocessExecution` defaults `checkRateLimit` + * from the trigger type), so a caller-supplied value is a quota bypass. + * `'api'` (the value they would get anyway) stays accepted for compatibility + * with callers that send it redundantly. + */ + if ( + (auth.authType === AuthType.API_KEY || isPublicApiAccess) && + body.triggerType !== undefined && + body.triggerType !== 'api' + ) { + return NextResponse.json( + { error: 'External callers cannot override triggerType' }, + { status: 400 } + ) + } + if (inputFromExecutionId && (isPublicApiAccess || auth.authType !== AuthType.SESSION)) { return NextResponse.json( { error: 'Stored execution input can only be reused by an authenticated session' }, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 0ca39eb6622..6f5656a8a7e 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,100 +1,14 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - type ExecutionCancellationRecordResult, - markExecutionCancelled, -} from '@/lib/execution/cancellation' -import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' -import { abortManualExecution } from '@/lib/execution/manual-cancellation' -import { captureServerEvent } from '@/lib/posthog/server' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' const logger = createLogger('CancelExecutionAPI') -const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 -const PAUSED_CANCELLATION_DB_RETRY_MS = 200 - -async function completePausedCancellationWithRetry( - executionId: string, - workflowId: string -): Promise { - for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { - try { - const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) - if (cancelled) { - logger.info('Paused execution cancelled in database', { executionId, attempt }) - return true - } - logger.warn('Paused execution cancellation could not be completed in database', { - executionId, - attempt, - }) - return false - } catch (error) { - logger.warn('Failed to complete paused execution cancellation in database', { - executionId, - attempt, - error, - }) - if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) { - await sleep(PAUSED_CANCELLATION_DB_RETRY_MS) - } - } - } - return false -} - -async function ensurePausedCancellationEventPublished( - executionId: string, - workflowId: string, - context: { workspaceId?: string; userId?: string } = {} -): Promise { - const metaState = await readExecutionMetaState(executionId) - if (metaState.status === 'found' && metaState.meta.status === 'cancelled') { - return true - } - - const writer = createExecutionEventWriter(executionId, { - workspaceId: context.workspaceId, - workflowId, - userId: context.userId, - }) - try { - await writer.writeTerminal( - { - type: 'execution:cancelled', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { duration: 0 }, - }, - 'cancelled' - ) - return true - } catch (error) { - logger.warn('Failed to publish paused execution cancellation event', { - executionId, - error, - }) - return false - } finally { - await writer.close().catch((error) => { - logger.warn('Failed to close paused cancellation event writer', { - executionId, - error, - }) - }) - } -} export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -135,182 +49,14 @@ export const POST = withRouteHandler( logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId }) - let pausedCancellationStarted = false - let pausedCancelled = false - try { - pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( - executionId, - workflowId - ) - } catch (error) { - logger.warn('Failed to begin paused execution cancellation in database', { - executionId, - error, - }) - } - const pendingPausedCancellation = pausedCancellationStarted - ? null - : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) - const isPausedCancellationPath = - pausedCancellationStarted || pendingPausedCancellation !== null - - const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath - ? { durablyRecorded: false, reason: 'redis_unavailable' } - : await markExecutionCancelled(executionId) - const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) - - if (pausedCancellationStarted) { - logger.info('Paused execution cancellation reserved in database', { executionId }) - } else if (cancellation.durablyRecorded) { - logger.info('Execution marked as cancelled in Redis', { executionId }) - } else if (locallyAborted) { - logger.info('Execution cancelled via local in-process fallback', { executionId }) - } else if (!pausedCancellationStarted) { - logger.warn('Execution cancellation was not durably recorded', { - executionId, - reason: cancellation.reason, - }) - } - - if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { - await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( - (error) => { - logger.warn('Failed to block queued paused resumes after cancellation', { - executionId, - error, - }) - } - ) - } else if (!isPausedCancellationPath) { - await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( - (error) => { - logger.warn( - 'Failed to clear paused cancellation intent after unsuccessful cancellation', - { - executionId, - error, - } - ) - } - ) - } - - let pausedCancellationPublished = false - let pausedCancellationPublishFailed = false - if (pausedCancellationStarted) { - pausedCancellationPublished = await ensurePausedCancellationEventPublished( - executionId, - workflowId, - { - workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, - userId: auth.userId, - } - ) - pausedCancellationPublishFailed = !pausedCancellationPublished - if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) - } - } else { - if (pendingPausedCancellation === 'cancelled') { - pausedCancellationPublished = await ensurePausedCancellationEventPublished( - executionId, - workflowId, - { - workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, - userId: auth.userId, - } - ) - pausedCancellationPublishFailed = !pausedCancellationPublished - pausedCancelled = pausedCancellationPublished - } else if (pendingPausedCancellation === 'cancelling') { - pausedCancellationPublished = await ensurePausedCancellationEventPublished( - executionId, - workflowId, - { - workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, - userId: auth.userId, - } - ) - pausedCancellationPublishFailed = !pausedCancellationPublished - if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) - } - } - } - - if ( - pausedCancellationPublishFailed && - (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') - ) { - await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( - (error) => { - logger.warn('Failed to clear paused cancellation intent after publish failure', { - executionId, - error, - }) - } - ) - } - - if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { - try { - await db - .update(workflowExecutionLogs) - .set({ status: 'cancelled', endedAt: new Date() }) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.status, 'running') - ) - ) - } catch (dbError) { - logger.warn('Failed to update execution log status directly', { - executionId, - error: dbError, - }) - } - } - - const success = - (isPausedCancellationPath - ? pausedCancelled && pausedCancellationPublished - : cancellation.durablyRecorded) || locallyAborted - - if (success) { - const workspaceId = workflowAuthorization.workflow?.workspaceId - captureServerEvent( - auth.userId, - 'workflow_execution_cancelled', - { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) - } - - const durablyRecorded = isPausedCancellationPath - ? pausedCancellationPublished - : pausedCancelled || cancellation.durablyRecorded - const reason = pausedCancellationPublishFailed - ? 'paused_event_publish_failed' - : !pausedCancelled && isPausedCancellationPath - ? 'paused_database_cancel_failed' - : pausedCancelled && !pausedCancellationPublished - ? 'paused_event_publish_failed' - : pausedCancelled || isPausedCancellationPath - ? 'recorded' - : cancellation.reason - - return NextResponse.json({ - success, + const result = await cancelWorkflowExecution({ executionId, - redisAvailable: - isPausedCancellationPath || pausedCancelled - ? pausedCancellationPublished - : cancellation.reason !== 'redis_unavailable', - durablyRecorded, - locallyAborted, - pausedCancelled, - reason, + workflowId, + userId: auth.userId, + workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, }) + + return NextResponse.json(result) } catch (error) { logger.error('Failed to cancel execution', { workflowId, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index d7923d662bc..0b0e17f6e43 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -1,89 +1,16 @@ -import { db } from '@sim/db' -import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - getWorkflowExecutionContract, - type WorkflowExecutionStatusResponse, -} from '@/lib/api/contracts/workflows' +import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - collectFunctionalBlockOutputs, FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, - type FunctionalExecutionDataSource, FunctionalOutputsUnavailableError, } from '@/lib/logs/execution/functional-outputs' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import type { PausePoint } from '@/executor/types' const logger = createLogger('WorkflowExecutionStatusAPI') - -type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' - -interface ExecutionDataShape extends FunctionalExecutionDataSource { - finalOutput?: { error?: string } & Record - error?: { message?: string } | string - completionFailure?: string -} - -function resolvePath(value: unknown, path: string[]): unknown { - let current: unknown = value - for (const segment of path) { - if (current == null || typeof current !== 'object') return undefined - current = (current as Record)[segment] - } - return current -} - -function pickSelectedOutputs( - selectedOutputs: string[], - blockOutputs: Map -): Record { - const out: Record = {} - for (const selector of selectedOutputs) { - const [head, ...rest] = selector.split('.') - if (!head) continue - if (!blockOutputs.has(head)) continue - const blockValue = blockOutputs.get(head) - out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest) - } - return out -} - -function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null { - const active = points.filter((p) => p.resumeStatus === 'paused') - if (active.length === 0) return null - return active.reduce((best, current) => { - if (!best) return current - if (!current.resumeAt) return best - if (!best.resumeAt) return current - return current.resumeAt < best.resumeAt ? current : best - }, null) -} - -function normalizePausePoints(raw: unknown): PausePoint[] { - if (!raw) return [] - if (Array.isArray(raw)) return raw as PausePoint[] - if (typeof raw === 'object') return Object.values(raw as Record) - return [] -} - -function extractError(executionData: unknown): string | null { - if (!executionData || typeof executionData !== 'object') return null - const data = executionData as ExecutionDataShape - if (typeof data.error === 'string') return data.error - if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') { - return data.error.message - } - if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error - if (typeof data.completionFailure === 'string') return data.completionFailure - return null -} - export const GET = withRouteHandler( async ( request: NextRequest, @@ -99,136 +26,31 @@ export const GET = withRouteHandler( return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } - const [logRow] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - status: workflowExecutionLogs.status, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, + let status + try { + status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput, + selectedOutputs, }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) - - if (!logRow) { - return NextResponse.json({ error: 'Execution not found' }, { status: 404 }) - } - - const [pausedRow] = await db - .select({ - id: pausedExecutions.id, - status: pausedExecutions.status, - pausePoints: pausedExecutions.pausePoints, - metadata: pausedExecutions.metadata, - resumedCount: pausedExecutions.resumedCount, - pausedAt: pausedExecutions.pausedAt, - nextResumeAt: pausedExecutions.nextResumeAt, - }) - .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) - .limit(1) - - const isCurrentlyPaused = - !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') - - let status: WorkflowExecutionStatusResponse['status'] - if (isCurrentlyPaused) { - status = 'paused' - } else { - status = logRow.status as LogStatus - } - - let paused: WorkflowExecutionStatusResponse['paused'] = null - if (isCurrentlyPaused && pausedRow) { - const points = normalizePausePoints(pausedRow.pausePoints) - const earliest = pickEarliestPausePoint(points) - const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata) - paused = { - pausedAt: pausedRow.pausedAt.toISOString(), - resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null, - pauseKind: earliest?.pauseKind ?? null, - blockedOnBlockId: earliest?.blockId ?? null, - automaticResumeWaitingReason: - automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null, - pausedExecutionId: pausedRow.id, - pausePointCount: points.length, - resumedCount: pausedRow.resumedCount, + } catch (error) { + if (error instanceof FunctionalOutputsUnavailableError) { + return NextResponse.json({ error: FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE }, { status: 409 }) } + throw error } - const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null - - // Heavy execution data may live in object storage; resolve the pointer - // before reading error / finalOutput / traceSpans (no-op for inline rows). - const executionData = (await materializeExecutionData( - logRow.executionData as Record | null, - { - workspaceId: logRow.workspaceId, - workflowId: logRow.workflowId, - executionId: logRow.executionId, - } - )) as ExecutionDataShape | undefined - - const error = status === 'failed' ? extractError(executionData) : null - - const finalOutput = - includeOutput && status === 'completed' && executionData - ? (executionData.finalOutput ?? null) - : null - - let blockOutputs: Record | null = null - if (selectedOutputs.length > 0) { - try { - blockOutputs = pickSelectedOutputs( - selectedOutputs, - collectFunctionalBlockOutputs(executionData) - ) - } catch (error) { - if (error instanceof FunctionalOutputsUnavailableError) { - return NextResponse.json( - { error: FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE }, - { status: 409 } - ) - } - throw error - } - } - - const response: WorkflowExecutionStatusResponse = { - executionId: logRow.executionId, - workflowId: logRow.workflowId ?? workflowId, - status, - trigger: logRow.trigger, - level: logRow.level, - startedAt: logRow.startedAt.toISOString(), - endedAt: logRow.endedAt?.toISOString() ?? null, - totalDurationMs: logRow.totalDurationMs ?? null, - paused, - cost, - error, - finalOutput, - blockOutputs, + if (!status) { + return NextResponse.json({ error: 'Execution not found' }, { status: 404 }) } - logger.debug('Fetched execution status', { workflowId, executionId, - status, - paused: !!paused, + status: status.status, + paused: !!status.paused, }) - return NextResponse.json(response) + return NextResponse.json(status) } ) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts new file mode 100644 index 00000000000..78f5cdb124f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockPerformUpdateContent } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockPerformUpdateContent: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performUpdateWorkspaceFileContent: mockPerformUpdateContent, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const RECORD = { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', + size: 5, + type: 'text/markdown', + uploadedBy: USER.id, + folderId: null, + folderPath: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-04T00:00:00.000Z'), +} + +const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } + +function createRequest(body: unknown, contentLength?: number): NextRequest { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/content`, + { + method: 'PUT', + headers: { + 'content-type': 'application/json', + ...(contentLength === undefined ? {} : { 'content-length': String(contentLength) }), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + } + ) +} + +describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: USER }) + mockGetUserEntityPermissions.mockResolvedValue('write') + mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + }) + + it('authenticates before parsing an invalid request body', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await PUT(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before parsing the request body', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + + const response = await PUT(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('rejects malformed base64 after authorization', async () => { + const response = await PUT( + createRequest({ content: 'not-base64!', encoding: 'base64' }), + routeContext + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte replacement', async () => { + const request = createRequest({ content: '', encoding: 'base64' }) + const response = await PUT(request, routeContext) + + expect(response.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + fileId: FILE_ID, + userId: USER.id, + content: '', + encoding: 'base64', + actorName: USER.name, + actorEmail: USER.email, + request, + }) + }) + + it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { + const response = await PUT( + createRequest({ content: 'TQ==', encoding: 'base64' }, 60 * 1024 * 1024), + routeContext + ) + + expect(response.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalled() + }) + + it('rejects a JSON body above the inline-content cap', async () => { + const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + error: `Request body exceeds the maximum allowed size of ${70 * 1024 * 1024} bytes`, + }) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index beece206917..a7d4934c783 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,12 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' +import { + updateWorkspaceFileContentContract, + workspaceFileParamsSchema, +} from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' +import { + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performUpdateWorkspaceFileContent, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -19,78 +27,53 @@ const logger = createLogger('WorkspaceFileContentAPI') */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { content, encoding } = parsed.data.body - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const buffer = - encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') - - const maxFileSizeBytes = 50 * 1024 * 1024 - if (buffer.length > maxFileSizeBytes) { - return NextResponse.json( - { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` }, - { status: 413 } - ) - } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const updatedFile = await updateWorkspaceFileContent( - workspaceId, - fileId, - session.user.id, - buffer + const paramsResult = workspaceFileParamsSchema.safeParse(await context.params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } ) + } + const { id: workspaceId, fileId } = paramsResult.data - logger.info(`Updated content for workspace file: ${updatedFile.name}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Updated content of file "${updatedFile.name}"`, - metadata: { contentSize: buffer.length }, - request, - }) + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - return NextResponse.json({ - success: true, - file: updatedFile, - }) - } catch (error) { - const errorMessage = toError(error).message || 'Failed to update file content' - const isNotFound = errorMessage.includes('File not found') - const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') - const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 + const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context, { + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + }) + if (!parsed.success) return parsed.response + const { content, encoding } = parsed.data.body - if (status === 500) { - logger.error('Error updating file content:', error) - } else { - logger.warn(errorMessage) - } + const result = await performUpdateWorkspaceFileContent({ + workspaceId, + fileId, + userId: session.user.id, + content, + encoding: encoding === 'base64' ? 'base64' : 'utf-8', + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) - return NextResponse.json({ success: false, error: errorMessage }, { status }) + if (!result.success || !result.file) { + return NextResponse.json( + { + success: false, + error: messageForOrchestrationError(result, 'Failed to update file content'), + }, + { status: statusForOrchestrationError(result.errorCode) } + ) } + + return NextResponse.json({ success: true, file: result.file }) } ) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index 0d6a09d6361..f5810627a1b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -1,23 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - getShareForResource, - ShareValidationError, - upsertFileShare, -} from '@/lib/public-shares/share-manager' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' @@ -31,40 +27,30 @@ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission === null) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + const parsed = await parseRequest(getFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params - const file = await getWorkspaceFile(workspaceId, fileId) - if (!file) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission === null) { + logger.warn(`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const share = await getShareForResource('file', fileId) - return NextResponse.json({ share }) - } catch (error) { - logger.error(`[${requestId}] Error fetching file share:`, error) + const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) + if (!result.success) { return NextResponse.json( - { error: getErrorMessage(error, 'Failed to fetch share') }, - { - status: 500, - } + { error: messageForOrchestrationError(result, 'Failed to fetch share') }, + { status: statusForOrchestrationError(result.errorCode) } ) } + + return NextResponse.json({ share: result.share ?? null }) } ) @@ -76,89 +62,45 @@ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(upsertFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { isActive, authType, password, allowedEmails, token } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const file = await getWorkspaceFile(workspaceId, fileId) - if (!file) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - // Enabling a share is gated by the org's access-control policy (both the - // master on/off and the per-auth-type allow-list); disabling is always - // allowed so users can still un-share after the policy is turned on. - if (isActive) { - // Validate the auth type that will ACTUALLY be persisted. upsertFileShare - // falls back to the existing share's authType when none is passed, so a bare - // re-enable must be checked against that stored mode — not 'public' — or a - // now-disallowed password/email/sso share could be silently reactivated. - const existingShare = await getShareForResource('file', fileId) - const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`) - return NextResponse.json({ error: error.message }, { status: 403 }) - } - throw error - } - } - - const share = await upsertFileShare({ - workspaceId, - fileId, - userId: session.user.id, - isActive, - authType, - password, - allowedEmails, - token, - }) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + const parsed = await parseRequest(upsertFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { isActive, authType, password, allowedEmails, token } = parsed.data.body - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: file.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - request, - }) + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - return NextResponse.json({ share }) - } catch (error) { - if (error instanceof ShareValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error updating file share:`, error) + const result = await performUpsertWorkspaceFileShare({ + workspaceId, + fileId, + userId: session.user.id, + isActive, + authType, + password, + allowedEmails, + token, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + + if (!result.success || !result.share) { return NextResponse.json( - { error: getErrorMessage(error, 'Failed to update share') }, - { - status: 500, - } + { error: messageForOrchestrationError(result, 'Failed to update share') }, + { status: statusForOrchestrationError(result.errorCode) } ) } + + return NextResponse.json({ share: result.share }) } ) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index 86df6e83f91..ecf7b17b281 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -3,12 +3,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - performRestoreWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' +import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolderRestoreAPI') @@ -38,7 +36,7 @@ export const POST = withRouteHandler( if (!result.success) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } const { folder, restoredItems } = result diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index 78232e8a704..079f25e8459 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -6,12 +6,12 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkspaceFileItems, performUpdateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -47,7 +47,7 @@ export const PATCH = withRouteHandler( if (!result.success || !result.folder) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } captureServerEvent( diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index 02de14dcf1e..ba3180cb609 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -6,13 +6,11 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { - performCreateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' +import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFoldersAPI') @@ -70,7 +68,7 @@ export const POST = withRouteHandler( if (!result.success || !result.folder) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } captureServerEvent( diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts deleted file mode 100644 index d69aa933389..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @vitest-environment node - */ -import { - authMockFns, - permissionsMock, - permissionsMockFns, - storageServiceMock, - storageServiceMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckStorageQuota, - mockGenerateWorkspaceFileKey, - mockResolveStorageBillingContext, - mockUseBlobStorage, -} = vi.hoisted(() => ({ - mockCheckStorageQuota: vi.fn(), - mockGenerateWorkspaceFileKey: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), - mockUseBlobStorage: { value: false }, -})) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuota, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, -})) - -vi.mock('@/lib/uploads/config', () => ({ - getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const STORAGE_CONTEXT = { - workspaceId: WS, - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -import { POST } from '@/app/api/workspaces/[id]/files/presigned/route' - -const params = (id = WS) => ({ params: Promise.resolve({ id }) }) - -const makeRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const validBody = { - fileName: 'video.mp4', - contentType: 'video/mp4', - fileSize: 10 * 1024 * 1024, -} - -describe('POST /api/workspaces/[id]/files/presigned', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockCheckStorageQuota.mockResolvedValue({ allowed: true }) - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) - mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`) - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({ - url: 'https://s3/presigned', - key: `workspace/${WS}/123-abc-video.mp4`, - uploadHeaders: { 'Content-Type': 'video/mp4' }, - }) - }) - - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(401) - }) - - it('returns 403 when user has read-only permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(403) - }) - - it('returns 400 for missing fileName', async () => { - const res = await POST(makeRequest({ ...validBody, fileName: '' }), params()) - expect(res.status).toBe(400) - }) - - it('returns 400 for negative fileSize', async () => { - const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params()) - expect(res.status).toBe(400) - }) - - it('accepts fileSize === 0 (empty new files)', async () => { - const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params()) - expect(res.status).toBe(200) - }) - - it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => { - const res = await POST( - makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }), - params() - ) - expect(res.status).toBe(413) - }) - - it('returns 413 when storage quota would be exceeded', async () => { - mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' }) - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(res.status).toBe(413) - expect(body.error).toBe('Over quota') - }) - - it('returns local fallback signal when cloud storage is not configured', async () => { - storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false) - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.directUploadSupported).toBe(false) - expect(body.presignedUrl).toBe('') - expect(body.fileInfo.name).toBe('video.mp4') - expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled() - }) - - it('issues a presigned URL bound to the workspace', async () => { - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.directUploadSupported).toBe(true) - expect(body.presignedUrl).toBe('https://s3/presigned') - expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`) - expect(body.fileInfo.path).toContain('?context=workspace') - expect(body.fileInfo.path).toContain('s3') - expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' }) - - expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4') - expect(mockResolveStorageBillingContext).toHaveBeenCalledWith(WS) - expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, validBody.fileSize) - expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith( - expect.objectContaining({ - context: 'workspace', - userId: 'user-1', - customKey: `workspace/${WS}/123-abc-video.mp4`, - metadata: { workspaceId: WS }, - }) - ) - }) - - it('serves blob path when blob storage is configured', async () => { - mockUseBlobStorage.value = true - try { - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(body.fileInfo.path).toContain('/blob/') - } finally { - mockUseBlobStorage.value = false - } - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts deleted file mode 100644 index 905ad938d98..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { - checkStorageQuotaForBillingContext, - resolveStorageBillingContext, -} from '@/lib/billing/storage' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getServeStoragePrefix } from '@/lib/uploads/config' -import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspacePresignedAPI') - -/** - * POST /api/workspaces/[id]/files/presigned - * Returns a presigned PUT URL for a workspace-scoped object key. The client - * uploads the bytes directly to S3/Blob, then calls /files/register to - * insert metadata. - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - const parsed = await parseRequest(workspacePresignedUploadContract, request, context) - if (!parsed.success) return parsed.response - const { params, body } = parsed.data - const workspaceId = params.id - const { fileName, contentType, fileSize, folderId } = body - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn(`User ${userId} lacks write permission for ${workspaceId}`) - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (fileSize > MAX_WORKSPACE_FILE_SIZE) { - return NextResponse.json( - { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, - { status: 413 } - ) - } - - let targetFolderId: string | null - try { - targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) - } catch (error) { - return NextResponse.json( - { error: getErrorMessage(error, 'Invalid target folder') }, - { status: 400 } - ) - } - - if (!hasCloudStorage()) { - logger.info(`Local storage detected, signaling API fallback for ${fileName}`) - return NextResponse.json({ - fileName, - presignedUrl: '', - fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType }, - directUploadSupported: false, - }) - } - - const storageBillingContext = await resolveStorageBillingContext(workspaceId) - const quotaCheck = await checkStorageQuotaForBillingContext(storageBillingContext, fileSize) - if (!quotaCheck.allowed) { - return NextResponse.json( - { error: quotaCheck.error || 'Storage limit exceeded' }, - { status: 413 } - ) - } - - const key = generateWorkspaceFileKey(workspaceId, fileName) - const presigned = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'workspace', - userId, - customKey: key, - expirationSeconds: 3600, - metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) }, - }) - - const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=workspace` - - logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`) - - return NextResponse.json({ - fileName, - presignedUrl: presigned.url, - fileInfo: { - path: finalPath, - key: presigned.key, - name: fileName, - size: fileSize, - type: contentType, - }, - uploadHeaders: presigned.uploadHeaders, - directUploadSupported: true, - }) - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts deleted file mode 100644 index cce56f7b8e8..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @vitest-environment node - */ -import { - auditMock, - auditMockFns, - authMockFns, - permissionsMock, - permissionsMockFns, - posthogServerMock, - posthogServerMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRegisterUploadedWorkspaceFile, mockParseWorkspaceFileKey, FileConflictErrorImpl } = - vi.hoisted(() => { - class FileConflictErrorImpl extends Error { - constructor(message: string) { - super(message) - this.name = 'FileConflictError' - } - } - return { - mockRegisterUploadedWorkspaceFile: vi.fn(), - mockParseWorkspaceFileKey: vi.fn(), - FileConflictErrorImpl, - } - }) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile, - parseWorkspaceFileKey: mockParseWorkspaceFileKey, - FileConflictError: FileConflictErrorImpl, -})) - -vi.mock('@/lib/posthog/server', () => posthogServerMock) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/audit', () => auditMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const VALID_KEY = `workspace/${WS}/123-abc-video.mp4` - -import { POST } from '@/app/api/workspaces/[id]/files/register/route' - -const params = (id = WS) => ({ params: Promise.resolve({ id }) }) - -const makeRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const validBody = { - key: VALID_KEY, - name: 'video.mp4', - contentType: 'video/mp4', -} - -describe('POST /api/workspaces/[id]/files/register', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockParseWorkspaceFileKey.mockImplementation((key: string) => { - const match = key.match(/^workspace\/([^/]+)\//) - return match ? match[1] : null - }) - mockRegisterUploadedWorkspaceFile.mockResolvedValue({ - file: { - id: 'wf_123', - name: 'video.mp4', - size: 10 * 1024 * 1024, - type: 'video/mp4', - url: '/api/files/serve/...', - key: VALID_KEY, - context: 'workspace', - }, - created: true, - }) - }) - - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(401) - }) - - it('returns 403 when user lacks write permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(403) - }) - - it('rejects keys belonging to a different workspace', async () => { - const otherWsKey = `workspace/00000000-0000-0000-0000-000000000000/123-abc-video.mp4` - const res = await POST(makeRequest({ ...validBody, key: otherWsKey }), params()) - const body = await res.json() - expect(res.status).toBe(400) - expect(body.error).toContain('does not belong') - expect(mockRegisterUploadedWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns 400 for empty key/name', async () => { - const res = await POST(makeRequest({ ...validBody, key: '' }), params()) - expect(res.status).toBe(400) - }) - - it('returns 404 when storage object is missing', async () => { - mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce( - new Error('Uploaded object not found in storage') - ) - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(404) - }) - - it('returns 409 on duplicate file conflict', async () => { - mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(new FileConflictErrorImpl('video.mp4')) - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(res.status).toBe(409) - expect(body.isDuplicate).toBe(true) - }) - - it('skips audit + analytics on idempotent re-register (created=false)', async () => { - mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ - file: { - id: 'wf_123', - name: 'video.mp4', - size: 10 * 1024 * 1024, - type: 'video/mp4', - url: '/api/files/serve/...', - key: VALID_KEY, - context: 'workspace', - }, - created: false, - }) - - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(200) - expect(posthogServerMockFns.mockCaptureServerEvent).not.toHaveBeenCalled() - expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() - }) - - it('finalizes upload, records audit and analytics', async () => { - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.success).toBe(true) - expect(body.file).toMatchObject({ id: 'wf_123', key: VALID_KEY }) - - expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - key: VALID_KEY, - originalName: 'video.mp4', - contentType: 'video/mp4', - }) - - expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'file_uploaded', - expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }), - expect.any(Object) - ) - expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( - expect.objectContaining({ - actorId: 'user-1', - workspaceId: WS, - }) - ) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts deleted file mode 100644 index 4ed0b90c285..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { - FileConflictError, - parseWorkspaceFileKey, - registerUploadedWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceRegisterAPI') - -/** - * POST /api/workspaces/[id]/files/register - * Finalize a direct-to-storage upload by inserting metadata, updating quota, - * and recording an audit log. Validates the storage key belongs to the - * caller's workspace to prevent cross-tenant key smuggling. - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - const parsed = await parseRequest(registerWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { params, body } = parsed.data - const workspaceId = params.id - const { key, name, contentType, folderId } = body - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn(`User ${userId} lacks write permission for ${workspaceId}`) - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (parseWorkspaceFileKey(key) !== workspaceId) { - logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`) - return NextResponse.json( - { error: 'Storage key does not belong to this workspace' }, - { status: 400 } - ) - } - - try { - const { file: userFile, created } = await registerUploadedWorkspaceFile({ - workspaceId, - userId, - key, - originalName: name, - contentType, - folderId, - }) - - if (created) { - logger.info(`Registered direct upload ${name} -> ${key}`) - - await notifyWorkspaceFilesChanged(workspaceId) - - captureServerEvent( - userId, - 'file_uploaded', - { workspace_id: workspaceId, file_type: contentType }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: name, - description: `Uploaded file "${name}"`, - metadata: { fileSize: userFile.size, fileType: contentType }, - request, - }) - } else { - logger.info(`Idempotent re-register for existing upload ${name} -> ${key}`) - } - - return NextResponse.json({ success: true, file: userFile }) - } catch (error) { - logger.error('Failed to register workspace file:', error) - - const errorMessage = getErrorMessage(error, 'Failed to register file') - const isDuplicate = - error instanceof FileConflictError || errorMessage.includes('already exists') - const isMissing = errorMessage.includes('not found in storage') - - const status = isDuplicate ? 409 : isMissing ? 404 : 500 - return NextResponse.json({ success: false, error: errorMessage, isDuplicate }, { status }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts index 9d71894ecfa..7ce4f9f1a4c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -1,143 +1,253 @@ /** - * Tests for the workspace files upload route's bounded multipart read. - * * @vitest-environment node */ -import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUploadWorkspaceFile, mockGetWorkspaceShares, mockRecordAudit } = vi.hoisted(() => ({ - mockUploadWorkspaceFile: vi.fn(), +const { + mockGetUserEntityPermissions, + mockGetWorkspaceShares, + mockListWorkspaceFiles, + mockPerformCreateWorkspaceFile, +} = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceShares: vi.fn(), - mockRecordAudit: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockPerformCreateWorkspaceFile: vi.fn(), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: mockUploadWorkspaceFile, - FileConflictError: class FileConflictError extends Error {}, +vi.mock('@/lib/public-shares/share-manager', () => ({ + getWorkspaceShares: mockGetWorkspaceShares, })) -vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, - } -}) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, +})) -vi.mock('@/lib/public-shares/share-manager', () => ({ - getWorkspaceShares: mockGetWorkspaceShares, +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, })) -vi.mock('@/lib/posthog/server', () => posthogServerMock) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) vi.mock('@/app/api/workflows/utils', () => ({ verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'), })) -vi.mock('@sim/audit', () => ({ - recordAudit: mockRecordAudit, - AuditAction: { FILE_UPLOADED: 'file_uploaded' }, - AuditResourceType: { FILE: 'file' }, -})) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' import { POST } from '@/app/api/workspaces/[id]/files/route' -const routeContext = { params: Promise.resolve({ id: WS }) } - -function buildFormData(file: File): FormData { - const formData = new FormData() - formData.append('file', file) - return formData +const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const CREATED_FILE = { + id: 'wf_created', + workspaceId: WORKSPACE_ID, + name: 'untitled.md', + key: `workspace/${WORKSPACE_ID}/untitled.md`, + path: '/api/files/serve/untitled.md?context=workspace', + size: 0, + type: 'text/markdown', + uploadedBy: USER.id, + folderId: null, + folderPath: null, + deletedAt: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-04T00:00:00.000Z'), } -/** - * Builds a pull-based stream that emits fixed-size chunks on demand, so the - * size-capped reader's `reader.cancel()` simply stops future `pull` calls - * instead of racing an external (e.g. undici FormData) chunk producer. - */ -function makeChunkedOverLimitBody( - chunkBytes: number, - chunkCount: number -): ReadableStream { - let emitted = 0 - return new ReadableStream({ - pull(controller) { - if (emitted >= chunkCount) { - controller.close() - return - } - emitted++ - controller.enqueue(new Uint8Array(chunkBytes)) - }, +const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function createRequest(body: unknown): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), }) } -describe('workspace files upload route', () => { +describe('POST /api/workspaces/[id]/files', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + authMockFns.mockGetSession.mockResolvedValue({ user: USER }) + mockGetUserEntityPermissions.mockResolvedValue('write') mockGetWorkspaceShares.mockResolvedValue(new Map()) - mockUploadWorkspaceFile.mockResolvedValue({ - id: 'file-1', - name: 'file.txt', - url: 'https://example.com/file.txt', - size: 11, - type: 'text/plain', - }) + mockListWorkspaceFiles.mockResolvedValue([]) + mockPerformCreateWorkspaceFile.mockResolvedValue({ success: true, file: CREATED_FILE }) }) - it('rejects a declared content-length above the limit before reading the body', async () => { - const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' })) - const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { - method: 'POST', - headers: { 'content-length': String(10 * 1024 * 1024) }, - body: formData, - }) + it('authenticates before parsing an invalid request body', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await POST(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) - const response = await POST(req, routeContext) - const data = await response.json() + it('authorizes the workspace before parsing the request body', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') - expect(response.status).toBe(413) - expect(data.error).toContain('exceeds maximum size') - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() }) - it('rejects a chunked body without content-length once the streamed size trips the cap', async () => { - const body = makeChunkedOverLimitBody(64 * 1024, 32) - const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { - method: 'POST', - body, - // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types - duplex: 'half', + it('rejects an invalid body after workspace authorization', async () => { + const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('Validation error') + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it.each(['read', null])( + 'requires write or admin permission (%s is rejected)', + async (permission) => { + mockGetUserEntityPermissions.mockResolvedValue(permission) + + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + } + ) + + it.each(['write', 'admin'])( + 'creates an empty file with defaults for %s users', + async (permission) => { + mockGetUserEntityPermissions.mockResolvedValue(permission) + const request = createRequest({ name: 'untitled.md' }) + + const response = await POST(request, routeContext) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body).toMatchObject({ success: true, file: { id: CREATED_FILE.id } }) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledTimes(1) + const params = mockPerformCreateWorkspaceFile.mock.calls[0][0] + expect(params).toMatchObject({ + workspaceId: WORKSPACE_ID, + userId: USER.id, + actorName: USER.name, + actorEmail: USER.email, + name: 'untitled.md', + contentType: 'text/markdown', + exactName: false, + }) + expect(params.folderId).toBeUndefined() + expect(params.content).toEqual(Buffer.alloc(0)) + expect(params.request).toBe(request) + } + ) + + it('decodes initialized base64 content and preserves folder and content type', async () => { + const content = Buffer.from([0, 1, 2, 255]) + const request = createRequest({ + name: 'data.bin', + contentType: 'application/octet-stream', + folderId: 'folder-1', + content: content.toString('base64'), + encoding: 'base64', + }) + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: true, + file: { + ...CREATED_FILE, + name: 'data.bin', + type: 'application/octet-stream', + size: content.length, + folderId: 'folder-1', + }, }) - expect(req.headers.get('content-length')).toBeNull() - const response = await POST(req, routeContext) - const data = await response.json() + const response = await POST(request, routeContext) + + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + name: 'data.bin', + contentType: 'application/octet-stream', + folderId: 'folder-1', + content, + exactName: false, + }) + ) + }) + + it('rejects malformed base64 after authorization and before orchestration', async () => { + const response = await POST( + createRequest({ name: 'data.bin', content: 'not-base64!', encoding: 'base64' }), + routeContext + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte file', async () => { + const response = await POST( + createRequest({ name: 'empty.bin', content: '', encoding: 'base64' }), + routeContext + ) - expect(response.status).toBe(413) - expect(data.error).toContain('exceeds maximum size') - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ content: Buffer.alloc(0) }) + ) }) - it('uploads a normal, well-under-limit file successfully', async () => { - const file = new File(['hello world'], 'file.txt', { type: 'text/plain' }) - const formData = buildFormData(file) - const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { - method: 'POST', - headers: { 'content-length': '512' }, - body: formData, + it.each([ + ['validation', 400, 'Invalid file name'], + ['not_found', 404, 'Target folder not found'], + ['conflict', 409, 'A file with this name already exists'], + ['payload_too_large', 413, 'File size exceeds 50MB limit'], + ] as const)('maps a %s orchestration failure to %i', async (errorCode, expectedStatus, error) => { + mockPerformCreateWorkspaceFile.mockResolvedValue({ success: false, error, errorCode }) + + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(expectedStatus) + await expect(response.json()).resolves.toEqual({ success: false, error }) + }) + + it('does not expose an internal orchestration error', async () => { + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: false, + error: 'update workspace_files set ... failed', + errorCode: 'internal', + }) + + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Failed to create file', }) + }) - const response = await POST(req, routeContext) - const data = await response.json() + it('maps an unexpected throw to a 500 response', async () => { + mockPerformCreateWorkspaceFile.mockRejectedValue(new Error('storage unavailable')) - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1) + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Failed to create file', + }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index b5d1d4f1fdc..9a370fb4f94 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,28 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { + createWorkspaceFileContract, listWorkspaceFilesQuerySchema, workspaceFilesParamsSchema, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { - isPayloadSizeLimitError, - MAX_MULTIPART_OVERHEAD_BYTES, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { - FileConflictError, - listWorkspaceFiles, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' -import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performCreateWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -101,19 +99,11 @@ export const GET = withRouteHandler( /** * POST /api/workspaces/[id]/files - * Upload a new file to workspace storage (requires write permission) + * Create an authored workspace file (requires write permission) */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const paramsResult = workspaceFilesParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data try { const session = await getSession() @@ -121,7 +111,15 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Check workspace permissions (requires write) + const paramsResult = workspaceFilesParamsSchema.safeParse(await context.params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + const { id: workspaceId } = paramsResult.data + const userPermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -134,93 +132,45 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - let formData: FormData - try { - formData = await readFormDataWithLimit(request, { - maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'workspace file upload body', - }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return NextResponse.json({ error: error.message }, { status: 413 }) - } - return NextResponse.json( - { error: 'Request body must be valid multipart form data' }, - { status: 400 } - ) - } - const rawFile = formData.get('file') - const rawFolderId = formData.get('folderId') - const folderId = - typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null - - if (!rawFile || !(rawFile instanceof File)) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - } - - const fileName = rawFile.name || 'untitled.md' - - if (rawFile.size > MAX_WORKSPACE_FORMDATA_FILE_SIZE) { - return NextResponse.json( - { - error: `File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`, - }, - { status: 413 } - ) - } - - const buffer = Buffer.from(await rawFile.arrayBuffer()) - - const userFile = await uploadWorkspaceFile( - workspaceId, - session.user.id, - buffer, - fileName, - rawFile.type || 'application/octet-stream', - { folderId } - ) - - logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) - - captureServerEvent( - session.user.id, - 'file_uploaded', - { workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' }, - { groups: { workspace: workspaceId } } - ) + const parsed = await parseRequest(createWorkspaceFileContract, request, context, { + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + }) + if (!parsed.success) return parsed.response + const { name, contentType, folderId, content, encoding } = parsed.data.body - recordAudit({ + const result = await performCreateWorkspaceFile({ workspaceId, - actorId: session.user.id, + userId: session.user.id, + name, + contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), + folderId, + content: Buffer.from(content, encoding), + exactName: false, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: fileName, - description: `Uploaded file "${fileName}"`, - metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' }, request, }) + if (!result.success || !result.file) { + return NextResponse.json( + { + success: false, + error: messageForOrchestrationError(result, 'Failed to create file'), + }, + { status: statusForOrchestrationError(result.errorCode) } + ) + } - return NextResponse.json({ - success: true, - file: userFile, - }) + logger.info(`[${requestId}] Created workspace file: ${result.file.name}`) + return NextResponse.json({ success: true, file: result.file }, { status: 201 }) } catch (error) { - logger.error(`[${requestId}] Error uploading workspace file:`, error) - - const errorMessage = getErrorMessage(error, 'Failed to upload file') - const isDuplicate = - error instanceof FileConflictError || errorMessage.includes('already exists') + logger.error(`[${requestId}] Error creating workspace file:`, error) return NextResponse.json( { success: false, - error: errorMessage, - isDuplicate, + error: 'Failed to create file', }, - { status: isDuplicate ? 409 : 500 } + { status: 500 } ) } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts index b91d1b99318..efd99ab7b40 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts @@ -2,14 +2,13 @@ import { useCallback, useEffect, useRef } from 'react' import { toast } from '@sim/emcn' -import { generateId } from '@sim/utils/id' import { useRouter } from 'next/navigation' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useImportFileAsTable } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' -export type CsvImportFileDescriptor = Pick +export type CsvImportFileDescriptor = Pick /** * Wires the "Import as a table" affordance for a capped CSV preview. When the preview is @@ -32,10 +31,7 @@ export function useCsvTruncationImport( const importAsTable = useCallback(() => { if (importingRef.current) return importingRef.current = true - const pendingId = `pending_${generateId()}` - useImportTrayStore - .getState() - .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) + let importId: string | null = null toast.success(`Importing "${file.name}" as a table`, { description: 'This runs in the background.', action: { @@ -44,17 +40,29 @@ export function useCsvTruncationImport( }, }) importFile.mutate( - { workspaceId, fileKey: file.key, fileName: file.name }, + { + workspaceId, + fileId: file.id, + fileName: file.name, + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + workspaceId, + title: file.name, + }) + }, + }, { onSettled: () => { importingRef.current = false - useImportTrayStore.getState().endUpload(pendingId) + if (importId) useImportTrayStore.getState().endUpload(importId) }, } ) // importFile.mutate and router are stable references // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, file.key, file.name]) + }, [workspaceId, file.id, file.key, file.name]) // Surface the cap as a warning toast with an import action, once per file. const notifiedKeyRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 006012191ac..33effd87945 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -288,6 +288,7 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({ mimeType={file.type} filename={file.name} workspaceId={workspaceId} + fileId={file.id} fileKey={file.key} readOnly /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 764349c42ad..4dbb0528483 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -42,6 +42,7 @@ interface PreviewPanelProps { mimeType: string | null filename: string workspaceId: string + fileId: string fileKey: string isStreaming?: boolean /** @@ -57,6 +58,7 @@ export const PreviewPanel = memo(function PreviewPanel({ mimeType, filename, workspaceId, + fileId, fileKey, isStreaming, readOnly, @@ -69,7 +71,7 @@ export const PreviewPanel = memo(function PreviewPanel({ ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 966b1943e8c..b2aad750049 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -680,6 +680,7 @@ export const TextEditor = memo(function TextEditor({ mimeType={file.type} filename={file.name} workspaceId={workspaceId} + fileId={file.id} fileKey={file.key} isStreaming={isStreaming} /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 736ec48089e..d772c13c800 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -112,6 +112,7 @@ import { type WorkspaceFileFolderApi, } from '@/hooks/queries/workspace-file-folders' import { + useCreateWorkspaceFile, useDeleteWorkspaceFile, useRenameWorkspaceFile, useUploadWorkspaceFile, @@ -256,6 +257,7 @@ export function Files() { return map }, [members]) const uploadFile = useUploadWorkspaceFile() + const createWorkspaceFile = useCreateWorkspaceFile() const notifyLimit = useLimitUpgradeToast() const deleteFile = useDeleteWorkspaceFile() const renameFile = useRenameWorkspaceFile() @@ -1313,15 +1315,13 @@ export function Files() { const name = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, existingNames) const mimeType = getMimeTypeFromExtension('md') - const blob = new Blob([''], { type: mimeType }) - const file = new File([blob], name, { type: mimeType }) - const result = await uploadFile.mutateAsync({ + const result = await createWorkspaceFile.mutateAsync({ workspaceId, - file, - folderId: currentFolderId, - skipToast: true, + name, + contentType: mimeType, + folderId: currentFolderId ?? undefined, }) - const fileId = result.file?.id + const fileId = result.file.id if (fileId) { justCreatedFileIdRef.current = fileId const params = new URLSearchParams({ new: '1' }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 89f35608f31..0d773da797c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -60,7 +60,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI import { useFolders } from '@/hooks/queries/folders' import { useLogDetail } from '@/hooks/queries/logs' import { useScheduleById } from '@/hooks/queries/schedules' -import { downloadTableExport } from '@/hooks/queries/tables' +import { exportTable } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -344,13 +344,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) ) case 'table': - return ( - - ) + return case 'log': return case 'scheduledtask': @@ -508,10 +502,9 @@ const tableLogger = createLogger('EmbeddedTableActions') interface EmbeddedTableActionsProps { workspaceId: string tableId: string - tableName: string } -function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTableActionsProps) { +function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProps) { const router = useRouter() const handleOpenTable = () => { @@ -520,7 +513,7 @@ function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTable const handleExport = async () => { try { - await downloadTableExport(tableId, tableName) + await exportTable(workspaceId, tableId) } catch (err) { tableLogger.error('Failed to export table:', err) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx index cfdc62d32af..ba29e66597d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx @@ -15,6 +15,10 @@ import { import { createLogger } from '@sim/logger' import { RotateCcw, X } from 'lucide-react' import { useParams } from 'next/navigation' +import { + assertMultiFileUploadAdmission, + MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils' import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' @@ -77,11 +81,11 @@ export function AddDocumentsModal({ } const processFiles = (selectedFiles: File[]) => { - setFileError(null) - if (!selectedFiles || selectedFiles.length === 0) return try { + assertMultiFileUploadAdmission(selectedFiles, { existingFiles: files }) + setFileError(null) const newFiles: File[] = [] let hasError = false @@ -100,6 +104,10 @@ export function AddDocumentsModal({ setFiles((prev) => [...prev, ...newFiles]) } } catch (error) { + if (error instanceof MultiFileUploadAdmissionError) { + setFileError(error.message) + return + } logger.error('Error processing files:', error) setFileError('An error occurred while processing files. Please try again.') } @@ -156,7 +164,7 @@ export function AddDocumentsModal({ accept={ACCEPT_ATTRIBUTE} multiple onChange={processFiles} - description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)' + description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 20 files, 100MB each, 500MB total)' error={fileError} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index 5364d754044..88c4b5c8603 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -27,6 +27,10 @@ import { type FieldErrors, useForm } from 'react-hook-form' import { z } from 'zod' import type { StrategyOptions } from '@/lib/chunkers/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + assertMultiFileUploadAdmission, + MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils' import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' @@ -202,11 +206,11 @@ export const CreateBaseModal = memo(function CreateBaseModal({ }, [open, reset]) const processFiles = (selectedFiles: File[]) => { - setFileError(null) - if (!selectedFiles || selectedFiles.length === 0) return try { + assertMultiFileUploadAdmission(selectedFiles, { existingFiles: files }) + setFileError(null) const newFiles: File[] = [] let hasError = false @@ -225,6 +229,10 @@ export const CreateBaseModal = memo(function CreateBaseModal({ setFiles((prev) => [...prev, ...newFiles]) } } catch (error) { + if (error instanceof MultiFileUploadAdmissionError) { + setFileError(error.message) + return + } logger.error('Error processing files:', error) setFileError('An error occurred while processing files. Please try again.') } @@ -474,7 +482,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ accept={ACCEPT_ATTRIBUTE} multiple onChange={processFiles} - description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)' + description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 20 files, 100MB each, 500MB total)' error={fileError} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx new file mode 100644 index 00000000000..c14cc716e7c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockInvalidateQueries, mockUploadKnowledgeDocumentSession } = vi.hoisted(() => ({ + mockInvalidateQueries: vi.fn(), + mockUploadKnowledgeDocumentSession: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})) + +vi.mock('@/lib/uploads/client/session-upload', () => ({ + uploadKnowledgeDocumentSession: mockUploadKnowledgeDocumentSession, +})) + +import { MULTI_FILE_UPLOAD_MAX_FILE_BYTES } from '@/lib/uploads/client/admission' +import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' + +interface HookHarness { + result: () => ReturnType + unmount: () => void +} + +function renderKnowledgeUploadHook(onError: ReturnType): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + let latest: ReturnType + + function Probe() { + latest = useKnowledgeUpload({ workspaceId: 'workspace-1', onError }) + return null + } + + act(() => root.render()) + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +function sizedFile(name: string, size: number): File { + const file = new File([], name, { type: 'application/octet-stream' }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +describe('useKnowledgeUpload admission', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('rejects aggregate bytes before allocating upload progress or sessions', async () => { + const onError = vi.fn() + const { result, unmount } = renderKnowledgeUploadHook(onError) + const files = Array.from({ length: 6 }, (_, index) => + sizedFile(`file-${index}.bin`, MULTI_FILE_UPLOAD_MAX_FILE_BYTES) + ) + + await act(async () => { + await expect(result().uploadFiles(files, 'kb-1')).rejects.toMatchObject({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + }) + }) + + expect(mockUploadKnowledgeDocumentSession).not.toHaveBeenCalled() + expect(mockInvalidateQueries).not.toHaveBeenCalled() + expect(result().isUploading).toBe(false) + expect(result().uploadProgress).toEqual({ + stage: 'idle', + filesCompleted: 0, + totalFiles: 0, + }) + expect(result().uploadError).toMatchObject({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + message: 'Select files totaling 500 MiB or less.', + }) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'UPLOAD_TOTAL_SIZE_EXCEEDED' }) + ) + + unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 3d096f53535..c9cd91416ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -1,38 +1,20 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { useQueryClient } from '@tanstack/react-query' +import type { V2KnowledgeDocumentSummary } from '@/lib/api/contracts/v2/knowledge' import { - calculateUploadTimeoutMs, - DirectUploadError, - isTransientUploadError, - LARGE_FILE_THRESHOLD, - MULTIPART_MAX_RETRIES, - MULTIPART_RETRY_BACKOFF, - MULTIPART_RETRY_DELAY_MS, - normalizePresignedData, - type PresignedUploadInfo, - runUploadStrategy, - runWithConcurrency, - type UploadProgressEvent, - WHOLE_FILE_PARALLEL_UPLOADS, -} from '@/lib/uploads/client/direct-upload' -import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils' + assertMultiFileUploadAdmission, + MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' +import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency' +import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeUpload') -const KB_BATCH_PRESIGNED_ENDPOINT = '/api/files/presigned/batch?type=knowledge-base' -const KB_API_UPLOAD_ENDPOINT = '/api/files/upload' - -const BATCH_REQUEST_SIZE = 50 - -export interface UploadedFile { - filename: string - fileUrl: string - fileSize: number - mimeType: string +interface KnowledgeDocumentUploadFile extends File { tag1?: string tag2?: string tag3?: string @@ -86,162 +68,15 @@ class KnowledgeUploadError extends Error { } } -class ProcessingError extends KnowledgeUploadError { - constructor(message: string, details?: unknown) { - super(message, 'PROCESSING_ERROR', details) - } -} - -interface BatchPresignedFile { - fileName: string - contentType: string - fileSize: number -} - -/** - * Fetch presigned upload data for the small files in `files`. Returns a sparse - * array aligned with the input: entries for files >= LARGE_FILE_THRESHOLD are - * `undefined` because those uploads use multipart and never consume a presigned - * single-PUT URL. - */ -const fetchBatchPresignedData = async ( - files: File[], - workspaceId: string -): Promise<(PresignedUploadInfo | undefined)[]> => { - const result: (PresignedUploadInfo | undefined)[] = new Array(files.length).fill(undefined) - const smallFileIndices: number[] = [] - for (let i = 0; i < files.length; i++) { - if (files[i].size <= LARGE_FILE_THRESHOLD) smallFileIndices.push(i) - } - if (smallFileIndices.length === 0) return result - - const batchEndpoint = `${KB_BATCH_PRESIGNED_ENDPOINT}&workspaceId=${encodeURIComponent(workspaceId)}` - - for (let start = 0; start < smallFileIndices.length; start += BATCH_REQUEST_SIZE) { - const batchIndices = smallFileIndices.slice(start, start + BATCH_REQUEST_SIZE) - const batchFiles = batchIndices.map((i) => files[i]) - const body: { files: BatchPresignedFile[] } = { - files: batchFiles.map((file) => ({ - fileName: file.name, - contentType: getFileContentType(file), - fileSize: file.size, - })), - } - - const response = await fetch(batchEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - throw new Error(`Batch presigned URL generation failed: ${response.statusText}`) - } - - const { files: presignedItems } = (await response.json()) as { files: unknown[] } - batchIndices.forEach((fileIdx, batchPos) => { - result[fileIdx] = normalizePresignedData(presignedItems[batchPos], batchFiles[batchPos].name) - }) - } - - return result -} - -/** - * Server-proxied fallback used when cloud storage isn't configured. - */ -const uploadFileThroughAPI = async ( - file: File, - workspaceId: string | undefined -): Promise<{ filePath: string }> => { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'knowledge-base') - if (workspaceId) formData.append('workspaceId', workspaceId) - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), calculateUploadTimeoutMs(file.size)) - - try { - const response = await fetch(KB_API_UPLOAD_ENDPOINT, { - method: 'POST', - body: formData, - signal: controller.signal, - }) - - if (!response.ok) { - let errorData: { message?: string; error?: string } | null = null - try { - errorData = (await response.json()) as { message?: string; error?: string } - } catch {} - throw new KnowledgeUploadError( - `Failed to upload ${file.name}: ${errorData?.message || errorData?.error || response.statusText}`, - 'API_UPLOAD_ERROR', - errorData - ) - } - - const result = (await response.json()) as { - fileInfo?: { path?: string } - path?: string - } - const filePath = result.fileInfo?.path ?? result.path - if (!filePath) { - throw new KnowledgeUploadError( - `Invalid upload response for ${file.name}: missing file path`, - 'API_UPLOAD_ERROR', - result - ) - } - - return { filePath } - } finally { - clearTimeout(timeoutId) - } -} - -const toAbsoluteUrl = (path: string): string => - path.startsWith('http') ? path : `${window.location.origin}${path}` - -/** - * Build the {@link UploadedFile} payload from a `File`, carrying through any - * `tagN` fields the caller attached to it. Pure — kept at module scope so it - * isn't rebuilt on every render of the hook. - */ -const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => { - const f = file as File & { - tag1?: string - tag2?: string - tag3?: string - tag4?: string - tag5?: string - tag6?: string - tag7?: string - } - return { - filename: file.name, - fileUrl, - fileSize: file.size, - mimeType: getFileContentType(file), - tag1: f.tag1, - tag2: f.tag2, - tag3: f.tag3, - tag4: f.tag4, - tag5: f.tag5, - tag6: f.tag6, - tag7: f.tag7, - } -} - export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const queryClient = useQueryClient() - const [isUploading, setIsUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ stage: 'idle', filesCompleted: 0, totalFiles: 0, }) const [uploadError, setUploadError] = useState(null) + const isUploading = uploadProgress.stage !== 'idle' const updateFileStatus = (fileIndex: number, patch: Partial) => { setUploadProgress((prev) => ({ @@ -255,51 +90,40 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const uploadOneFile = async ( file: File, fileIndex: number, - presigned: PresignedUploadInfo | undefined - ): Promise => { + knowledgeBaseId: string, + processingOptions: ProcessingOptions + ): Promise => { if (!options.workspaceId) { throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID') } - const onProgress = (event: UploadProgressEvent) => { updateFileStatus(fileIndex, { progress: event.percent, status: 'uploading' }) } - - let attempt = 0 - while (true) { - try { - const result = await runUploadStrategy({ - file, - workspaceId: options.workspaceId, - context: 'knowledge-base', - presignedEndpoint: `/api/files/presigned?type=knowledge-base&workspaceId=${encodeURIComponent(options.workspaceId)}`, - presignedOverride: presigned, - onProgress, - }) - return buildUploadedFile(file, toAbsoluteUrl(result.path)) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const { filePath } = await uploadFileThroughAPI(file, options.workspaceId) - return buildUploadedFile(file, toAbsoluteUrl(filePath)) - } - - const retryable = isNetworkError(error) || isTransientUploadError(error) - if (isAbortError(error) || !retryable || attempt >= MULTIPART_MAX_RETRIES) { - throw error - } - - const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt - attempt++ - logger.warn( - `Upload retry ${attempt}/${MULTIPART_MAX_RETRIES} for ${file.name} in ${Math.round(delay / 1000)}s` - ) - updateFileStatus(fileIndex, { progress: 0, status: 'uploading' }) - await sleep(delay) - } - } + const taggedFile = file as KnowledgeDocumentUploadFile + return uploadKnowledgeDocumentSession({ + workspaceId: options.workspaceId, + knowledgeBaseId, + file, + onProgress, + tag1: taggedFile.tag1, + tag2: taggedFile.tag2, + tag3: taggedFile.tag3, + tag4: taggedFile.tag4, + tag5: taggedFile.tag5, + tag6: taggedFile.tag6, + tag7: taggedFile.tag7, + processingOptions: { + recipe: processingOptions.recipe ?? 'default', + lang: 'en', + }, + }) } - const uploadFilesInBatches = async (files: File[]): Promise => { + const uploadFilesInBatches = async ( + files: File[], + knowledgeBaseId: string, + processingOptions: ProcessingOptions + ): Promise => { if (!options.workspaceId) { throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID') } @@ -313,9 +137,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { setUploadProgress((prev) => ({ ...prev, fileStatuses })) - logger.info(`Starting batch upload of ${files.length} files`) - - const presignedData = await fetchBatchPresignedData(files, options.workspaceId) + logger.info(`Starting signed session upload of ${files.length} files`) const settled = await runWithConcurrency( files, @@ -323,7 +145,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { async (file, index) => { updateFileStatus(index, { status: 'uploading' }) try { - const uploaded = await uploadOneFile(file, index, presignedData[index]) + const uploaded = await uploadOneFile(file, index, knowledgeBaseId, processingOptions) setUploadProgress((prev) => ({ ...prev, filesCompleted: prev.filesCompleted + 1, @@ -337,7 +159,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { } ) - const succeeded: UploadedFile[] = [] + const succeeded: V2KnowledgeDocumentSummary[] = [] const failed: Array<{ file: File; error: Error }> = [] settled.forEach((result, idx) => { if (result?.status === 'fulfilled') { @@ -354,7 +176,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { throw new KnowledgeUploadError( `Failed to upload ${failed.length} file(s)`, 'PARTIAL_UPLOAD_FAILURE', - { failedFiles: failed, uploadedFiles: succeeded } + { failedFiles: failed, uploadedDocuments: succeeded } ) } @@ -365,7 +187,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { files: File[], knowledgeBaseId: string, processingOptions: ProcessingOptions = {} - ): Promise => { + ): Promise => { if (files.length === 0) { throw new KnowledgeUploadError('No files provided for upload', 'NO_FILES') } @@ -374,77 +196,30 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { } try { - setIsUploading(true) + assertMultiFileUploadAdmission(files) setUploadError(null) setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length }) - const uploadedFiles = await uploadFilesInBatches(files) + const uploadedDocuments = await uploadFilesInBatches( + files, + knowledgeBaseId, + processingOptions + ) setUploadProgress((prev) => ({ ...prev, stage: 'processing' })) - - // boundary-raw-fetch: bulk document-processing kickoff with dynamic recipe payload; response is consumed alongside the upload progress lifecycle and not modeled by a single contract - const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - documents: uploadedFiles.map((f) => ({ ...f })), - processingOptions: { - recipe: processingOptions.recipe ?? 'default', - lang: 'en', - }, - bulk: true, - }), - }) - - if (!processResponse.ok) { - let errorData: { error?: string; message?: string } | null = null - try { - errorData = (await processResponse.json()) as { error?: string; message?: string } - } catch {} - logger.error('Document processing failed:', { - status: processResponse.status, - error: errorData, - }) - throw new ProcessingError( - `Failed to start document processing: ${errorData?.error || errorData?.message || 'Unknown error'}`, - errorData - ) - } - - const processResult = (await processResponse.json()) as { - success?: boolean - error?: string - data?: { documentsCreated?: unknown } - } - - if (!processResult.success) { - throw new ProcessingError( - `Document processing failed: ${processResult.error || 'Unknown error'}`, - processResult - ) - } - - if (!processResult.data?.documentsCreated) { - throw new ProcessingError( - 'Invalid processing response: missing document data', - processResult - ) - } - - setUploadProgress((prev) => ({ ...prev, stage: 'completing' })) - logger.info(`Successfully started processing ${uploadedFiles.length} documents`) + logger.info(`Successfully started processing ${uploadedDocuments.length} documents`) await queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }) - return uploadedFiles + return uploadedDocuments } catch (err) { logger.error('Error uploading documents:', err) const error: UploadError = err instanceof KnowledgeUploadError ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() } - : err instanceof DirectUploadError - ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() } + : err instanceof MultiFileUploadAdmissionError + ? { message: err.message, code: err.code, timestamp: Date.now() } : err instanceof Error ? { message: err.message, timestamp: Date.now() } : { message: 'Unknown error occurred during upload', timestamp: Date.now() } @@ -453,7 +228,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { options.onError?.(error) throw err } finally { - setIsUploading(false) setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 }) } } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 18402ab42ae..c4e815a9597 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -56,7 +56,7 @@ function UsageLogRow({ log }: UsageLogRowProps) { {rowLabel(log)} - {formatApportionedCreditCost(log.creditCost, log.dollarCost)} + {formatApportionedCreditCost(log.creditCost, log.hasCost)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index 09fdbd75eff..879b875d637 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' const logger = createLogger('ProfilePictureUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -66,28 +65,25 @@ export function useProfilePictureUpload({ const uploadFileToServer = useCallback( async (file: File): Promise => { - const presignedEndpoint = - context === 'workspace-logos' && workspaceId - ? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}` - : `/api/files/presigned?type=${context}` - - try { - const result = await runUploadStrategy({ + if (context === 'workspace-logos') { + if (!workspaceId) { + throw new Error('workspaceId is required for workspace logo upload') + } + const result = await uploadInternalFileSession({ + purpose: 'workspace_logo', + workspaceId, file, - workspaceId: workspaceId ?? '', - context, - presignedEndpoint, }) logger.info(`${context} uploaded successfully: ${result.path}`) return result.path - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const { path } = await uploadViaApiFallback(file, context, workspaceId) - logger.info(`${context} uploaded successfully via API fallback: ${path}`) - return path - } - throw error } + + const result = await uploadInternalFileSession({ + purpose: 'profile_picture', + file, + }) + logger.info(`${context} uploaded successfully: ${result.path}`) + return result.path }, [context, workspaceId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 6d7fa21927e..341c581af1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -253,7 +253,7 @@ export function useTableEventStream({ // Keep the tray's export list fresh between its polls. void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) if (status === 'ready' && jobId && consumeInitiatedExport(jobId)) { - void downloadExportResult(workspaceId, tableId, jobId) + void downloadExportResult(workspaceId, jobId) .then(() => toast.success('Export ready — downloading')) .catch((err) => { logger.error('Export download failed', { tableId, jobId, err }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 13191a7347a..e193f6e3da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -21,7 +21,6 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { TABLE_LIMITS } from '@/lib/table/constants' import { type BreadcrumbItem, type ColumnOption, @@ -35,13 +34,13 @@ import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' import { useLogByExecutionId } from '@/hooks/queries/logs' import { - downloadTableExport, + downloadExportResult, useCancelTableRuns, useCreateTableView, useDeleteTable, useDeleteTableRowsAsync, useDeleteTableView, - useExportTableAsync, + useExportTable, useRenameTable, useRunColumn, useTableViews, @@ -1026,16 +1025,11 @@ export function Table({ const handleExportCsv = useCallback(async () => { if (!tableData) return try { - // Big tables export as a background job (the file downloads when the job completes via the - // SSE stream); small ones keep the instant synchronous stream. While a delete job runs, - // rowCount is a doomed-estimate-adjusted number — not ground truth — so always take the - // async path (safe at any size; exports bypass the one-job-per-table gate). - const deleteRunning = tableData.jobType === 'delete' && tableData.jobStatus === 'running' - if (deleteRunning || tableData.rowCount > TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { - await exportTableAsync.mutateAsync({ format: 'csv' }) - toast.success('Export started — the download will begin when it finishes') + const exported = await exportTableAsync.mutateAsync({ format: 'csv' }) + if (exported.status === 'completed') { + await downloadExportResult(workspaceId, exported.id) } else { - await downloadTableExport(tableData.id, tableData.name) + toast.success('Export started — the download will begin when it finishes') } captureEvent(posthogRef.current, 'table_exported', { table_id: tableData.id, @@ -1256,7 +1250,7 @@ export function Table({ const deleteTableMutation = useDeleteTable(workspaceId) const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId }) - const exportTableAsync = useExportTableAsync({ workspaceId, tableId }) + const exportTableAsync = useExportTable({ workspaceId, tableId }) const handleDeleteTable = async () => { try { await deleteTableMutation.mutateAsync(tableId) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index e306aeac52d..de5983f975b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -24,7 +24,6 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' -import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants' import { buildAutoMapping, CSV_DELIMITER_SNIFF_BYTES, @@ -33,12 +32,7 @@ import { parseCsvBuffer, } from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' -import { - type CsvImportMode, - cancelTableJob, - useImportCsvIntoTable, - useImportCsvIntoTableAsync, -} from '@/hooks/queries/tables' +import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('ImportCsvDialog') @@ -152,7 +146,6 @@ export function ImportCsvDialog({ const [createHeaders, setCreateHeaders] = useState>(new Set()) const [mode, setMode] = useState('append') const importMutation = useImportCsvIntoTable() - const importAsyncMutation = useImportCsvIntoTableAsync() function resetState() { setParsed(null) @@ -306,7 +299,6 @@ export function ImportCsvDialog({ const canSubmit = parsed !== null && !importMutation.isPending && - !importAsyncMutation.isPending && missingRequired.length === 0 && duplicateTargets.length === 0 && mappedCount + createCount > 0 @@ -320,76 +312,44 @@ export function ImportCsvDialog({ const createColumns = canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined - // Large files can't be POSTed through the server (request-body cap) — upload them - // straight to storage and import in the background instead. Seed the header tray and - // close the dialog immediately so the indicator is visible during the upload, then run - // the upload + kickoff in the background (don't block the dialog on it). - if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) { - useImportTrayStore.getState().startUpload({ - uploadId: table.id, - workspaceId, - title: parsed.file.name, - }) - onOpenChange(false) - toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`) - importAsyncMutation.mutate( - { - workspaceId, - tableId: table.id, - file: parsed.file, - mode: effectiveMode, - mapping, - createColumns, - onProgress: (percent) => { - useImportTrayStore.getState().setUploadPercent(table.id, percent) - }, - }, - { - onSuccess: (data) => { - useImportTrayStore.getState().endUpload(table.id) - // The server row drives the tray once the list refetches. If canceled mid-upload, flag - // the id so it's not shown and cancel the worker server-side. - if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) { - useImportTrayStore.getState().cancel(table.id) - void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {}) - } - }, - onError: () => { - // The hook's onError surfaces the toast; just clear the tray indicator here. - useImportTrayStore.getState().endUpload(table.id) - }, - } - ) - return - } - - try { - const result = await importMutation.mutateAsync({ + let importId: string | null = null + onOpenChange(false) + toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`) + importMutation.mutate( + { workspaceId, tableId: table.id, file: parsed.file, mode: effectiveMode, mapping, createColumns, - }) - const data = result.data - if (effectiveMode === 'append') { - toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`) - } else { - toast.success( - `Replaced rows in "${table.name}": deleted ${data?.deletedCount ?? 0}, inserted ${data?.insertedCount ?? 0}` - ) + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + tableId: table.id, + workspaceId, + title: parsed.file.name, + }) + }, + onProgress: (percent) => { + if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent) + }, + }, + { + onSuccess: () => { + if (importId) { + useImportTrayStore.getState().endUpload(importId) + useImportTrayStore.getState().consumeCanceled(importId) + } + onImported?.({}) + }, + onError: (error) => { + if (importId) useImportTrayStore.getState().endUpload(importId) + setSubmitError(summarizeImportError(error.message)) + }, } - onImported?.({ - insertedCount: data?.insertedCount, - deletedCount: data?.deletedCount, - }) - onOpenChange(false) - } catch (err) { - const message = getErrorMessage(err, 'Failed to import CSV') - setSubmitError(summarizeImportError(message)) - logger.error('CSV import into existing table failed', err) - } + ) } const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index 46deda65b1c..9cb0c793d6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -10,7 +10,7 @@ import { } from '@sim/emcn' import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables' +import { cancelTableImport, downloadExportResult } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' import { getImportStage } from './import-stage' import { type ImportRow, useWorkspaceImports } from './use-workspace-imports' @@ -49,13 +49,13 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP // Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by // the kickoff handler once its jobId is known; see the `consumeCanceled` branches.) if (row.jobId) { - void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {}) + void cancelTableImport(row.workspaceId, row.jobId).catch(() => {}) } } const download = (row: ImportRow) => { if (!row.jobId) return - void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => { + void downloadExportResult(row.workspaceId, row.jobId).catch((err) => { logger.error('Export download failed', { jobId: row.jobId, err }) toast.error('Download failed — the export may have expired') }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts index d72ed8b6d20..934757a198e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts @@ -132,17 +132,18 @@ export function useWorkspaceImports( for (const upload of uploads) { if (upload.workspaceId !== workspaceId) continue - if (scopeTableId && upload.uploadId !== scopeTableId) continue + if (scopeTableId && upload.tableId !== scopeTableId) continue if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue rows.push({ id: upload.uploadId, - tableId: upload.uploadId, + tableId: upload.tableId ?? upload.uploadId, workspaceId: upload.workspaceId, title: upload.title, phase: 'importing', jobType: 'import', rowsProcessed: 0, percent: upload.percent, + jobId: upload.uploadId, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index b7b7dea07dd..e6292d6accd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -6,11 +6,10 @@ import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn' import { Columns3, FolderPlus, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -62,16 +61,15 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { - cancelTableJob, - downloadTableExport, + exportTable, useCreateTable, useDeleteTable, - useImportCsvAsync, + useImportCsv, useMoveTable, useRenameTable, useTablesList, - useUploadCsvToTable, } from '@/hooks/queries/tables' +import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -154,8 +152,7 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) - const uploadCsv = useUploadCsvToTable() - const importCsvAsync = useImportCsvAsync() + const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() const deleteFolder = useDeleteFolderMutation() @@ -866,112 +863,64 @@ export function Tables() { } } - const handleCsvChange = useCallback( - async (e: React.ChangeEvent) => { - const list = e.target.files - if (!list || list.length === 0 || !workspaceId) return + const handleCsvChange = async (e: React.ChangeEvent) => { + const list = e.target.files + if (!list || list.length === 0 || !workspaceId) return - const csvFiles = Array.from(list).filter((f) => { - const ext = f.name.split('.').pop()?.toLowerCase() - return ext === 'csv' || ext === 'tsv' - }) - - if (csvFiles.length === 0) { - toast.error('No CSV or TSV files selected') - if (csvInputRef.current) csvInputRef.current.value = '' - return - } - - // Large files can't be POSTed through the server (request-body cap) — upload them - // straight to storage and import in the background. These are tracked by the import - // tray, never the header upload button, so don't touch uploading/uploadProgress here. - const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - - try { - for (const file of asyncFiles) { - // Show the indicator immediately under a temporary id (the real table id doesn't - // exist until kickoff returns), then let the tray track it. Don't redirect — the - // table is still empty/importing, so stay on the list. - const pendingId = `pending_${generateId()}` - useImportTrayStore - .getState() - .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) - toast.success(`Importing "${file.name}" in the background`) - try { - const result = await importCsvAsync.mutateAsync({ - workspaceId, - folderId: currentFolderId, - file, - onProgress: (percent) => { - useImportTrayStore.getState().setUploadPercent(pendingId, percent) - }, - }) - useImportTrayStore.getState().endUpload(pendingId) - // The server row drives the tray once the list refetches (mutation invalidates it). - // If canceled mid-upload, flag the real id so it's not shown and cancel server-side. - if ( - result?.tableId && - result.importId && - useImportTrayStore.getState().consumeCanceled(pendingId) - ) { - useImportTrayStore.getState().cancel(result.tableId) - void cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {}) - } - } catch { - // The hook's onError surfaces the toast; just clear the tray indicator here. - useImportTrayStore.getState().endUpload(pendingId) - } - } - - if (syncFiles.length === 0) return + const csvFiles = Array.from(list).filter((f) => { + const ext = f.name.split('.').pop()?.toLowerCase() + return ext === 'csv' || ext === 'tsv' + }) - setUploadProgress({ completed: 0, total: syncFiles.length }) - const failed: string[] = [] + if (csvFiles.length === 0) { + toast.error('No CSV or TSV files selected') + if (csvInputRef.current) csvInputRef.current.value = '' + return + } - for (let i = 0; i < syncFiles.length; i++) { - const file = syncFiles[i] - try { - const result = await uploadCsv.mutateAsync({ - workspaceId, - folderId: currentFolderId, - file, - }) - - if (syncFiles.length === 1 && asyncFiles.length === 0) { - const tableId = result?.data?.table?.id - if (tableId) { - router.push(`/workspace/${workspaceId}/tables/${tableId}`) - } - } - } catch (err) { - failed.push(file.name) - logger.error('Error uploading CSV:', err) - } finally { - setUploadProgress({ completed: i + 1, total: syncFiles.length }) + try { + setUploadProgress({ completed: 0, total: csvFiles.length }) + for (let index = 0; index < csvFiles.length; index++) { + const file = csvFiles[index] + let importId: string | null = null + toast.success(`Importing "${file.name}" in the background`) + try { + await importCsv.mutateAsync({ + workspaceId, + folderPath: getCanonicalFolderPath(currentFolderId, folderById), + file, + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + workspaceId, + title: file.name, + }) + }, + onProgress: (percent) => { + if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent) + }, + }) + if (importId) { + useImportTrayStore.getState().endUpload(importId) + useImportTrayStore.getState().consumeCanceled(importId) } - } - - if (failed.length > 0) { - toast.error( - failed.length === 1 - ? `Failed to import ${failed[0]}` - : `Failed to import ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed.join(', ')}` - ) - } - } catch (err) { - logger.error('Error uploading CSV:', err) - toast.error('Failed to import CSV') - } finally { - setUploadProgress({ completed: 0, total: 0 }) - if (csvInputRef.current) { - csvInputRef.current.value = '' + } catch { + if (importId) useImportTrayStore.getState().endUpload(importId) + } finally { + setUploadProgress({ completed: index + 1, total: csvFiles.length }) } } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 - [workspaceId, currentFolderId, router] - ) + } catch (err) { + logger.error('Error uploading CSV:', err) + toast.error('Failed to import CSV') + } finally { + setUploadProgress({ completed: 0, total: 0 }) + if (csvInputRef.current) { + csvInputRef.current.value = '' + } + } + } const handleListUploadCsv = useCallback(() => { csvInputRef.current?.click() @@ -1129,7 +1078,8 @@ export function Tables() { onExportCsv={async () => { if (!activeTable) return try { - await downloadTableExport(activeTable.id, activeTable.name) + const status = await exportTable(workspaceId, activeTable.id) + if (status === 'processing') toast.success('Export started') } catch (err) { logger.error('Failed to export table:', err) toast.error('Failed to export table') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx new file mode 100644 index 00000000000..9d1a3d26d63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockToastError, mockUploadInternalFileSession } = vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockUploadInternalFileSession: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ toast: { error: mockToastError } })) + +vi.mock('@/lib/uploads/client/session-upload', () => ({ + uploadInternalFileSession: mockUploadInternalFileSession, +})) + +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' + +interface HookHarness { + result: () => ReturnType + unmount: () => void +} + +function renderFileAttachmentsHook(): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + let latest: ReturnType + + function Probe() { + latest = useFileAttachments({ userId: 'user-1', workspaceId: 'workspace-1' }) + return null + } + + act(() => root.render()) + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +function sizedFile(name: string, size: number): File { + const file = new File([], name, { type: 'image/png' }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +function asFileList(files: File[]): FileList { + return Object.assign(files, { item: (index: number) => files[index] ?? null }) +} + +describe('useFileAttachments admission', () => { + const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, 'createObjectURL') + const createObjectUrl = vi.fn() + + beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrl, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + if (originalCreateObjectUrl) { + Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl) + } else { + Reflect.deleteProperty(URL, 'createObjectURL') + } + }) + + it('rejects aggregate bytes before previews, placeholders, or sessions are allocated', async () => { + const { result, unmount } = renderFileAttachmentsHook() + const files = asFileList([ + ...Array.from({ length: 5 }, (_, index) => + sizedFile(`large-image-${index}.png`, MAX_WORKSPACE_FILE_SIZE) + ), + sizedFile('extra-image.png', 1), + ]) + + await act(async () => { + await result().processFiles(files) + }) + + expect(mockToastError).toHaveBeenCalledWith("Couldn't add files", { + description: 'Select files totaling 25 GiB or less.', + }) + expect(createObjectUrl).not.toHaveBeenCalled() + expect(mockUploadInternalFileSession).not.toHaveBeenCalled() + expect(result().attachedFiles).toEqual([]) + + unmount() + }) + + it('starts a mothership session for a file above the old FormData limit', async () => { + mockUploadInternalFileSession.mockResolvedValue({ + path: '/api/files/serve/s3/mothership%2Flarge-image.png?context=mothership', + key: 'mothership/large-image.png', + }) + const { result, unmount } = renderFileAttachmentsHook() + const file = sizedFile('large-image.png', 101 * 1024 * 1024) + + await act(async () => { + await result().processFiles(asFileList([file])) + }) + + expect(mockUploadInternalFileSession).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'mothership_attachment', file }) + ) + expect(result().attachedFiles).toEqual([ + expect.objectContaining({ name: file.name, uploading: false }), + ]) + + unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index 4c40839d27b..ef68d2bb113 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -5,8 +5,10 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission' +import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { resolveFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('useFileAttachments') @@ -64,16 +66,26 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const { userId, workspaceId, disabled, isLoading } = props const [attachedFiles, setAttachedFiles] = useState([]) - const [isDragging, setIsDragging] = useState(false) const [dragCounter, setDragCounter] = useState(0) + const isDragging = dragCounter > 0 const fileInputRef = useRef(null) + const attachedFilesRef = useRef([]) + const uploadControllersRef = useRef(new Map()) + + const updateAttachedFiles = useCallback((update: (files: AttachedFile[]) => AttachedFile[]) => { + const next = update(attachedFilesRef.current) + attachedFilesRef.current = next + setAttachedFiles(next) + }, []) /** * Cleanup preview URLs on unmount */ useEffect(() => { return () => { - attachedFiles.forEach((f) => { + for (const controller of uploadControllersRef.current.values()) controller.abort() + uploadControllersRef.current.clear() + attachedFilesRef.current.forEach((f) => { if (f.previewUrl) { URL.revokeObjectURL(f.previewUrl) } @@ -122,8 +134,18 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { return } + if (fileList.length === 0) return + try { + assertMultiFileUploadAdmission(fileList, { + existingFiles: attachedFilesRef.current, + maxFileBytes: MAX_WORKSPACE_FILE_SIZE, + }) + } catch (error) { + toast.error("Couldn't add files", { description: toError(error).message }) + return + } + const files = Array.from(fileList) - if (files.length === 0) return const placeholders: AttachedFile[] = files.map((file) => ({ id: generateId(), @@ -137,56 +159,48 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { ? URL.createObjectURL(file) : undefined, })) + const controllers = placeholders.map(() => new AbortController()) + placeholders.forEach((placeholder, index) => { + uploadControllersRef.current.set(placeholder.id, controllers[index]) + }) - setAttachedFiles((prev) => [...prev, ...placeholders]) - - const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(workspaceId)}` - - await Promise.all( - files.map(async (file, i) => { - const placeholder = placeholders[i] - try { - let result: { path: string; key: string } - try { - result = await runUploadStrategy({ - file, - workspaceId, - context: 'mothership', - presignedEndpoint, - }) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const fallback = await uploadViaApiFallback(file, 'mothership', workspaceId) - if (!fallback.key) { - throw new Error('Invalid upload response: missing key') - } - result = { path: fallback.path, key: fallback.key } - } else { - throw error - } - } - - logger.info(`File uploaded successfully: ${result.path}`) - - setAttachedFiles((prev) => - prev.map((f) => - f.id === placeholder.id - ? { ...f, path: result.path, key: result.key, uploading: false } - : f - ) + updateAttachedFiles((current) => [...current, ...placeholders]) + + await runWithConcurrency(files, WHOLE_FILE_PARALLEL_UPLOADS, async (file, i) => { + const placeholder = placeholders[i] + const controller = controllers[i] + try { + const result = await uploadInternalFileSession({ + purpose: 'mothership_attachment', + file, + workspaceId, + signal: controller.signal, + }) + + logger.info(`File uploaded successfully: ${result.path}`) + + updateAttachedFiles((current) => + current.map((f) => + f.id === placeholder.id + ? { ...f, path: result.path, key: result.key, uploading: false } + : f ) - } catch (error) { + ) + } catch (error) { + if (!controller.signal.aborted) { logger.error(`File upload failed: ${error}`) toast.error(`Couldn't upload "${file.name}"`, { description: toError(error).message, }) - if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) - setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id)) } - }) - ) + if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) + updateAttachedFiles((current) => current.filter((file) => file.id !== placeholder.id)) + } finally { + uploadControllersRef.current.delete(placeholder.id) + } + }) }, - [userId, workspaceId] + [userId, workspaceId, updateAttachedFiles] ) /** @@ -222,13 +236,15 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { */ const removeFile = useCallback( (fileId: string) => { - const file = attachedFiles.find((f) => f.id === fileId) + uploadControllersRef.current.get(fileId)?.abort() + uploadControllersRef.current.delete(fileId) + const file = attachedFilesRef.current.find((f) => f.id === fileId) if (file?.previewUrl) { URL.revokeObjectURL(file.previewUrl) } - setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId)) + updateAttachedFiles((current) => current.filter((file) => file.id !== fileId)) }, - [attachedFiles] + [updateAttachedFiles] ) /** @@ -249,13 +265,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragCounter((prev) => { - const newCount = prev + 1 - if (newCount === 1) { - setIsDragging(true) - } - return newCount - }) + setDragCounter((prev) => prev + 1) }, []) /** @@ -264,13 +274,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragCounter((prev) => { - const newCount = prev - 1 - if (newCount === 0) { - setIsDragging(false) - } - return newCount - }) + setDragCounter((prev) => Math.max(0, prev - 1)) }, []) /** @@ -289,7 +293,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { async (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setIsDragging(false) setDragCounter(0) if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { @@ -303,26 +306,33 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { * Clears all attached files and cleanup preview URLs */ const clearAttachedFiles = useCallback(() => { - attachedFiles.forEach((f) => { + for (const controller of uploadControllersRef.current.values()) controller.abort() + uploadControllersRef.current.clear() + attachedFilesRef.current.forEach((f) => { if (f.previewUrl) { URL.revokeObjectURL(f.previewUrl) } }) - setAttachedFiles([]) - }, [attachedFiles]) + updateAttachedFiles(() => []) + }, [updateAttachedFiles]) /** * Replaces the current attached files with a given set. * Cleans up preview URLs from the prior set before replacing. */ - const restoreAttachedFiles = useCallback((files: AttachedFile[]) => { - setAttachedFiles((prev) => { - prev.forEach((f) => { - if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) + const restoreAttachedFiles = useCallback( + (files: AttachedFile[]) => { + for (const controller of uploadControllersRef.current.values()) controller.abort() + uploadControllersRef.current.clear() + updateAttachedFiles((current) => { + current.forEach((f) => { + if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) + }) + return files }) - return files - }) - }, []) + }, + [updateAttachedFiles] + ) return { // State diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index c468acb2a3d..a5950e82f60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -421,7 +421,7 @@ console.log(limits);` case 'status': return 'Check Status' case 'rate-limits': - return 'Rate Limits' + return 'Usage Limits' default: return 'Execute Job' } @@ -564,7 +564,7 @@ console.log(limits);` options={[ { label: 'Execute Job', value: 'execute' }, { label: 'Check Status', value: 'status' }, - { label: 'Rate Limits', value: 'rate-limits' }, + { label: 'Usage Limits', value: 'rate-limits' }, ]} value={asyncExampleType} onChange={(value) => setAsyncExampleType(value as AsyncExampleType)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 3ed0c144334..3ba4638306d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -7,28 +7,17 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { - DirectUploadErrorMock, executionStoreState, mockExecute, mockExecuteFromBlock, mockFetch, mockResolveStartCandidates, - mockRunUploadStrategy, mockSelectBestTrigger, + mockUploadInternalFileSession, terminalStoreState, workflowBlocks, workflowStoreState, } = vi.hoisted(() => { - class DirectUploadErrorMock extends Error { - constructor( - message: string, - public code: string - ) { - super(message) - this.name = 'DirectUploadError' - } - } - const workflowBlocks = { start: { id: 'start', @@ -88,14 +77,13 @@ const { } return { - DirectUploadErrorMock, executionStoreState, mockExecute: vi.fn(), mockExecuteFromBlock: vi.fn(), mockFetch: vi.fn(), mockResolveStartCandidates: vi.fn(), - mockRunUploadStrategy: vi.fn(), mockSelectBestTrigger: vi.fn(), + mockUploadInternalFileSession: vi.fn(), terminalStoreState, workflowBlocks, workflowStoreState, @@ -127,9 +115,8 @@ vi.mock('@/lib/tokenization', () => ({ processStreamingBlockLogs: () => 0, })) -vi.mock('@/lib/uploads/client/direct-upload', () => ({ - DirectUploadError: DirectUploadErrorMock, - runUploadStrategy: mockRunUploadStrategy, +vi.mock('@/lib/uploads/client/session-upload', () => ({ + uploadInternalFileSession: mockUploadInternalFileSession, })) vi.mock('@/lib/workflows/input-format', () => ({ @@ -354,8 +341,8 @@ describe('useWorkflowExecution attachment uploads', () => { mockResolveStartCandidates.mockReturnValue([]) mockSelectBestTrigger.mockReturnValue([]) vi.stubGlobal('fetch', mockFetch) - mockRunUploadStrategy.mockRejectedValue( - new DirectUploadErrorMock('Server signaled fallback to API upload', 'FALLBACK_REQUIRED') + mockUploadInternalFileSession.mockRejectedValue( + new Error('Workspace file storage limit exceeded') ) mockFetch.mockResolvedValue( new Response(JSON.stringify({ error: 'Workspace file storage limit exceeded' }), { @@ -378,12 +365,14 @@ describe('useWorkflowExecution attachment uploads', () => { const file = new File(['report'], 'report.pdf', { type: 'application/pdf' }) let uploadError: unknown - mockRunUploadStrategy.mockResolvedValueOnce({ + mockUploadInternalFileSession.mockResolvedValueOnce({ + id: 'attachment-context', key: 'executions/context.txt', - path: '/uploads/context.txt', + url: '/uploads/context.txt', name: contextFile.name, size: contextFile.size, - contentType: contextFile.type, + type: contextFile.type, + context: 'execution', }) await act(async () => { @@ -437,12 +426,14 @@ describe('useWorkflowExecution attachment uploads', () => { } let runResult: unknown - mockRunUploadStrategy.mockResolvedValueOnce({ + mockUploadInternalFileSession.mockResolvedValueOnce({ + id: 'attachment-diagram', key: 'execution/diagram.png', - path: '/api/files/serve/execution%2Fdiagram.png', + url: '/api/files/serve/execution%2Fdiagram.png', name: file.name, size: file.size, - contentType: file.type, + type: file.type, + context: 'execution', }) await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts index 938366c71bb..48a77406ed5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts @@ -1,11 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { - type ApiFallbackUploadMetadata, - uploadViaApiFallbackWithMetadata, -} from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' export interface WorkflowAttachmentInput { name: string @@ -33,39 +27,6 @@ interface UploadWorkflowAttachmentsParams { executionId: string } -function getOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed || undefined -} - -function getDirectUploadFailureReason(error: unknown): string { - if (error instanceof DirectUploadError && isRecordLike(error.details)) { - const message = - getOptionalString(error.details.message) ?? getOptionalString(error.details.error) - if (message) return message - } - - return getErrorMessage(error, 'Unknown upload error') -} - -function normalizeFallbackUpload( - value: ApiFallbackUploadMetadata, - fallbackFile: WorkflowAttachmentInput -): UploadedWorkflowAttachment { - return { - id: value.id ?? `file_${Date.now()}_${generateShortId(7)}`, - name: value.name ?? fallbackFile.name, - url: value.path, - size: typeof value.size === 'number' ? value.size : fallbackFile.size, - type: value.type ?? fallbackFile.type, - key: value.key, - context: 'execution', - uploadedAt: value.uploadedAt, - expiresAt: value.expiresAt, - } -} - /** * Uploads every explicit workflow attachment before execution may begin. * @@ -78,46 +39,21 @@ export async function uploadWorkflowAttachments({ executionId, }: UploadWorkflowAttachmentsParams): Promise { const uploadedFiles: UploadedWorkflowAttachment[] = [] - const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(workflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}` for (const fileData of files) { try { - const result = await runUploadStrategy({ + const result = await uploadInternalFileSession({ + purpose: 'execution_attachment', file: fileData.file, workspaceId, - context: 'execution', workflowId, executionId, - presignedEndpoint, - }) - uploadedFiles.push({ - id: `file_${Date.now()}_${generateShortId(7)}`, - name: fileData.file.name, - url: result.path, - size: fileData.file.size, - type: fileData.file.type, - key: result.key, - context: 'execution', }) + uploadedFiles.push(result) } catch (uploadError) { - if (!(uploadError instanceof DirectUploadError) || uploadError.code !== 'FALLBACK_REQUIRED') { - throw new Error( - `Failed to upload ${fileData.name}: ${getDirectUploadFailureReason(uploadError)}` - ) - } - - try { - const fallbackResult = await uploadViaApiFallbackWithMetadata(fileData.file, 'execution', { - workflowId, - executionId, - workspaceId, - }) - uploadedFiles.push(normalizeFallbackUpload(fallbackResult, fileData)) - } catch (error) { - throw new Error( - `Failed to upload ${fileData.name}: ${getErrorMessage(error, 'Network error')}` - ) - } + throw new Error( + `Failed to upload ${fileData.name}: ${getErrorMessage(uploadError, 'Network error')}` + ) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts index 0d589d24996..a902e2f18bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' const logger = createLogger('WorkspaceLogoUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -68,25 +67,13 @@ export function useWorkspaceLogoUpload({ throw new Error('workspaceId is required for workspace logo upload') } - const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}` - - try { - const result = await runUploadStrategy({ - file, - workspaceId: targetWorkspaceId, - context: 'workspace-logos', - presignedEndpoint, - }) - logger.info(`Workspace logo uploaded successfully: ${result.path}`) - return result.path - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId) - logger.info(`Workspace logo uploaded via API fallback: ${path}`) - return path - } - throw error - } + const result = await uploadInternalFileSession({ + purpose: 'workspace_logo', + file, + workspaceId: targetWorkspaceId, + }) + logger.info(`Workspace logo uploaded successfully: ${result.path}`) + return result.path }, []) const processFile = useCallback( diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 42bf64f7237..0dc4b025fea 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -113,7 +113,9 @@ async function selectExpiredWorkspaceFiles( key: workspaceFiles.key, workspaceId: workspaceFiles.workspaceId, context: workspaceFiles.context, - size: workspaceFiles.size, + size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith( + Number + ), }) .from(workspaceFiles) .where( @@ -325,7 +327,12 @@ async function deleteExpiredBillableWorkspaceFileRows( lt(workspaceFiles.deletedAt, retentionDate) ) ) - .returning({ id: workspaceFiles.id, size: workspaceFiles.size }) + .returning({ + id: workspaceFiles.id, + size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith( + Number + ), + }) if (deletedRows.some(({ size }) => size < 0)) { throw new Error('Cannot delete workspace files with negative stored-byte metadata') } @@ -695,14 +702,13 @@ const CLEANUP_TARGETS = [ ] as const /** - * Sweep abandoned knowledge-base ownership bindings. The presigned upload flow - * writes a `workspace_files` binding when it hands out an upload URL, before the - * object is stored and before any document is created. If the upload is never - * completed, that binding is orphaned — no `document.storageKey` ever references - * its key. Such bindings are inert (read access requires a live document, and - * the move re-point only follows referenced keys), but they accumulate, so we - * drop the best-effort object and soft-delete the binding once they are older - * than the grace window. + * Sweep abandoned knowledge-base ownership bindings. Knowledge upload sessions write a + * `workspace_files` binding before the object is stored and before any document is created. + * If the upload is never completed, that binding is orphaned — no + * `document.storageKey` ever references its key. Such bindings are inert (read access requires + * a live document, and the move re-point only follows referenced keys), but they accumulate, + * so we drop the best-effort object and soft-delete the binding once they are older than the + * grace window. */ async function cleanupOrphanedKnowledgeBaseBindings( workspaceIds: string[], diff --git a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts index 760cd187c76..f936afaa055 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts @@ -48,7 +48,7 @@ export async function sumForkCopyBytes( fileSelectors.length === 0 ? sql`0` : sql`( - SELECT coalesce(sum(${workspaceFiles.size}), 0) + SELECT coalesce(sum(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})), 0) FROM ${workspaceFiles} WHERE ${and( fileSelectors.length === 1 ? fileSelectors[0] : or(...fileSelectors), diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index 79539ced526..8d340e923eb 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -11,7 +11,7 @@ import type { ToolResponse } from '@/tools/types' const logger = createLogger('CustomBlockToolRunner') /** Server-set execution context propagated to every agent tool call. */ -interface CustomBlockExecutorContext { +export interface CustomBlockExecutorContext { workspaceId?: string userId?: string workflowId?: string diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts new file mode 100644 index 00000000000..9596b039cca --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -0,0 +1,104 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' +import type { TraceSpan } from '@/lib/logs/types' +import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import { + buildCustomBlockExecutionContext, + type CustomBlockExecutorContext, +} from '@/executor/handlers/workflow/custom-block-tool-runner' +import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' +import { classifyExecutionError } from '@/executor/utils/errors' +import { parseJSON } from '@/executor/utils/json' +import type { SerializedBlock } from '@/serializer/types' +import type { ToolResponse } from '@/tools/types' + +const logger = createLogger('WorkflowToolRunner') + +/** + * Hosted-key spend of a failed child run, the way the parent bills it: recurse + * nested spans and de-dupe model breakdowns, then subtract the base execution + * charge the parent already applies once itself. A naive top-level `cost.total` + * sum undercounts when spend sits on nested children. + */ +function aggregateChildCost(childTraceSpans: TraceSpan[]): number { + if (childTraceSpans.length === 0) return 0 + const summary = calculateCostSummary(childTraceSpans) + return Math.max(0, summary.totalCost - summary.baseExecutionCharge) +} + +interface WorkflowToolParams { + workflowId?: string + inputMapping?: Record | string + _context?: CustomBlockExecutorContext +} + +/** + * Runs a workflow selected as an Agent tool (`workflow_executor`) in-process + * via `WorkflowBlockHandler` — the same invocation boundary canvas child + * workflows use — replacing the historical HTTP hop to the execute endpoint. + * One admission slot, one top-level log row, cost rolled into the parent + * trace, and the workspace assert / call-chain depth / deployment checks the + * handler already enforces. + * + * On failure the result carries the structured error + the child executionId + * in `output` so parent workflows can route on `error.code` and report a + * reproducible handle to the workflow's provider. + */ +export async function runWorkflowTool(params: WorkflowToolParams): Promise { + if (!params.workflowId) { + return { success: false, output: {}, error: 'Missing workflowId' } + } + + const ctx = buildCustomBlockExecutionContext(params._context ?? {}) + const block: SerializedBlock = { + id: generateId(), + position: { x: 0, y: 0 }, + config: { tool: 'workflow_executor', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'workflow_input' }, + enabled: true, + } + + let inputMapping = params.inputMapping ?? {} + if (typeof inputMapping === 'string') { + inputMapping = parseJSON(inputMapping, {}) as Record + } + + try { + const output = await new WorkflowBlockHandler().execute(ctx, block, { + workflowId: params.workflowId, + inputMapping, + }) + const normalized: Record = + output && typeof output === 'object' && !Array.isArray(output) + ? (output as Record) + : { result: output } + return { success: true, output: normalized } + } catch (error) { + const message = getErrorMessage(error, 'Workflow execution failed') + const isChildError = ChildWorkflowError.isChildWorkflowError(error) + const failedChildSpans = isChildError ? error.childTraceSpans : [] + const childCost = aggregateChildCost(failedChildSpans) + const executionResult = isChildError ? error.executionResult : undefined + const structured = classifyExecutionError(error, executionResult) + const childExecutionId = executionResult?.metadata?.executionId + + logger.info('Workflow tool execution failed', { + workflowId: params.workflowId, + message, + code: structured.code, + }) + return { + success: false, + output: { + ...(childCost > 0 ? { cost: { total: childCost } } : {}), + ...(childExecutionId ? { executionId: childExecutionId } : {}), + error: structured, + }, + error: message, + } + } +} diff --git a/apps/sim/executor/utils/errors.classify.test.ts b/apps/sim/executor/utils/errors.classify.test.ts new file mode 100644 index 00000000000..3051cf6a52c --- /dev/null +++ b/apps/sim/executor/utils/errors.classify.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ExecutionResult } from '@/executor/types' +import { + attachExecutionResult, + buildBlockExecutionError, + classifyExecutionError, +} from '@/executor/utils/errors' + +function failedResult(partial?: Partial): ExecutionResult { + return { success: false, output: {}, ...partial } +} + +describe('classifyExecutionError', () => { + it('reads block context from the fields buildBlockExecutionError attaches and strips the name prefix', () => { + const error = buildBlockExecutionError({ + block: { id: 'block-1', metadata: { name: 'Send Email', id: 'gmail' } } as never, + error: new Error('Invalid credentials'), + }) + + const classified = classifyExecutionError(error) + + expect(classified).toMatchObject({ + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'block-1', + blockName: 'Send Email', + blockType: 'gmail', + }) + }) + + it('falls back to the last failed, un-handled block log', () => { + const result = failedResult({ + error: 'Agent: model refused', + logs: [ + { + blockId: 'b-ok', + blockName: 'First', + blockType: 'function', + success: true, + startedAt: '', + endedAt: '', + durationMs: 1, + }, + { + blockId: 'b-handled', + blockName: 'Handled', + blockType: 'api', + success: false, + errorHandled: true, + error: 'handled upstream', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + { + blockId: 'b-fail', + blockName: 'Agent', + blockType: 'agent', + success: false, + error: 'model refused', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + + const classified = classifyExecutionError(new Error('Agent: model refused'), result) + + expect(classified).toMatchObject({ + message: 'model refused', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'b-fail', + blockName: 'Agent', + blockType: 'agent', + }) + }) + + it('classifies child-workflow failures so parents can route on error class', () => { + const result = failedResult({ + logs: [ + { + blockId: 'wf-block', + blockName: 'Enrich Lead', + blockType: 'workflow_input', + success: false, + error: 'Child workflow failed', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + + expect(classifyExecutionError(new Error('Child workflow failed'), result).code).toBe( + 'CHILD_WORKFLOW_FAILED' + ) + }) + + it('maps the attached 4xx statusCode families', () => { + const timeoutError = new Error('Execution exceeded the time limit') + Object.assign(timeoutError, { statusCode: 408 }) + expect(classifyExecutionError(timeoutError).code).toBe('TIMEOUT') + + const usageError = new Error('Usage limit exceeded for this billing period') + Object.assign(usageError, { statusCode: 402 }) + expect(classifyExecutionError(usageError).code).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('uses the attached executionResult when none is passed explicitly', () => { + const error = new Error('Slack: channel not found') + attachExecutionResult( + error, + failedResult({ + logs: [ + { + blockId: 'slack-1', + blockName: 'Slack', + blockType: 'slack', + success: false, + error: 'channel not found', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + ) + + expect(classifyExecutionError(error)).toMatchObject({ + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'slack-1', + message: 'channel not found', + }) + }) + + it('falls back to EXECUTION_FAILED with the raw message when nothing is classifiable', () => { + expect(classifyExecutionError(new Error('something odd'))).toEqual({ + message: 'something odd', + code: 'EXECUTION_FAILED', + blockId: undefined, + blockName: undefined, + blockType: undefined, + }) + }) +}) diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index b5a509fbf6c..90bd6a11b1b 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -131,3 +131,120 @@ export function normalizeError(error: unknown): string { } return String(error) } + +/** + * Stable, append-only error classes for failed workflow executions. Callers + * (v2 API consumers, parent workflows, MCP clients) route on these instead of + * substring-matching messages; this module is the single place raw errors are + * interpreted, so the executor can later attach codes natively at throw sites + * without a wire change. + */ +export type WorkflowExecutionErrorCode = + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + +export interface StructuredExecutionError { + message: string + code: WorkflowExecutionErrorCode + blockId?: string + blockName?: string + blockType?: string +} + +interface AttachedBlockContext { + blockId?: unknown + blockName?: unknown + blockType?: unknown +} + +function readAttachedBlockContext(error: unknown): { + blockId?: string + blockName?: string + blockType?: string +} { + if (!(error instanceof Error)) return {} + const attached = error as unknown as AttachedBlockContext + return { + blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined, + blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined, + blockType: typeof attached.blockType === 'string' ? attached.blockType : undefined, + } +} + +function lastFailedBlockLog(result: ExecutionResult | undefined): { + blockId?: string + blockName?: string + blockType?: string + error?: string +} { + const logs = result?.logs + if (!logs?.length) return {} + for (let i = logs.length - 1; i >= 0; i--) { + const log = logs[i] + if (!log.success && log.errorHandled !== true) { + return { + blockId: log.blockId, + blockName: log.blockName, + blockType: log.blockType, + error: log.error, + } + } + } + return {} +} + +const CHILD_WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input']) + +/** + * Classifies a failed execution into {@link StructuredExecutionError}. + * Block context comes from the fields {@link buildBlockExecutionError} already + * attaches at the throw site, falling back to the last failed, un-handled + * `BlockLog`. The message drops the historical `"BlockName: "` prefix once + * `blockName` is carried as its own field. + */ +export function classifyExecutionError( + error: unknown, + result?: ExecutionResult +): StructuredExecutionError { + const executionResult = result ?? (hasExecutionResult(error) ? error.executionResult : undefined) + const attached = readAttachedBlockContext(error) + const fromLog = lastFailedBlockLog(executionResult) + const blockId = attached.blockId ?? fromLog.blockId + const blockName = attached.blockName ?? fromLog.blockName + const blockType = attached.blockType ?? fromLog.blockType + + let message = + (error instanceof Error ? error.message : undefined) ?? + executionResult?.error ?? + fromLog.error ?? + 'Execution failed' + if (blockName && message.startsWith(`${blockName}: `)) { + message = message.slice(blockName.length + 2) + } + + const statusCode = error instanceof Error ? getExecutionErrorStatus(error) : undefined + let code: WorkflowExecutionErrorCode + if (statusCode === 408 || /\btimed? ?out\b/i.test(message)) { + code = 'TIMEOUT' + } else if (statusCode === 402 || /usage limit/i.test(message)) { + code = 'USAGE_LIMIT_EXCEEDED' + } else if (executionResult?.status === 'cancelled' || /\bcancelled\b/i.test(message)) { + code = 'CANCELLED' + } else if (/invalid input format/i.test(message)) { + code = 'INVALID_INPUT' + } else if (blockType && CHILD_WORKFLOW_BLOCK_TYPES.has(blockType)) { + code = 'CHILD_WORKFLOW_FAILED' + } else if (blockId) { + code = 'BLOCK_EXECUTION_FAILED' + } else { + code = 'EXECUTION_FAILED' + } + + return { message, code, blockId, blockName, blockType } +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index a1f13eba635..c734bc01939 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -17,13 +17,20 @@ import { } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { - ApiClientError, extractValidationIssues, isApiClientError, isValidationError, } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' +import { + cancelTableImportResourceContract, + completeTableImportResourceContract, + createTableExportResourceContract, + createTableImportPartUrlsContract, + createTableImportResourceContract, + downloadTableExportResourceContract, +} from '@/lib/api/contracts/table-transfers' import { type ActiveDispatch, type AddWorkflowGroupBodyInput, @@ -35,7 +42,6 @@ import { batchUpdateTableRowsContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, - cancelTableJobContract, cancelTableRunsContract, createTableContract, createTableRowContract, @@ -48,14 +54,10 @@ import { deleteTableRowsContract, deleteTableViewContract, deleteWorkflowGroupContract, - exportDownloadContract, - exportTableAsyncContract, findTableRowsContract, getEnrichmentDetailContract, getTableContract, type InsertTableRowBodyInput, - importIntoTableAsyncContract, - importTableAsyncContract, listActiveDispatchesContract, listTableJobsContract, listTableRowsContract, @@ -84,6 +86,8 @@ import { updateTableViewContract, updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' +import type { V2TableImportSource, V2TableImportTarget } from '@/lib/api/contracts/v2/tables' +import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import type { CsvHeaderMapping, @@ -107,7 +111,9 @@ import { isExecInFlight, optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' -import { runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { sanitizeName } from '@/lib/table/import' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' +import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { useTimezone } from '@/hooks/queries/general-settings' import { TABLE_LIST_STALE_TIME, @@ -1722,105 +1728,115 @@ export function useRestoreTable() { }) } -interface UploadCsvParams { - workspaceId: string - /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null - file: File -} - -/** - * Upload a CSV file to create a new table with inferred schema. - */ -export function useUploadCsvToTable() { - const queryClient = useQueryClient() - const timezone = useTimezone() - - return useMutation({ - mutationFn: async ({ workspaceId, folderId, file }: UploadCsvParams) => { - // Text fields must precede the file part: the server parses the body as a - // stream and resolves as soon as it reaches the file, so any field appended - // after it is never seen. - const formData = new FormData() - formData.append('workspaceId', workspaceId) - if (folderId) formData.append('folderId', folderId) - formData.append('timezone', timezone) - formData.append('file', file) - - // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies - const response = await fetch('/api/table/import-csv', { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - // Carry the status: a plain Error drops it, and the 423 self-heal below - // keys off `error.status`. - throw new ApiClientError({ - status: response.status, - body: data, - message: data.error || 'CSV import failed', - }) - } - - return response.json() - }, - onError: (error) => { - logger.error('Failed to upload CSV:', error) - toast.error(error.message, { duration: 5000 }) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) - }, - }) -} - interface ImportCsvAsyncParams { workspaceId: string /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null + folderPath?: string file: File + onCreated?: (importId: string) => void onProgress?: (percent: number) => void } -/** - * Uploads a CSV/TSV straight to workspace storage (bypassing the server's request-body - * cap) and returns its storage key. Shared by the async-import kickoff hooks. - */ -async function uploadCsvToWorkspaceStorage( - file: File, - workspaceId: string, +async function createAndUploadTableImport(params: { + workspaceId: string + source: V2TableImportSource + target: V2TableImportTarget + file?: File + mapping?: CsvHeaderMapping + createColumns?: string[] + timezone: string + onCreated?: (importId: string) => void onProgress?: (percent: number) => void -): Promise { - const upload = await runUploadStrategy({ - file, - workspaceId, - context: 'workspace', - presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, - onProgress: onProgress ? (event) => onProgress(event.percent) : undefined, +}) { + const created = await requestJson(createTableImportResourceContract, { + body: { + workspaceId: params.workspaceId, + source: params.source, + target: params.target, + mapping: params.mapping, + createColumns: params.createColumns, + timezone: params.timezone, + }, }) - return upload.key + const { session, uploadToken, transfer } = created.data + params.onCreated?.(session.id) + if (params.source.type === 'workspace_file') return session + if (!params.file || !uploadToken || !transfer) { + throw new Error('Upload-backed table import returned no upload session') + } + const getPartUrls = async (partNumbers: number[]) => { + const response = await requestJson(createTableImportPartUrlsContract, { + params: { importId: session.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': uploadToken }, + body: { partNumbers }, + }) + return response.data.parts + } + const common = { + file: params.file, + onProgress: params.onProgress + ? (event: UploadProgressEvent) => params.onProgress?.(event.percent) + : undefined, + complete: async (body: V2CompleteUploadBody) => { + const response = await requestJson(completeTableImportResourceContract, { + params: { importId: session.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': uploadToken }, + body, + }) + return response.data + }, + abort: async () => { + await requestJson(cancelTableImportResourceContract, { + params: { importId: session.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': uploadToken }, + }) + }, + } + return transfer.method === 'put' + ? uploadFileSession({ ...common, transfer }) + : uploadFileSession({ ...common, transfer, getPartUrls }) } -/** - * Uploads a large CSV/TSV straight to storage, then kicks off a background import into a - * new table. Resolves with `{ tableId, importId }` immediately — load progress and the - * terminal state arrive over the table-events SSE stream (see `useTableEventStream`). - */ -export function useImportCsvAsync() { +/** Uploads a CSV/TSV through a signed upload session and creates a table from it. */ +export function useImportCsv() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, folderId, file, onProgress }: ImportCsvAsyncParams) => { - const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) - const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, folderId, fileKey, fileName: file.name, timezone }, + mutationFn: async ({ + workspaceId, + folderPath, + file, + onCreated, + onProgress, + }: ImportCsvAsyncParams) => { + const imported = await createAndUploadTableImport({ + workspaceId, + source: { + type: 'upload', + name: file.name, + contentType: file.type || 'text/csv', + size: file.size, + }, + target: { + type: 'new', + name: sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ), + folderPath, + }, + file, + timezone, + onCreated, + onProgress, }) - return response.data + return { tableId: imported.tableId, importId: imported.id } }, onError: (error) => { - logger.error('Failed to start async CSV import:', error) + logger.error('Failed to start CSV import:', error) toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -1831,8 +1847,9 @@ export function useImportCsvAsync() { interface ImportFileAsTableParams { workspaceId: string - fileKey: string + fileId: string fileName: string + onCreated?: (importId: string) => void } /** @@ -1846,11 +1863,21 @@ export function useImportFileAsTable() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, fileKey, fileName }: ImportFileAsTableParams) => { - const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, fileKey, fileName, deleteSourceFile: false, timezone }, + mutationFn: async ({ workspaceId, fileId, fileName, onCreated }: ImportFileAsTableParams) => { + const imported = await createAndUploadTableImport({ + workspaceId, + source: { type: 'workspace_file', fileId }, + target: { + type: 'new', + name: sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ), + }, + timezone, + onCreated, }) - return response.data + return { tableId: imported.tableId, importId: imported.id } }, onError: (error) => { logger.error('Failed to start import from file:', error) @@ -1871,15 +1898,12 @@ interface ImportCsvIntoTableAsyncParams { mode: CsvImportMode mapping?: CsvHeaderMapping createColumns?: string[] + onCreated?: (importId: string) => void onProgress?: (percent: number) => void } -/** - * Async append/replace import into an existing table for large files: uploads straight to - * storage (bypassing the server's request-body cap), then kicks off the background worker. - * Resolves immediately; progress + completion arrive over the table-events SSE stream. - */ -export function useImportCsvIntoTableAsync() { +/** Imports a CSV/TSV into an existing table through the same durable resource for every size. */ +export function useImportCsvIntoTable() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ @@ -1890,98 +1914,30 @@ export function useImportCsvIntoTableAsync() { mode, mapping, createColumns, + onCreated, onProgress, }: ImportCsvIntoTableAsyncParams) => { - const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) - const response = await requestJson(importIntoTableAsyncContract, { - params: { tableId }, - body: { workspaceId, fileKey, fileName: file.name, mode, mapping, createColumns, timezone }, - }) - return response.data - }, - onError: (error, variables) => { - if (handleTableLockRejection(error, queryClient, variables.tableId)) return - logger.error('Failed to start async CSV import:', error) - toast.error(error.message, { duration: 5000 }) - }, - onSettled: (_data, _error, variables) => { - invalidateRowCount(queryClient, variables.tableId) - }, - }) -} - -interface ImportCsvIntoTableParams { - workspaceId: string - tableId: string - file: File - mode: CsvImportMode - mapping?: CsvHeaderMapping - /** CSV headers to auto-create as new columns on the target table. */ - createColumns?: string[] -} - -interface ImportCsvIntoTableResponse { - success: boolean - data?: { - tableId: string - mode: CsvImportMode - insertedCount?: number - deletedCount?: number - mappedColumns?: string[] - skippedHeaders?: string[] - unmappedColumns?: string[] - sourceFile?: string - } -} - -/** - * Upload a CSV file to an existing table in append or replace mode. Supports - * an optional explicit header-to-column mapping; when omitted the server - * auto-maps headers by sanitized name. - */ -export function useImportCsvIntoTable() { - const queryClient = useQueryClient() - const timezone = useTimezone() - - return useMutation({ - mutationFn: async ({ - workspaceId, - tableId, - file, - mode, - mapping, - createColumns, - }: ImportCsvIntoTableParams): Promise => { - // Text fields must precede the file part: the server parses the body as a - // stream and needs these fields before it reaches the (large) file. - const formData = new FormData() - formData.append('workspaceId', workspaceId) - formData.append('mode', mode) - formData.append('timezone', timezone) - if (mapping) { - formData.append('mapping', JSON.stringify(mapping)) - } - if (createColumns && createColumns.length > 0) { - formData.append('createColumns', JSON.stringify(createColumns)) - } - formData.append('file', file) - - // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies - const response = await fetch(`/api/table/${tableId}/import`, { - method: 'POST', - body: formData, + const imported = await createAndUploadTableImport({ + workspaceId, + source: { + type: 'upload', + name: file.name, + contentType: file.type || 'text/csv', + size: file.size, + }, + target: { type: 'existing', tableId, mode }, + file, + mapping, + createColumns, + timezone, + onCreated, + onProgress, }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'CSV import failed') - } - - return response.json() + return { tableId: imported.tableId, importId: imported.id } }, onError: (error, variables) => { if (handleTableLockRejection(error, queryClient, variables.tableId)) return - logger.error('Failed to import CSV into table:', error) + logger.error('Failed to start CSV import:', error) toast.error(error.message, { duration: 5000 }) }, onSettled: (_data, _error, variables) => { @@ -1990,19 +1946,12 @@ export function useImportCsvIntoTable() { }) } -/** - * Cancels an in-flight async table job (import or delete). Plain function (not a hook) because the - * job tray lists multiple tables and cancels a chosen one by id rather than binding to a single - * table. - */ -export async function cancelTableJob( - workspaceId: string, - tableId: string, - jobId: string -): Promise { - await requestJson(cancelTableJobContract, { - params: { tableId }, - body: { workspaceId, jobId }, +/** Cancels an in-flight table import resource. */ +export async function cancelTableImport(workspaceId: string, importId: string): Promise { + await requestJson(cancelTableImportResourceContract, { + params: { importId }, + query: { workspaceId }, + headers: {}, }) } @@ -2046,23 +1995,17 @@ export function consumeInitiatedExport(jobId: string): boolean { } /** - * Kicks off a background export job for large tables (small ones stream synchronously via - * {@link downloadTableExport}). The SSE job stream auto-downloads the file when the job is ready. + * Creates an export resource. The server completes small exports before responding and processes + * large exports in the background; the client follows the same path for both. */ -export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext) { +export function useExportTable({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ format }: { format: 'csv' | 'json' }) => { - const response = await requestJson(exportTableAsyncContract, { - params: { tableId }, - body: { workspaceId, format }, - }) - initiatedExportJobIds.add(response.data.jobId) - return response.data + return createTableExport(workspaceId, tableId, format) }, - onSuccess: () => { - // Surface the new running job in the tray immediately — its poll only - // self-sustains once a running job is already in the cache. + onSettled: () => { + // Reconcile failed creation and seed polling after a successful background export. void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) }, onError: (error) => { @@ -2073,15 +2016,20 @@ export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext }) } -/** Resolves a ready export job to its presigned URL and triggers the browser download. */ -export async function downloadExportResult( - workspaceId: string, - tableId: string, - jobId: string -): Promise { - const response = await requestJson(exportDownloadContract, { +async function createTableExport(workspaceId: string, tableId: string, format: 'csv' | 'json') { + const response = await requestJson(createTableExportResourceContract, { params: { tableId }, - query: { workspaceId, jobId }, + body: { workspaceId, format }, + }) + if (response.data.status !== 'completed') initiatedExportJobIds.add(response.data.id) + return response.data +} + +/** Resolves a ready export job to its presigned URL and triggers the browser download. */ +export async function downloadExportResult(workspaceId: string, exportId: string): Promise { + const response = await requestJson(downloadTableExportResourceContract, { + params: { exportId }, + query: { workspaceId }, }) const a = document.createElement('a') a.href = response.data.url @@ -2091,32 +2039,18 @@ export async function downloadExportResult( document.body.removeChild(a) } -/** - * Downloads the full contents of a table to the user's device by streaming - * `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON. - */ -export async function downloadTableExport( +/** Creates one export resource and downloads it immediately when the server completed it inline. */ +export async function exportTable( + workspaceId: string, tableId: string, - fileName: string, format: 'csv' | 'json' = 'csv' -): Promise { - const url = `/api/table/${tableId}/export?format=${format}&t=${Date.now()}` - // boundary-raw-fetch: streaming download to a Blob, requestJson cannot consume non-JSON streams - const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `Failed to export table: ${response.statusText}`) +): Promise<'completed' | 'processing'> { + const exported = await createTableExport(workspaceId, tableId, format) + if (exported.status === 'completed') { + await downloadExportResult(workspaceId, exported.id) + return 'completed' } - const blob = await response.blob() - const objectUrl = URL.createObjectURL(blob) - const safeName = fileName.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'table' - const a = document.createElement('a') - a.href = objectUrl - a.download = `${safeName}.${format}` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(objectUrl) + return 'processing' } export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { diff --git a/apps/sim/hooks/queries/utils/folder-tree.ts b/apps/sim/hooks/queries/utils/folder-tree.ts index 656c23fa4eb..efbcd624243 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.ts @@ -1,3 +1,4 @@ +import { buildFolderPath } from '@/lib/folders/paths' import type { WorkflowFolder } from '@/stores/folders/types' /** @@ -54,6 +55,31 @@ export function getFolderPath( return segments.length > 0 ? segments.join(separator) : null } +/** Returns the canonical public API path for a folder and rejects corrupt trees. */ +export function getCanonicalFolderPath( + folderId: string | null | undefined, + folders: Record | Map +): string { + if (!folderId) return '/' + + const segments: string[] = [] + const visited = new Set() + let currentFolderId: string | null | undefined = folderId + + while (currentFolderId) { + if (visited.has(currentFolderId)) throw new Error('Folder tree contains a cycle') + visited.add(currentFolderId) + + const folder: WorkflowFolder | undefined = + folders instanceof Map ? folders.get(currentFolderId) : folders[currentFolderId] + if (!folder) throw new Error(`Folder ${currentFolderId} was not found`) + segments.unshift(folder.name) + currentFolderId = folder.parentId + } + + return buildFolderPath(segments) +} + /** * Names that appear more than once in the list, so callers can disambiguate * only the entries that actually collide. diff --git a/apps/sim/hooks/queries/workspace-files.test.tsx b/apps/sim/hooks/queries/workspace-files.test.tsx index db51e9fc452..64afac2dd03 100644 --- a/apps/sim/hooks/queries/workspace-files.test.tsx +++ b/apps/sim/hooks/queries/workspace-files.test.tsx @@ -12,11 +12,21 @@ import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' +import { createWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { + useCreateWorkspaceFile, + useWorkspaceFileContent, + workspaceFilesKeys, +} from '@/hooks/queries/workspace-files' + +const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) let fetchCount = 0 beforeEach(() => { + vi.clearAllMocks() ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true fetchCount = 0 vi.stubGlobal( @@ -63,6 +73,42 @@ function renderContentHook(options?: { } } +function renderCreateHook(): { + getMutation: () => ReturnType + queryClient: QueryClient + unmount: () => void +} { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let mutation: ReturnType | undefined + + function Probe() { + mutation = useCreateWorkspaceFile() + return null + } + + act(() => { + root.render( + + + + ) + }) + + return { + getMutation: () => { + if (!mutation) throw new Error('Create mutation did not render') + return mutation + }, + queryClient, + unmount: () => { + act(() => root.unmount()) + queryClient.clear() + }, + } +} + describe('useWorkspaceFileContent refetchInterval passthrough', () => { it('fetches once and does not poll by default', async () => { const { unmount } = renderContentHook() @@ -102,3 +148,34 @@ describe('useWorkspaceFileContent refetchInterval passthrough', () => { unmount() }) }) + +describe('useCreateWorkspaceFile', () => { + it('uses the create contract and reconciles workspace file caches', async () => { + const response = { success: true, file: { id: 'wf-created' } } + mockRequestJson.mockResolvedValue(response) + const { getMutation, queryClient, unmount } = renderCreateHook() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + let result: unknown + + await act(async () => { + result = await getMutation().mutateAsync({ + workspaceId: 'ws-1', + name: 'notes.md', + contentType: 'text/markdown', + }) + }) + + expect(result).toBe(response) + expect(mockRequestJson).toHaveBeenCalledWith(createWorkspaceFileContract, { + params: { id: 'ws-1' }, + body: { name: 'notes.md', contentType: 'text/markdown' }, + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: workspaceFilesKeys.workspaceLists('ws-1'), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: workspaceFilesKeys.storageInfo(), + }) + unmount() + }) +}) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index ad49ba3e283..0c5d0f09cbf 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -1,26 +1,23 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ApiClientError, isApiClientError } from '@/lib/api/client/errors' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { + type CreateWorkspaceFileBody, + createWorkspaceFileContract, deleteWorkspaceFileContract, listWorkspaceFilesContract, - registerWorkspaceFileContract, renameWorkspaceFileContract, restoreWorkspaceFileContract, updateWorkspaceFileContentContract, } from '@/lib/api/contracts/workspace-files' -import { - DirectUploadError, - runUploadStrategy, - type UploadProgressEvent, -} from '@/lib/uploads/client/direct-upload' +import { uploadWorkspaceFileSession } from '@/lib/uploads/client/session-upload' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UserFile } from '@/executor/types' import { useFileContentSource } from '@/hooks/use-file-content-source' @@ -335,41 +332,6 @@ interface UploadFileResponse { file: UserFile } -async function uploadViaApiFallback( - workspaceId: string, - file: File, - folderId?: string | null, - signal?: AbortSignal -): Promise { - const formData = new FormData() - formData.append('file', file) - if (folderId) formData.append('folderId', folderId) - - // boundary-raw-fetch: multipart/form-data fallback upload, requestJson only supports JSON bodies - const response = await fetch(`/api/workspaces/${workspaceId}/files`, { - method: 'POST', - body: formData, - signal, - }) - - return parseUploadResponse(response, 'Upload failed') -} - -async function parseUploadResponse( - response: Response, - fallbackMessage: string -): Promise { - let data: { success?: boolean; error?: string; file?: UserFile } | null = null - try { - data = await response.json() - } catch {} - - if (!response.ok || !data?.success) { - throw new Error(data?.error || `${fallbackMessage} (${response.status})`) - } - return data as UploadFileResponse -} - async function uploadWorkspaceFile( workspaceId: string, file: File, @@ -377,69 +339,25 @@ async function uploadWorkspaceFile( onProgress?: (event: UploadProgressEvent) => void, signal?: AbortSignal ): Promise { - let result - try { - result = await runUploadStrategy({ - file, - presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, - presignedBody: { folderId }, - workspaceId, + const uploaded = await uploadWorkspaceFileSession({ + workspaceId, + folderId, + file, + onProgress, + signal, + }) + return { + success: true, + file: { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + type: uploaded.type, + url: `/api/files/serve/${encodeURIComponent(uploaded.key)}?context=workspace`, + key: uploaded.key, context: 'workspace', - onProgress, - signal, - }) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - return uploadViaApiFallback(workspaceId, file, folderId, signal) - } - throw error - } - - const data = await registerWithRetry(workspaceId, result, folderId, signal) - - if (!data.success || !data.file) { - throw new Error(data.error || 'Failed to register file') - } - return { success: true, file: data.file } -} - -const REGISTER_MAX_ATTEMPTS = 3 -const REGISTER_RETRY_DELAY_MS = 500 - -/** - * Register the uploaded object with bounded retries. The server-side handler - * is idempotent (existing-record short-circuit), so safely retrying handles - * dropped responses that would otherwise orphan the object in storage. - */ -async function registerWithRetry( - workspaceId: string, - result: { key: string; name: string; contentType: string }, - folderId?: string | null, - signal?: AbortSignal -) { - let lastError: unknown - for (let attempt = 1; attempt <= REGISTER_MAX_ATTEMPTS; attempt++) { - try { - return await requestJson(registerWorkspaceFileContract, { - params: { id: workspaceId }, - body: { - key: result.key, - name: result.name, - contentType: result.contentType, - folderId, - }, - signal, - }) - } catch (error) { - lastError = error - if (signal?.aborted) throw error - const isTransient = - !(error instanceof ApiClientError) || (error.status >= 500 && error.status < 600) - if (!isTransient || attempt === REGISTER_MAX_ATTEMPTS) throw error - await sleep(REGISTER_RETRY_DELAY_MS * attempt) - } + }, } - throw lastError } export function useUploadWorkspaceFile() { @@ -471,6 +389,31 @@ export function useUploadWorkspaceFile() { }) } +type CreateWorkspaceFileParams = CreateWorkspaceFileBody & { + workspaceId: string +} + +export function useCreateWorkspaceFile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, ...body }: CreateWorkspaceFileParams) => + requestJson(createWorkspaceFileContract, { + params: { id: workspaceId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId), + }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) + }, + onError: (error) => { + logger.error('Failed to create file:', error) + }, + }) +} + /** * Update workspace file content mutation */ diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index 197ebec5c22..8887e319a69 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -8,7 +8,7 @@ import { } from '@/lib/workflows/deployment-lifecycle' import type { WorkflowState } from '@/stores/workflows/workflow/types' -const deployedWorkflowStateSchema = z.custom( +export const deployedWorkflowStateSchema = z.custom( (value) => typeof value === 'object' && value !== null, 'Expected workflow state' ) diff --git a/apps/sim/lib/api/contracts/file-uploads.ts b/apps/sim/lib/api/contracts/file-uploads.ts deleted file mode 100644 index 10a1868ef6c..00000000000 --- a/apps/sim/lib/api/contracts/file-uploads.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' - -export const fileUploadTypeSchema = z.enum([ - 'knowledge-base', - 'chat', - 'copilot', - 'profile-pictures', -]) - -export const fileUploadTypeQuerySchema = z.object({ - type: fileUploadTypeSchema, -}) - -export const presignedUploadBodySchema = z - .object({ - fileName: z.string().optional(), - contentType: z.string().optional(), - fileSize: z.number().optional(), - userId: z.string().optional(), - chatId: z.string().optional(), - }) - .passthrough() - -export const batchPresignedUploadBodySchema = z - .object({ - files: z - .array( - z - .object({ - fileName: z.string().optional(), - contentType: z.string().optional(), - fileSize: z.number().optional(), - }) - .passthrough() - ) - .optional(), - }) - .passthrough() - -export const presignedFileInfoSchema = z - .object({ - path: z.string(), - key: z.string(), - name: z.string(), - size: z.number(), - type: z.string(), - }) - .passthrough() - -export const presignedUploadResponseSchema = z - .object({ - fileName: z.string(), - presignedUrl: z.string(), - fileInfo: presignedFileInfoSchema, - uploadHeaders: z.record(z.string(), z.string()).optional(), - directUploadSupported: z.boolean(), - }) - .passthrough() - -export const batchPresignedUploadResponseSchema = z - .object({ - files: z.array(presignedUploadResponseSchema), - directUploadSupported: z.boolean(), - }) - .passthrough() - -export const createPresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned', - query: fileUploadTypeQuerySchema, - body: presignedUploadBodySchema, - response: { - mode: 'json', - schema: presignedUploadResponseSchema, - }, -}) - -export const createBatchPresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned/batch', - query: fileUploadTypeQuerySchema, - body: batchPresignedUploadBodySchema, - response: { - mode: 'json', - schema: batchPresignedUploadResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 10ad693347c..1b74fdb1567 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -12,7 +12,6 @@ export * from './desktop-auth' export * from './desktop-tool-authorization' export * from './environment' export * from './execution-payloads' -export * from './file-uploads' export * from './folders' export * from './hotspots' export * from './inbox' diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 701073e3a23..2c142049afc 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -7,7 +7,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { defineRouteContract } from '@/lib/api/contracts/types' import type { StrategyOptions } from '@/lib/chunkers/types' -import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, +} from '@/lib/knowledge/constants' export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all']) export type KnowledgeScope = z.output @@ -67,11 +70,7 @@ export const createKnowledgeBaseBodySchema = z.object({ folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingDimension: z.literal(1536).default(1536), - chunkingConfig: chunkingConfigSchema.default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), + chunkingConfig: chunkingConfigSchema.default(DEFAULT_CHUNKING_CONFIG), }) export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index ed147c741a1..cef3d8718d1 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -22,7 +22,8 @@ export const updateConnectorBodySchema = z.object({ }) export const deleteConnectorQuerySchema = z.object({ - deleteDocuments: z.boolean().optional(), + /** Also hard-delete the documents the connector produced; kept by default. */ + deleteDocuments: booleanQueryFlagSchema.optional().default(false), }) export const connectorDocumentsQuerySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts new file mode 100644 index 00000000000..805b40a6936 --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts @@ -0,0 +1,55 @@ +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateKnowledgeDocumentUploadBodySchema, + v2CreateKnowledgeDocumentUploadDataSchema, + v2KnowledgeDocumentUploadParamsSchema, + v2KnowledgeDocumentUploadSchema, + v2UploadKnowledgeDocumentQuerySchema, +} from '@/lib/api/contracts/v2/knowledge' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/[id]/documents/uploads', + params: v2KnowledgeDocumentUploadParamsSchema.omit({ uploadId: true }), + body: v2CreateKnowledgeDocumentUploadBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + }, +}) + +export const abortKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/knowledge/[id]/documents/uploads/[uploadId]', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +export const createKnowledgeDocumentUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/[id]/documents/uploads/[uploadId]/parts', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/[id]/documents/uploads/[uploadId]/complete', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 2e4a2753ae6..4e8071a2f6d 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { userFileSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { cancelWorkflowExecutionReasonSchema } from '@/lib/api/contracts/workflows' const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!=']) @@ -327,7 +328,7 @@ export const cancelWorkflowExecutionResponseSchema = z.object({ durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), - reason: z.enum(['recorded', 'redis_unavailable', 'redis_write_failed']), + reason: cancelWorkflowExecutionReasonSchema, }) export type SegmentStats = z.output diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 204e6e9d2fe..fe8ad146961 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { customPatternSchema, + isCanonicalBase64, organizationIdSchema, piiStagePolicySchema, piiStagesSchema, @@ -12,6 +13,19 @@ import { workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('isCanonicalBase64', () => { + it.each(['', 'TQ==', 'TWE=', 'TWFu', 'AAEC/w=='])('accepts canonical base64 %j', (value) => { + expect(isCanonicalBase64(value)).toBe(true) + }) + + it.each(['TQ', 'TQ=', 'TQ===', 'T=Q=', 'TQ==\n', 'TR==', 'TWF='])( + 'rejects malformed or non-canonical base64 %j', + (value) => { + expect(isCanonicalBase64(value)).toBe(false) + } + ) +}) + describe('customPatternSchema', () => { it('accepts a well-formed pattern', () => { expect( diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index f899a27174f..142e0990a54 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -37,6 +37,50 @@ export function flattenFieldErrors( export const noInputSchema = z.object({}).strict() export type NoInput = z.output +/** + * Accepts canonical RFC 4648 base64, including the empty encoding used for a + * zero-byte file. Padding is required when the final quantum is incomplete, + * and non-zero unused pad bits are rejected. + */ +export function isCanonicalBase64(value: string): boolean { + if (value.length === 0) return true + if (value.length % 4 !== 0) return false + + let contentLength = value.length + while (contentLength > 0 && value.charCodeAt(contentLength - 1) === 61) { + contentLength -= 1 + } + + const paddingLength = value.length - contentLength + if (paddingLength > 2) return false + if (paddingLength === 1 && contentLength % 4 !== 3) return false + if (paddingLength === 2 && contentLength % 4 !== 2) return false + + let finalSextet = 0 + for (let index = 0; index < contentLength; index += 1) { + const code = value.charCodeAt(index) + const sextet = + code >= 65 && code <= 90 + ? code - 65 + : code >= 97 && code <= 122 + ? code - 71 + : code >= 48 && code <= 57 + ? code + 4 + : code === 43 + ? 62 + : code === 47 + ? 63 + : -1 + + if (sextet === -1) return false + finalSextet = sextet + } + + if (paddingLength === 1 && (finalSextet & 0b11) !== 0) return false + if (paddingLength === 2 && (finalSextet & 0b1111) !== 0) return false + return true +} + export const jobIdParamsSchema = z.object({ jobId: z.string().min(1), }) diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 31af51cd489..3b29193d87b 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -36,13 +36,13 @@ export const skillEditorSchema = z.object({ export type SkillEditor = z.output -const skillNameSchema = z +export const skillNameSchema = z .string() .min(1, 'Skill name is required') .max(64) .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)') -const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) -const skillContentSchema = z +export const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) +export const skillContentSchema = z .string() .min(1, 'Content is required') .max(50_000, 'Content is too large') diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index b5ad82d362c..aaa0a54f5ba 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -1,8 +1,4 @@ import { z } from 'zod' -import { - batchPresignedUploadResponseSchema, - presignedUploadResponseSchema, -} from '@/lib/api/contracts/file-uploads' import { workspaceFileIdSchema } from '@/lib/api/contracts/primitives' import { type ContractBodyInput, @@ -19,56 +15,6 @@ import { const jsonResponseSchema = z.unknown() -function formatFileSize(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes' - const k = 1024 - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1) - const value = bytes / k ** i - return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${sizes[i]}` -} - -const multipartPartUrlSchema = z.object({ - partNumber: z.number(), - url: z.string(), - blockId: z.string().optional(), -}) - -const multipartCompletedUploadSchema = z.object({ - success: z.literal(true), - location: z.string(), - path: z.string(), - key: z.string(), -}) - -export const initiateMultipartResponseSchema = z.object({ - uploadId: z.string(), - key: z.string(), - uploadToken: z.string(), -}) - -export const getMultipartPartUrlsResponseSchema = z.object({ - presignedUrls: z.array(multipartPartUrlSchema), -}) - -export const completeMultipartResponseSchema = z.union([ - multipartCompletedUploadSchema, - z.object({ - results: z.array(multipartCompletedUploadSchema), - }), -]) - -export const abortMultipartResponseSchema = z.object({ - success: z.literal(true), -}) - -export const multipartUploadResponseSchema = z.union([ - initiateMultipartResponseSchema, - getMultipartPartUrlsResponseSchema, - completeMultipartResponseSchema, - abortMultipartResponseSchema, -]) - const connectionFields = { host: z.string().min(1, 'Host is required'), port: z.coerce.number().int().positive().default(22), @@ -325,168 +271,6 @@ export const fileDeleteBodySchema = z }) .passthrough() -const MAX_FILE_SIZE = 100 * 1024 * 1024 -export const validUploadTypes = [ - 'knowledge-base', - 'chat', - 'copilot', - 'profile-pictures', - 'mothership', - 'workspace-logos', - 'execution', -] as const - -export const uploadTypeSchema = z.enum(validUploadTypes) - -/** - * Storage contexts a client may mint a single presigned upload URL for. Each one - * has a per-context authorization predicate in `/api/files/presigned`; a context - * that cannot be authorized must not be listed here. `chat` is deliberately - * absent — it has no owning entity to authorize against and no client that mints - * one (chat assets go through the server-proxied `/api/files/upload`). - */ -export const presignedUploadTypes = [ - 'knowledge-base', - 'copilot', - 'profile-pictures', - 'mothership', - 'workspace-logos', - 'execution', -] as const - -export const presignedUploadTypeSchema = z.enum(presignedUploadTypes) - -/** - * Storage contexts `/api/files/presigned/batch` serves. Batching exists only for - * knowledge-base ingest; no other context has a batch client, and the batch - * endpoint carries no authorization predicate for one. - */ -export const batchPresignedUploadTypes = ['knowledge-base'] as const - -export const batchPresignedUploadTypeSchema = z.enum(batchPresignedUploadTypes) - -export const presignedUploadQuerySchema = z.object({ - type: presignedUploadTypeSchema, -}) - -export const presignedUrlBodySchema = z - .object({ - fileName: z - .string({ error: 'fileName is required and cannot be empty' }) - .refine((value) => value.trim().length > 0, { - message: 'fileName is required and cannot be empty', - }), - contentType: z - .string({ error: 'contentType is required and cannot be empty' }) - .refine((value) => value.trim().length > 0, { - message: 'contentType is required and cannot be empty', - }), - fileSize: z - .number({ error: 'fileSize must be a positive number' }) - .positive('fileSize must be a positive number') - .superRefine((val, ctx) => { - if (val > MAX_FILE_SIZE) { - ctx.addIssue({ - code: 'custom', - message: `File size ${formatFileSize(val)} exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}`, - }) - } - }), - userId: z.string().optional(), - chatId: z.string().optional(), - }) - .passthrough() - -export const batchPresignedUrlBodySchema = z - .object({ - files: z - .array( - z - .object({ - fileName: z.string().refine((value) => value.trim().length > 0, { - message: 'fileName is required for all files', - }), - contentType: z.string().refine((value) => value.trim().length > 0, { - message: 'contentType is required for all files', - }), - fileSize: z.number(), - }) - .passthrough() - .superRefine((file, ctx) => { - const name = typeof file.fileName === 'string' ? file.fileName : 'file' - if (!Number.isFinite(file.fileSize) || file.fileSize <= 0) { - ctx.addIssue({ - code: 'custom', - path: ['fileSize'], - message: `${name} is empty (fileSize must be greater than 0)`, - }) - } else if (file.fileSize > MAX_FILE_SIZE) { - ctx.addIssue({ - code: 'custom', - path: ['fileSize'], - message: `${name} (${formatFileSize(file.fileSize)}) exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}`, - }) - } - }) - ) - .min(1, 'files array is required and cannot be empty') - .max(100, 'Cannot process more than 100 files at once'), - }) - .passthrough() - -export const multipartActionSchema = z.enum(['initiate', 'get-part-urls', 'complete', 'abort']) - -export const initiateMultipartBodySchema = z - .object({ - fileName: z.string(), - contentType: z.string(), - fileSize: z.number(), - workspaceId: z.string({ error: 'workspaceId is required' }).min(1, 'workspaceId is required'), - context: z.string().optional(), - }) - .passthrough() - -export const tokenBoundMultipartBodySchema = z - .object({ - uploadToken: z.string().optional(), - }) - .passthrough() - -export const getMultipartPartUrlsBodySchema = tokenBoundMultipartBodySchema.extend({ - partNumbers: z.array(z.number()), -}) - -export const completeMultipartBodySchema = z - .object({ - uploadToken: z.string().optional(), - parts: z.unknown().optional(), - uploads: z - .array( - z - .object({ - uploadToken: z.string().optional(), - parts: z.unknown().optional(), - }) - .passthrough() - ) - .optional(), - }) - .passthrough() - -export type CompleteMultipartBody = z.output - -export const uploadFilesFormFilesSchema = z.preprocess( - (value) => (Array.isArray(value) ? value.filter((entry) => entry instanceof File) : value), - z.array(z.custom((value) => value instanceof File)).min(1, 'No files provided') -) - -export const uploadFilesFormFieldsSchema = z.object({ - workflowId: z.string().nullable(), - executionId: z.string().nullable(), - workspaceId: z.string().nullable(), - context: z.string().nullable(), -}) - export const fileServeParamsSchema = z.object({ path: z.array(z.string()).min(1), }) @@ -680,76 +464,6 @@ export const fileDeleteContract = defineRouteContract({ response: { mode: 'json', schema: jsonResponseSchema }, }) -export const fileUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/upload', - response: { mode: 'json', schema: jsonResponseSchema }, -}) - -export const presignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned', - query: presignedUploadQuerySchema, - body: presignedUrlBodySchema, - response: { mode: 'json', schema: presignedUploadResponseSchema }, -}) - -export const presignedUploadBodyContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned', - body: presignedUrlBodySchema, - response: { mode: 'json', schema: presignedUploadResponseSchema }, -}) - -export const batchPresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned/batch', - query: presignedUploadQuerySchema, - body: batchPresignedUrlBodySchema, - response: { mode: 'json', schema: batchPresignedUploadResponseSchema }, -}) - -export const batchPresignedUploadBodyContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned/batch', - body: batchPresignedUrlBodySchema, - response: { mode: 'json', schema: batchPresignedUploadResponseSchema }, -}) - -export const multipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - response: { mode: 'json', schema: multipartUploadResponseSchema }, -}) - -export const initiateMultipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: initiateMultipartBodySchema, - response: { mode: 'json', schema: initiateMultipartResponseSchema }, -}) - -export const getMultipartPartUrlsContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: getMultipartPartUrlsBodySchema, - response: { mode: 'json', schema: getMultipartPartUrlsResponseSchema }, -}) - -export const completeMultipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: completeMultipartBodySchema, - response: { mode: 'json', schema: completeMultipartResponseSchema }, -}) - -export const abortMultipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: tokenBoundMultipartBodySchema, - response: { mode: 'json', schema: abortMultipartResponseSchema }, -}) - export const fileServeContract = defineRouteContract({ method: 'GET', path: '/api/files/serve/[...path]', @@ -815,16 +529,6 @@ export type FileParseBody = ContractBodyInput export type FileParseResponse = ContractJsonResponse export type FileDeleteBody = ContractBodyInput export type FileDeleteResponse = ContractJsonResponse -export type PresignedUploadQuery = ContractQueryInput -export type PresignedUploadBody = ContractBodyInput -export type PresignedUploadResponse = ContractJsonResponse -export type BatchPresignedUploadQuery = ContractQueryInput -export type BatchPresignedUploadBody = ContractBodyInput -export type BatchPresignedUploadResponse = ContractJsonResponse -export type MultipartAction = z.output -export type InitiateMultipartBody = z.output -export type TokenBoundMultipartBody = z.output -export type GetMultipartPartUrlsBody = z.output export type FileServeParams = ContractParamsInput export type FileServeQuery = ContractQueryInput export type FileViewParams = ContractParamsInput diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts new file mode 100644 index 00000000000..3b83e074603 --- /dev/null +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -0,0 +1,96 @@ +import { exportTableAsyncBodySchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CreateTableImportBodySchema, + v2CreateTableImportDataSchema, + v2TableExportDownloadDataSchema, + v2TableExportParamsSchema, + v2TableExportSchema, + v2TableImportParamsSchema, + v2TableImportSchema, + v2TableTransferWorkspaceQuerySchema, +} from '@/lib/api/contracts/v2/tables' +import { + v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, +}) + +export const getTableImportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const cancelTableImportResourceContract = defineRouteContract({ + method: 'DELETE', + path: '/api/table/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableExportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/exports', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const getTableExportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const cancelTableExportResourceContract = defineRouteContract({ + method: 'DELETE', + path: '/api/table/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const downloadTableExportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/exports/[exportId]/download', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 075a1a8a199..d7abbcbe383 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -32,7 +32,7 @@ import { SORT_DIRECTIONS, TABLE_LIMITS, } from '@/lib/table/constants' -import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' export const domainObjectSchema = () => z.custom(isRecordLike) @@ -127,7 +127,7 @@ function refineColumnOptions( * Identifier for tables/columns: starts with letter or underscore, contains * only alphanumerics + underscores, capped at `MAX_TABLE_NAME_LENGTH`. */ -const tableNameSchema = z +export const tableNameSchema = z .string() .min(1, 'Name is required') .max( @@ -477,14 +477,16 @@ function predicateTreeTooLarge(root: unknown): string | null { * before it ran. Strict on BOTH branches is required: strict on the group alone * would just fall through to the leaf branch, which is the more dangerous reading. */ -// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value -// is arbitrary JSON), but infers `unknown`, which is wider than -// `Predicate['value']`. The narrowing is type-level only — nothing is coerced. -const predicateLeafSchema = z.strictObject({ +const predicateLeafObjectSchema = z.strictObject({ field: z.string().min(1, 'field is required').max(128), op: z.enum(FILTER_OPS), value: z.unknown().optional(), -}) as unknown as z.ZodType +}) + +// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value +// is arbitrary JSON), but infers `unknown`, which is wider than +// `Predicate['value']`. The narrowing is type-level only — nothing is coerced. +const predicateLeafSchema = predicateLeafObjectSchema as unknown as z.ZodType const predicateNodeSchema: z.ZodType = z.lazy(() => z.union([predicateGroupSchema, predicateLeafSchema]) @@ -1036,7 +1038,7 @@ export const csvFileSchema = z if (value.size > CSV_MAX_FILE_SIZE_BYTES) { ctx.addIssue({ code: 'custom', - message: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`, + message: CSV_MAX_FILE_SIZE_MESSAGE, }) } }) @@ -1400,7 +1402,7 @@ const workflowGroupInputMappingSchema = z.object({ columnName: z.string().min(1, 'columnName cannot be empty'), }) -const workflowGroupOutputColumnSchema = z.object({ +export const workflowGroupOutputColumnSchema = z.object({ name: z.string().min(1), type: columnTypeSchema, required: z.boolean().optional(), @@ -1532,44 +1534,60 @@ export const deleteWorkflowGroupContract = defineRouteContract({ * cells on rows matching it (filtered "select all" Stop) * - `row` — every running/pending cell for a specific row (`rowId` required) */ -export const cancelTableRunsBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - scope: z.enum(['all', 'row']), - rowId: z.string().min(1).optional(), - filter: z.union([predicateSchema, domainObjectSchema()]).optional(), - /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - }) - .superRefine((value, ctx) => { - if (value.scope === 'row' && !value.rowId) { - ctx.addIssue({ - code: 'custom', - path: ['rowId'], - message: 'rowId is required when scope is "row"', - }) - } - if (value.scope === 'row' && value.filter) { - ctx.addIssue({ - code: 'custom', - path: ['filter'], - message: 'filter only applies to scope "all"', - }) - } - if (value.scope === 'row' && value.excludeRowIds) { - ctx.addIssue({ - code: 'custom', - path: ['excludeRowIds'], - message: 'excludeRowIds only applies to scope "all"', - }) - } - }) +/** + * Plain-object base for the cancel-runs body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying {@link refineCancelTableRunsScope} — Zod forbids + * `.extend()` on a refined schema. + */ +export const cancelTableRunsBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + scope: z.enum(['all', 'row']), + rowId: z.string().min(1).optional(), + filter: z.union([predicateSchema, domainObjectSchema()]).optional(), + /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), +}) + +/** + * `row` scope names exactly one row, so it requires `rowId` and rejects the + * two select-all-only narrowing fields rather than ignoring them — a caller + * that sends both has misunderstood the scope. + */ +export function refineCancelTableRunsScope(value: { + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: string[] +}): { path: string[]; message: string }[] { + if (value.scope !== 'row') return [] + const issues: { path: string[]; message: string }[] = [] + if (!value.rowId) { + issues.push({ path: ['rowId'], message: 'rowId is required when scope is "row"' }) + } + if (value.filter) { + issues.push({ path: ['filter'], message: 'filter only applies to scope "all"' }) + } + if (value.excludeRowIds) { + issues.push({ + path: ['excludeRowIds'], + message: 'excludeRowIds only applies to scope "all"', + }) + } + return issues +} + +export const cancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema.superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } +}) export const cancelTableRunsContract = defineRouteContract({ method: 'POST', @@ -1633,32 +1651,47 @@ export const runLimitSchema = z.object({ .max(1_000_000, 'max cannot exceed 1,000,000'), }) -export const runColumnBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - groupIds: z.array(z.string().min(1)).min(1), - runMode: z.enum(['all', 'incomplete']).default('all'), - rowIds: z.array(z.string().min(1)).min(1).optional(), - /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The - * dispatcher walks only matching rows (paginated), so no id list is materialized. */ - filter: bulkFilterSchema.optional(), - /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ - limit: runLimitSchema.optional(), - }) - .refine((data) => !(data.rowIds && data.filter), { - message: 'Provide either filter or rowIds, but not both', - }) - .refine((data) => !(data.rowIds && data.excludeRowIds), { - message: 'excludeRowIds only applies to select-all scope (no rowIds)', - }) +/** + * Plain-object base for the run-column body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying the mutex refines — Zod forbids `.extend()` on a + * refined schema. + */ +export const runColumnBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + groupIds: z.array(z.string().min(1)).min(1), + runMode: z.enum(['all', 'incomplete']).default('all'), + rowIds: z.array(z.string().min(1)).min(1).optional(), + /** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The + * dispatcher walks only matching rows (paginated), so no id list is materialized. */ + filter: bulkFilterSchema.optional(), + /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), + /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ + limit: runLimitSchema.optional(), +}) + +/** An explicit row set and a select-all filter are mutually exclusive scopes. */ +export const runColumnScopeMutexRefine = [ + (data: { rowIds?: string[]; filter?: unknown }) => !(data.rowIds && data.filter), + { message: 'Provide either filter or rowIds, but not both' }, +] as const + +/** Deselections only mean something under select-all scope. */ +export const runColumnExcludeMutexRefine = [ + (data: { rowIds?: string[]; excludeRowIds?: string[] }) => !(data.rowIds && data.excludeRowIds), + { message: 'excludeRowIds only applies to select-all scope (no rowIds)' }, +] as const + +export const runColumnBodySchema = runColumnBodyBaseSchema + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) export const runColumnContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts new file mode 100644 index 00000000000..4e3a5153f3f --- /dev/null +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -0,0 +1,221 @@ +import { z } from 'zod' +import { folderIdSchema, workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2FileSchema } from '@/lib/api/contracts/v2/files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, + v2UploadTransferSchema, +} from '@/lib/api/contracts/v2/uploads' +import { executionIdSchema } from '@/lib/api/contracts/workflows' +import { + MAX_WORKSPACE_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, +} from '@/lib/uploads/shared/types' + +const MAX_ASSET_FILE_SIZE = 5 * 1024 * 1024 + +const internalFileUploadBaseShape = { + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z + .string() + .trim() + .min(1, 'contentType is required') + .max(255, 'contentType is too long'), +} as const + +export const createInternalFileUploadBodySchema = z.discriminatedUnion('purpose', [ + z + .object({ + purpose: z.literal('workspace_file'), + ...internalFileUploadBaseShape, + size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), + workspaceId: workspaceIdSchema, + folderId: folderIdSchema.optional(), + }) + .strict(), + z + .object({ + purpose: z.literal('profile_picture'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_ASSET_FILE_SIZE), + }) + .strict(), + z + .object({ + purpose: z.literal('workspace_logo'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_ASSET_FILE_SIZE), + workspaceId: workspaceIdSchema, + }) + .strict(), + z + .object({ + purpose: z.literal('mothership_attachment'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), + workspaceId: workspaceIdSchema, + }) + .strict(), + z + .object({ + purpose: z.literal('execution_attachment'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_WORKSPACE_FORMDATA_FILE_SIZE), + workspaceId: workspaceIdSchema, + workflowId: workflowIdSchema, + executionId: executionIdSchema, + }) + .strict(), +]) +export type CreateInternalFileUploadBody = z.input + +export const internalFileUploadParamsSchema = z.object({ + uploadId: z.string().min(1, 'uploadId is required'), +}) + +const internalFileUploadSessionBaseShape = { + id: z.string().min(1), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), +} as const + +export const internalUploadedAssetSchema = z + .object({ + path: z.string().min(1), + key: z.string().min(1), + name: z.string().min(1), + size: z.number().int().positive(), + type: z.string().min(1), + }) + .strict() + +export const internalExecutionAttachmentSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + size: z.number().int().positive(), + type: z.string().min(1), + url: z.string().min(1), + key: z.string().min(1), + context: z.literal('execution'), + }) + .strict() + +export const internalFileUploadSessionSchema = z.discriminatedUnion('purpose', [ + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('workspace_file'), + size: z.number().int().nonnegative(), + result: v2FileSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('profile_picture'), + size: z.number().int().positive(), + result: internalUploadedAssetSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('workspace_logo'), + size: z.number().int().positive(), + result: internalUploadedAssetSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('mothership_attachment'), + size: z.number().int().positive(), + result: internalUploadedAssetSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('execution_attachment'), + size: z.number().int().positive(), + result: internalExecutionAttachmentSchema.nullable(), + }) + .strict(), +]) +export type InternalFileUploadSession = z.output + +export const createInternalFileUploadDataSchema = z + .object({ + session: internalFileUploadSessionSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type CreateInternalFileUploadData = z.output + +export const createInternalFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads', + body: createInternalFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(createInternalFileUploadDataSchema) }, +}) + +export const abortInternalFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/files/uploads/[uploadId]', + params: internalFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(internalFileUploadSessionSchema) }, +}) + +export const createInternalFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/parts', + params: internalFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeInternalFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/complete', + params: internalFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(internalFileUploadSessionSchema) }, +}) + +export const localUploadPartParamsSchema = z.object({ + uploadId: z.string().min(1, 'uploadId is required'), + partNumber: z.coerce.number().int().min(1), +}) + +export const localUploadPartQuerySchema = z.object({ + token: z.string().min(1, 'token is required'), +}) + +export const localUploadPartContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/uploads/[uploadId]/parts/[partNumber]', + params: localUploadPartParamsSchema, + query: localUploadPartQuerySchema, + response: { mode: 'empty', status: 204 }, +}) + +export const localPutUploadContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/uploads/[uploadId]', + params: internalFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'empty', status: 204 }, +}) diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 286eda6fa03..655a69ecd74 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -342,12 +342,16 @@ export const usageLogEntrySchema = z.object({ * Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = * $5), apportioned across the page so row credits always sum exactly to * the page's rounded total — this can legitimately be 0 for a row with a - * real but sub-credit `dollarCost` once a sibling row absorbs the shared - * rounding remainder. + * real but sub-credit charge once a sibling row absorbs the shared + * rounding remainder (see `hasCost`). */ creditCost: z.number(), - /** Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event. */ - dollarCost: z.number(), + /** + * Whether the event carried any real charge — distinguishes a row whose + * `creditCost` apportioned to 0 from a genuinely free event, without putting + * raw dollar costs on the wire. + */ + hasCost: z.boolean(), }) export const usageLogsApiResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 192415b06da..af86a5f40c1 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -147,7 +147,8 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - usageCaptured: z.boolean(), + /** Dollar amount of departed-member usage captured (0 when none). */ + usageCaptured: z.number(), proRestored: z.boolean(), usageRestored: z.boolean(), skipBillingLogic: z.boolean(), @@ -190,8 +191,10 @@ const adminV1TransferOwnershipResultSchema = z.object({ currentOwnerUserId: z.string(), newOwnerUserId: z.string(), workspacesReassigned: z.number(), - billedAccountReassigned: z.boolean(), - overageMigrated: z.boolean(), + /** Count of workspaces whose billed account was reassigned to the new owner. */ + billedAccountReassigned: z.number(), + /** Decimal-string dollar amount of overage migrated to the new owner ('0' when none). */ + overageMigrated: z.string(), billingBlockInherited: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/v1/audit-logs.ts b/apps/sim/lib/api/contracts/v1/audit-logs.ts index f82b86e4b6d..4ce86e22e9b 100644 --- a/apps/sim/lib/api/contracts/v1/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v1/audit-logs.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + adminV1ListResponseSchema, + adminV1PaginationQuerySchema, + adminV1SingleResponseSchema, +} from '@/lib/api/contracts/v1/admin/shared' +import { v1UserLimitsSchema } from '@/lib/api/contracts/v1/shared' const isoDateString = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date format. Use ISO 8601.', @@ -43,25 +49,51 @@ export const v1AdminAuditLogsQuerySchema = z.object({ actorEmail: optionalQueryString, startDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), + ...adminV1PaginationQuerySchema.shape, }) /** - * Generic wrapper used by v1 admin audit-log responses. The `data` and - * `limits` halves are intentionally `z.unknown()` because this proxy returns - * provider-shaped payloads that vary per route family; tightening here would - * require a discriminated union per route, which is tracked as a follow-up. - * - * boundary-policy: this is the "validates nothing" alias form that the audit - * script's `untyped-response` regex doesn't currently catch. Treat any new - * wrapper of this shape the same way and either annotate at the contract use - * site with `// untyped-response: ` or replace with a concrete schema. + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts`; `ipAddress`/`userAgent` are intentionally + * excluded for privacy. `metadata` is genuinely arbitrary per-action JSON. */ -const apiResponseWithLimitsSchema = z - .object({ - data: z.unknown(), - limits: z.unknown().optional(), - }) - .passthrough() +const v1AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +/** + * Admin audit-log entry. Mirrors `toAdminAuditLog` in `app/api/v1/admin/types.ts`, + * which additionally exposes `ipAddress`/`userAgent`. + */ +const adminV1AuditLogEntrySchema = v1AuditLogEntrySchema.extend({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), +}) + +const v1ListAuditLogsResponseSchema = z.object({ + data: z.array(v1AuditLogEntrySchema), + nextCursor: z.string().optional(), + limits: v1UserLimitsSchema, +}) + +const v1GetAuditLogResponseSchema = z.object({ + data: v1AuditLogEntrySchema, + limits: v1UserLimitsSchema, +}) + +export type V1AuditLogEntry = z.output +export type AdminV1AuditLogEntry = z.output export const v1ListAuditLogsContract = defineRouteContract({ method: 'GET', @@ -69,7 +101,7 @@ export const v1ListAuditLogsContract = defineRouteContract({ query: v1ListAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1ListAuditLogsResponseSchema, }, }) @@ -79,7 +111,7 @@ export const v1GetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1GetAuditLogResponseSchema, }, }) @@ -89,7 +121,7 @@ export const v1AdminListAuditLogsContract = defineRouteContract({ query: v1AdminAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1ListResponseSchema(adminV1AuditLogEntrySchema), }, }) @@ -99,6 +131,6 @@ export const v1AdminGetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1SingleResponseSchema(adminV1AuditLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 76abfcf16a0..926786090b4 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -7,7 +7,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, +} from '@/lib/knowledge/constants' /** * Public API v1 schemas (`/api/v1/knowledge/**`) @@ -25,9 +28,9 @@ import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants /** Simpler chunking config used by the public API (no `strategy`). */ export const v1ChunkingConfigSchema = z.object({ - maxSize: z.number().min(100).max(4000).default(1024), - minSize: z.number().min(1).max(2000).default(100), - overlap: z.number().min(0).max(500).default(200), + maxSize: z.number().min(100).max(4000).default(DEFAULT_CHUNKING_CONFIG.maxSize), + minSize: z.number().min(1).max(2000).default(DEFAULT_CHUNKING_CONFIG.minSize), + overlap: z.number().min(0).max(500).default(DEFAULT_CHUNKING_CONFIG.overlap), }) /** GET `/api/v1/knowledge` — list knowledge bases scoped to a workspace. */ @@ -46,11 +49,7 @@ export const v1CreateKnowledgeBaseBodySchema = z.object({ `Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less` ) .optional(), - chunkingConfig: v1ChunkingConfigSchema.optional().default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), + chunkingConfig: v1ChunkingConfigSchema.optional().default(DEFAULT_CHUNKING_CONFIG), }) /** GET/DELETE `/api/v1/knowledge/[id]` — workspace scope param. */ diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts new file mode 100644 index 00000000000..9502e57ee5f --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Rate-limit / usage envelope injected into every Family-A v1 response by + * `createApiResponse` (see `app/api/v1/logs/meta.ts`). Mirrors the `UserLimits` + * interface in that file. Shared here so logs, audit-logs, and workflows + * contracts describe `limits` identically instead of each redefining it. + */ +export const v1UserLimitsSchema = z.object({ + workflowExecutionRateLimit: z.object({ + sync: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + async: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + }), + usage: z.object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + isExceeded: z.boolean(), + }), +}) + +export type V1UserLimits = z.output + +/** + * Family-A envelope helper: `{ data, limits }`. Use for the `createApiResponse` + * detail/action surfaces (logs/[id], workflows deploy/rollback/undeploy). List + * endpoints that also return a `nextCursor` should compose the object directly + * (`{ data, nextCursor: z.string().optional(), limits: v1UserLimitsSchema }`). + */ +export const withV1Limits = (dataSchema: T) => + z.object({ + data: dataSchema, + limits: v1UserLimitsSchema, + }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts new file mode 100644 index 00000000000..91051d1acab --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { v2ListLogsQuerySchema } from '@/lib/api/contracts/v2/logs' +import { + v2FolderPathInputSchema, + v2FolderPathSchema, + v2NonRootFolderPathInputSchema, + v2NonRootFolderPathSchema, + v2RelocateFolderBodySchema, +} from '@/lib/api/contracts/v2/shared' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +describe('v2 folder path contracts', () => { + it('accepts slashless paths and normalizes them to the canonical form', () => { + expect(v2FolderPathInputSchema.parse('Reports/Q1')).toBe('/Reports/Q1') + expect(v2FolderPathInputSchema.parse('/Reports/Q1')).toBe('/Reports/Q1') + expect(v2NonRootFolderPathInputSchema.parse('Reports')).toBe('/Reports') + }) + + it('does not treat an empty path as the workspace root', () => { + expect(v2FolderPathInputSchema.safeParse('').success).toBe(false) + expect(v2NonRootFolderPathInputSchema.safeParse('/').success).toBe(false) + }) + + it('still rejects noncanonical path syntax after adding the leading slash', () => { + expect(v2FolderPathInputSchema.safeParse('Reports/').success).toBe(false) + expect(v2FolderPathInputSchema.safeParse('Reports//Q1').success).toBe(false) + expect(v2FolderPathInputSchema.safeParse('Reports/%71').success).toBe(false) + }) + + it('keeps response paths strict and fail-fast', () => { + expect(v2FolderPathSchema.safeParse('Reports').success).toBe(false) + expect(v2NonRootFolderPathSchema.safeParse('Reports').success).toBe(false) + }) + + it('compares normalized paths in cross-field validation', () => { + expect( + v2RelocateFolderBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + path: 'Reports', + destinationPath: '/Reports', + }).success + ).toBe(false) + }) + + it('normalizes every folder path in the logs filter', () => { + const query = v2ListLogsQuerySchema.parse({ + workspaceId: WORKSPACE_ID, + folderPaths: 'Reports/Q1,/Archive', + }) + + expect(query.folderPaths).toBe('/Reports/Q1,/Archive') + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts new file mode 100644 index 00000000000..9e9dca55d01 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, + v2CreateTableImportBodySchema, + v2TableUploadImportSourceSchema, +} from '@/lib/api/contracts/v2/tables' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +function uploadSource(size: number) { + return { + type: 'upload' as const, + name: 'data.csv', + contentType: 'text/csv', + size, + } +} + +function existingTableImport(overrides: Record = {}) { + return { + workspaceId: WORKSPACE_ID, + source: uploadSource(128), + target: { type: 'existing' as const, tableId: 'table-1', mode: 'append' as const }, + ...overrides, + } +} + +describe('v2 table import contracts', () => { + it('accepts the exact CSV byte limit and rejects one byte over it', () => { + expect( + v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES)).success + ).toBe(true) + expect( + v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES + 1)).success + ).toBe(false) + }) + + it('accepts native JSON mapping and createColumns values', () => { + const body = existingTableImport({ + mapping: { email: 'email_address', notes: null }, + createColumns: ['phone'], + }) + + expect(v2CreateTableImportBodySchema.parse(body)).toEqual(body) + }) + + it('rejects the legacy FormData JSON-string representation', () => { + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ mapping: JSON.stringify({ email: 'email_address' }) }) + ).success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ createColumns: JSON.stringify(['phone']) }) + ).success + ).toBe(false) + }) + + it('caps mapping entries and createColumns items at the table column limit', () => { + const mapping = Object.fromEntries( + Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, (_, index) => [ + `header_${index}`, + `column_${index}`, + ]) + ) + const createColumns = Array.from( + { length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, + (_, index) => `header_${index}` + ) + + expect(v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping })).success).toBe( + true + ) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ createColumns })).success + ).toBe(true) + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ mapping: { ...mapping, overflow: 'overflow' } }) + ).success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ createColumns: [...createColumns, 'overflow'] }) + ).success + ).toBe(false) + }) + + it('bounds CSV header and mapped column names', () => { + const exact = 'x'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) + const over = `${exact}x` + + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ mapping: { [exact]: exact }, createColumns: [exact] }) + ).success + ).toBe(true) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping: { [over]: exact } })) + .success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping: { header: over } })) + .success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ createColumns: [over] })) + .success + ).toBe(false) + }) + + it('caps aggregate mapping metadata before it is embedded in the signed upload token', () => { + const mapping = Object.fromEntries( + Array.from({ length: 30 }, (_, index) => [ + `header_${index}_${'h'.repeat(30)}`, + `column_${index}_${'c'.repeat(30)}`, + ]) + ) + const result = v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping })) + + expect(new TextEncoder().encode(JSON.stringify({ mapping })).byteLength).toBeGreaterThan( + V2_TABLE_IMPORT_OPTIONS_MAX_BYTES + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['mapping'], + message: expect.stringMatching(/signed request token/), + }) + ) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts new file mode 100644 index 00000000000..b301ab84ea1 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { v2CompleteUploadBodySchema, v2UploadTransferSchema } from '@/lib/api/contracts/v2/uploads' + +describe('v2 upload transfer contracts', () => { + it('accepts only an empty object for PUT completion', () => { + expect(v2CompleteUploadBodySchema.parse({})).toEqual({}) + expect(v2CompleteUploadBodySchema.safeParse({ method: 'put' }).success).toBe(false) + }) + + it('accepts a strict completed-parts body for multipart completion', () => { + expect( + v2CompleteUploadBodySchema.parse({ parts: [{ partNumber: 1, etag: 'etag-1' }] }) + ).toEqual({ parts: [{ partNumber: 1, etag: 'etag-1' }] }) + expect(v2CompleteUploadBodySchema.safeParse({ parts: [] }).success).toBe(false) + expect( + v2CompleteUploadBodySchema.safeParse({ + parts: [{ partNumber: 1 }], + ignored: true, + }).success + ).toBe(false) + }) + + it('discriminates a PUT transfer from multipart geometry', () => { + expect( + v2UploadTransferSchema.parse({ + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/octet-stream' }, + }) + ).toMatchObject({ method: 'put' }) + expect( + v2UploadTransferSchema.parse({ + method: 'multipart', + partSize: 8 * 1024 * 1024, + partCount: 7, + }) + ).toMatchObject({ method: 'multipart' }) + expect( + v2UploadTransferSchema.safeParse({ + method: 'put', + partSize: 8 * 1024 * 1024, + partCount: 1, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts new file mode 100644 index 00000000000..1084d9bbecb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1AuditLogParamsSchema, + v1ListAuditLogsQuerySchema, +} from '@/lib/api/contracts/v1/audit-logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The + * request schemas are reused verbatim from v1 (the query/param shape is + * unchanged); only the response envelope is upgraded to the canonical v2 + * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated + * usage endpoint, not inlined into every response. + */ + +/** + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts` and the v1 `v1AuditLogEntrySchema`; + * `ipAddress`/`userAgent` are intentionally excluded for privacy. `metadata` is + * genuinely arbitrary per-action JSON. + */ +export const v2AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +export type V2AuditLogEntry = z.output + +export const v2ListAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs', + query: v1ListAuditLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2AuditLogEntrySchema), + }, +}) + +export const v2GetAuditLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs/[id]', + params: v1AuditLogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AuditLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts new file mode 100644 index 00000000000..6867e587db6 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { usageLogPeriodSchema, usageLogSourceSchema } from '@/lib/api/contracts/user' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 billing contracts — the read-only, API-key-facing usage surface. + * + * Deliberately separate from the session-only `/api/users/me/usage-logs` + * endpoints that back the Billing settings UI: the internal surface can evolve + * with the UI, while this one is the versioned public contract for external + * monitors. Everything is credit-denominated (Sim's usage unit; 1,000 credits + * = $5) — raw dollar costs and rate-limit internals are never on this wire. + */ + +/** `Date`-constructor-parseable string; validates parseability, not a wire format. */ +const parseableDateSchema = z + .string() + .min(1) + .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) + +export const v2UsageSummaryQuerySchema = z.object({ + /** + * Restrict the breakdown to one workspace. A workspace-scoped API key is + * always pinned to its own workspace; passing a different id returns 403. + */ + workspaceId: z.string().optional(), +}) + +/** + * Current-billing-period usage summary. `bySourceCredits` is the source-aware + * breakdown (workflow, copilot, knowledge-base, …) of the account's ledger for + * the period, so a monitor can watch one source's consumption directly instead + * of estimating it by subtraction. + */ +export const v2UsageSummaryDataSchema = z.object({ + period: z.object({ start: z.string(), end: z.string() }), + totalCredits: z.number(), + bySourceCredits: z.record(z.string(), z.number()), + limitCredits: z.number(), + plan: z.string(), +}) +export type V2UsageSummaryData = z.output + +export const v2GetUsageSummaryContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/billing/usage', + query: v2UsageSummaryQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UsageSummaryDataSchema), + }, +}) + +export const v2UsageLogsQuerySchema = z + .object({ + source: usageLogSourceSchema.optional(), + /** See {@link v2UsageSummaryQuerySchema}'s `workspaceId` — same pinning rules. */ + workspaceId: z.string().optional(), + period: usageLogPeriodSchema.optional().default('30d'), + /** Required when `period` is `'custom'`. */ + startDate: parseableDateSchema.optional(), + /** Defaults to now when omitted for `'custom'`. */ + endDate: parseableDateSchema.optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), + }) + .refine((query) => query.period !== 'custom' || query.startDate !== undefined, { + error: 'startDate is required when period is "custom"', + path: ['startDate'], + }) + +/** + * One credit-consuming usage event. `creditCost` is apportioned across the + * page so row credits sum exactly to the page's rounded total; it can + * legitimately be 0 for a sub-credit event once a sibling row absorbs the + * shared rounding remainder. + */ +export const v2UsageLogEntrySchema = z.object({ + id: z.string(), + createdAt: z.string(), + source: usageLogSourceSchema, + /** Populated only when `source` is `'workflow'`. */ + workflowName: z.string().nullable(), + creditCost: z.number(), +}) +export type V2UsageLogEntry = z.output + +export const v2ListUsageLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/billing/usage/logs', + query: v2UsageLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2UsageLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts new file mode 100644 index 00000000000..aea3f744be0 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -0,0 +1,242 @@ +import { z } from 'zod' +import { + normalizeCredentialEnvKey, + workspaceCredentialRoleSchema, + workspaceCredentialTypeSchema, +} from '@/lib/api/contracts/credentials' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' + +/** + * v2 credential contracts. + * + * Secret material — service-account JSON, API tokens, signing secrets, bot + * tokens, client secrets — is accepted on write and **never** returned on read, + * the same treatment MCP request headers get. A read exposes only whether a + * secret is stored (`hasServiceAccountKey`). + * + * `oauth` credentials cannot be created here: they are minted by the interactive + * OAuth connect flow and are bound to an `account` row the caller authorized in + * a browser. They are listed, read, updated, and deleted like any other type. + * + * Credential sharing (`/api/credentials/[id]/members`) is not part of this + * surface. + */ + +const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ + +/** The types a public caller can create. `oauth` requires the browser connect flow. */ +export const v2CreatableCredentialTypeSchema = z.enum( + ['env_workspace', 'env_personal', 'service_account'], + { error: 'type must be one of env_workspace, env_personal, service_account' } +) +export type V2CreatableCredentialType = z.output + +/** + * Public credential projection. `workspaceId` (supplied by the caller), + * `createdBy`, and every encrypted column are omitted. + */ +export const v2CredentialSchema = z.object({ + id: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + description: z.string().nullable(), + /** The integration this credential authenticates against, when it has one. */ + providerId: z.string().nullable(), + /** The linked OAuth account, for `oauth` credentials. */ + accountId: z.string().nullable(), + /** The environment-variable name, for `env_workspace` / `env_personal` credentials. */ + envKey: z.string().nullable(), + /** Whether a service-account secret is stored. The secret itself is never returned. */ + hasServiceAccountKey: z.boolean(), + /** The caller's role on this credential. */ + role: workspaceCredentialRoleSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Credential = z.output + +/** `{ credential }` payload for single-credential reads and mutations. */ +export const v2CredentialDataSchema = z.object({ credential: v2CredentialSchema }) +export type V2CredentialData = z.output + +export const v2CredentialDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CredentialDeleteData = z.output + +export const v2CredentialParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CredentialParams = z.output + +export const v2CredentialWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CredentialWorkspaceQuery = z.output + +/** A credential's natural name field is `displayName`, so that is what `search` matches. */ +export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const + +export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] + +export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ + type: workspaceCredentialTypeSchema.optional(), + providerId: z.string().min(1, 'providerId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2CredentialSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) +export type V2ListCredentialsQuery = z.output + +/** Write-only secret fields, shared by create and the reconnect-style update. */ +const credentialSecretFields = { + /** Write-only. Google-style service-account JSON key. */ + serviceAccountJson: z.string().min(1, 'serviceAccountJson cannot be empty').optional(), + /** Write-only. Slack custom-bot signing secret. */ + signingSecret: z.string().trim().min(1, 'signingSecret cannot be empty').optional(), + /** Write-only. Slack custom-bot token. */ + botToken: z.string().trim().min(1, 'botToken cannot be empty').optional(), + /** Write-only. Atlassian API token. */ + apiToken: z.string().trim().min(1, 'apiToken cannot be empty').optional(), + domain: z.string().trim().min(1, 'domain cannot be empty').optional(), + /** Write-only. Client-credentials service-account id/secret pair. */ + clientId: z.string().trim().min(1, 'clientId cannot be empty').max(512).optional(), + clientSecret: z.string().trim().min(1, 'clientSecret cannot be empty').max(1024).optional(), + orgId: z.string().trim().min(1, 'orgId cannot be empty').max(255).optional(), + dataCenter: z.string().trim().min(1, 'dataCenter cannot be empty').max(32).optional(), +} as const + +export const v2CreateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + type: v2CreatableCredentialTypeSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).optional(), + providerId: z.string().trim().min(1, 'providerId cannot be empty').optional(), + /** Required for `env_workspace` / `env_personal`. Accepts `NAME` or `{{NAME}}`. */ + envKey: z.string().trim().min(1, 'envKey cannot be empty').optional(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + if (data.type === 'service_account') { + for (const field of getServiceAccountRequiredFields(data.providerId)) { + if (!data[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${data.providerId ?? 'service account'} credentials`, + }) + } + } + return + } + + const normalizedEnvKey = data.envKey ? normalizeCredentialEnvKey(data.envKey) : '' + if (!normalizedEnvKey) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey is required for env credentials', + }) + return + } + if (!ENV_VAR_NAME_REGEX.test(normalizedEnvKey)) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey must contain only letters, numbers, and underscores', + }) + } + }) +export type V2CreateCredentialBody = z.input + +/** + * Update body. Renaming and re-describing apply to any type; the secret fields + * rotate a stored secret in place (the provider re-verifies it). + */ +export const v2UpdateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).nullish(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + const { workspaceId: _workspaceId, ...changes } = data + if (Object.values(changes).every((value) => value === undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['displayName'], + message: 'At least one field to change is required', + }) + } + }) +export type V2UpdateCredentialBody = z.input + +/** + * Credential list. A workspace's credential set is small and bounded, so the + * full visible set is returned as a single page (`nextCursor` is always `null`); + * the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCredentialsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials', + query: v2ListCredentialsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialSchema), + }, +}) + +export const v2CreateCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + body: v2CreateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2GetCredentialContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2UpdateCredentialContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + body: v2UpdateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts new file mode 100644 index 00000000000..c2e6221e776 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -0,0 +1,165 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' + +/** + * v2 custom tool contracts. + * + * The internal `/api/tools/custom` surface is a bulk upsert with no per-id + * update, and it tolerates legacy *personal* tools (`workspaceId: null`, owned + * by one user) alongside workspace ones. v2 splits create from update and is + * workspace-scoped in every direction — a workspace key never reaches another + * user's personal tool. + * + * The JSON-Schema `schema` field is reused verbatim from the internal contract: + * it is an OpenAI-style function declaration whose `parameters.properties` are + * caller-defined, so the shape is deliberately open below the function level. + */ + +const customToolTitleSchema = z + .string({ error: 'title is required' }) + .min(1, 'title is required') + .max(200, 'title must be at most 200 characters') + +const customToolCodeSchema = z + .string({ error: 'code is required' }) + .max(100_000, 'code must be at most 100000 characters') + +export const v2CustomToolSchema = z.object({ + id: z.string(), + title: z.string(), + /** OpenAI-style function declaration describing the tool's callable surface. */ + schema: customToolSchemaSchema, + /** The tool's implementation body, executed in Sim's sandboxed function runtime. */ + code: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2CustomTool = z.output + +/** `{ customTool }` payload for single-tool reads and mutations. */ +export const v2CustomToolDataSchema = z.object({ customTool: v2CustomToolSchema }) +export type V2CustomToolData = z.output + +export const v2CustomToolDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CustomToolDeleteData = z.output + +export const v2CustomToolParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CustomToolParams = z.output + +export const v2CustomToolWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CustomToolWorkspaceQuery = z.output + +/** A custom tool's natural name field is `title`, so that is what `search` matches. */ +export const v2CustomToolSortFields = ['title', 'createdAt', 'updatedAt'] as const + +export type V2CustomToolSortBy = (typeof v2CustomToolSortFields)[number] + +export const v2ListCustomToolsQuerySchema = v2CustomToolWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2CustomToolSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListCustomToolsQuery = z.output + +export const v2CreateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema, + schema: customToolSchemaSchema, + code: customToolCodeSchema, + }) + .strict() +export type V2CreateCustomToolBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema.optional(), + schema: customToolSchemaSchema.optional(), + code: customToolCodeSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.title === undefined && body.schema === undefined && body.code === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['title'], + message: 'At least one of title, schema, or code is required', + }) + } + }) +export type V2UpdateCustomToolBody = z.input + +/** + * Custom tool list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCustomToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools', + query: v2ListCustomToolsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CustomToolSchema), + }, +}) + +export const v2CreateCustomToolContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/custom-tools', + body: v2CreateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2GetCustomToolContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2UpdateCustomToolContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + body: v2UpdateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2DeleteCustomToolContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..429dbf00913 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,497 @@ +import { z } from 'zod' +import { + isCanonicalBase64, + workspaceFileIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateFolderBodySchema, + v2CursorListResponse, + v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, + v2UploadTransferSchema, +} from '@/lib/api/contracts/v2/uploads' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' + +/** + * 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. List and item routes carry the workspace + * as a query parameter; upload-session creation carries it in the JSON body. + * + * Folder placement is represented only by canonical paths. Database folder ids + * remain an internal storage detail. + * + * Uploads use a signed stateless control token. The storage provider owns the + * multipart part state; completion atomically registers the workspace file. + */ + +/** 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(), + /** Canonical containing-folder path; `/` means the workspace root. */ + folderPath: v2FolderPathSchema, + uploadedBy: z.string(), + /** ISO-8601 timestamp. */ + uploadedAt: z.string(), + /** ISO-8601 timestamp; advances on content and metadata writes alike. */ + updatedAt: z.string(), +}) + +export type V2File = z.output + +export const v2FileUploadParamsSchema = z.object({ uploadId: z.string().min(1) }) +export type V2FileUploadParams = z.output + +export const v2CreateFileUploadBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), + folderPath: v2FolderPathInputSchema.optional(), + }) + .strict() +export type V2CreateFileUploadBody = z.input + +export const v2FileUploadWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2FileUploadWorkspaceQuery = z.output + +export const v2FileUploadSchema = z.object({ + id: z.string(), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().nonnegative(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + file: v2FileSchema.nullable(), +}) +export type V2FileUpload = z.output + +export const v2CreateFileUploadDataSchema = z + .object({ + session: v2FileUploadSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type V2CreateFileUploadData = z.output + +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 + +/** + * A file-folder name becomes a path segment, so path separators and dot + * segments are rejected rather than normalized. Mirrors + * `normalizeWorkspaceFileItemName`, which enforces the same rule in the manager. + */ +const v2FileItemNameSchema = z + .string() + .trim() + .min(1, 'name is required') + .max(255, 'name is too long') + .refine( + (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), + 'name cannot contain path separators or dot segments' + ) + +export const v2CreateFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + contentType: z + .string() + .trim() + .min(1, 'contentType cannot be empty') + .max(255, 'contentType is too long') + .optional(), + folderPath: v2FolderPathInputSchema.optional(), + content: z.string().max(70_000_000, 'content is too large').default(''), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'content must be valid base64', + }) + } + }) + .strict() + +export type V2CreateFileBody = z.input + +/** Sortable file fields. `name` is the uploaded file name, not the storage key. */ +export const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const + +export type V2FileSortBy = (typeof v2FileSortFields)[number] + +/** + * List query: workspace scope, the v2 search/sort convention, an optional + * folder filter, and opaque keyset cursor pagination. `limit` clamps to + * `[1, 1000]` (default 100) to bound the response. + * + * The keyset is `(, id)`, so the cursor is stamped with the sort it was + * minted under and rejected if the request's sort has since changed. Filtering, + * ordering, and the page slice all happen in the query. + */ +export const v2ListFilesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + /** Restrict to one file folder. Omit to list the whole workspace. */ + folderPath: v2FolderPathInputSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), + }) + .strict() + +export type V2ListFilesQuery = z.output + +/** Download/delete both target a single file within a workspace-scoped query. */ +export const v2FileWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2FileWorkspaceQuery = z.output + +export const v2RenameFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + }) + .strict() + +export type V2RenameFileBody = z.input + +const fileSelectionSchema = { + fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).min(1).max(1000), +} + +export const v2MoveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileSelectionSchema, + /** Omission moves the files to the workspace root. */ + targetFolderPath: v2FolderPathInputSchema.optional(), + }) + .strict() + +export type V2MoveFileItemsBody = z.input + +export const v2MoveFileItemsResultSchema = z.object({ + movedItems: z.object({ files: z.number().int() }), +}) + +export type V2MoveFileItemsResult = z.output + +export const v2BulkDeleteFilesBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileSelectionSchema, + }) + .strict() + +export type V2BulkDeleteFilesBody = z.input + +export const v2BulkDeleteFilesResultSchema = z.object({ + deletedItems: z.object({ files: z.number().int() }), +}) + +export type V2BulkDeleteFilesResult = z.output + +export const v2FileFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteFileFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), files: z.number().int() }), +}) + +export const v2ListFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileFolderDataSchema) }, +}) + +export const v2RelocateFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileFolderDataSchema) }, +}) + +export const v2DeleteFileFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteFileFolderDataSchema) }, +}) + +/** + * Public share state. Reuses the internal {@link shareRecordSchema}, which is + * already public-safe — `hasPassword` is a boolean and neither the ciphertext + * nor the storage key is carried — with `url` tightened to a real URL. + */ +export const v2FileShareSchema = shareRecordSchema.extend({ + url: z.string().url(), +}) + +export type V2FileShare = z.output + +export const v2GetFileShareResultSchema = z.object({ + share: v2FileShareSchema.nullable(), +}) + +export type V2GetFileShareResult = z.output + +export const v2UpsertFileShareResultSchema = z.object({ + share: v2FileShareSchema, +}) + +export type V2UpsertFileShareResult = z.output + +/** + * Share upsert body. The internal surface accepts a caller-supplied `token` so + * the UI can show a link before saving; v2 drops it. Over an API key it would + * let a caller mint predictable public URLs, and a token collision surfaces as + * an unhandled unique-index violation. v2 tokens are always server-generated. + */ +export const v2UpsertFileShareBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + isActive: z.boolean(), + authType: shareAuthTypeSchema.optional(), + password: z + .string() + .min(1, 'password cannot be empty') + .max(1024, 'password is too long') + .optional(), + allowedEmails: z + .array(z.string().min(1, 'allowedEmails entries cannot be empty').max(320)) + .max(200, 'Too many allowed emails') + .optional(), + }) + .strict() + +export type V2UpsertFileShareBody = z.input + +/** + * Content replace body. `content` is the whole new body of the file — this is a + * replace, not an append. Base64 is the escape hatch for non-UTF-8 bytes. + */ +export const v2UpdateFileContentBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + content: z.string().max(70_000_000, 'content is too large'), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'content must be valid base64', + }) + } + }) + .strict() + +export type V2UpdateFileContentBody = z.input + +export const v2ListFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files', + query: v2ListFilesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileSchema), + }, +}) + +export const v2CreateFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + body: v2CreateFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2CreateFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads', + body: v2CreateFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema) }, +}) + +export const v2AbortFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2CreateFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2DownloadFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', + }, +}) + +export const v2GetFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/metadata', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2RenameFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + body: v2RenameFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2DeleteFileContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileResultSchema), + }, +}) + +export const v2MoveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/move', + body: v2MoveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MoveFileItemsResultSchema), + }, +}) + +export const v2BulkDeleteFilesContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/bulk-delete', + body: v2BulkDeleteFilesBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkDeleteFilesResultSchema), + }, +}) + +export const v2GetFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2GetFileShareResultSchema), + }, +}) + +export const v2UpsertFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + body: v2UpsertFileShareBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertFileShareResultSchema), + }, +}) + +export const v2UpdateFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + params: v2FileParamsSchema, + body: v2UpdateFileContentBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..9f2f3ec1e94 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,485 @@ +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, + v1ListKnowledgeDocumentsQuerySchema, +} from '@/lib/api/contracts/v1/knowledge' +import { + v2CreateFolderBodySchema, + v2CursorListResponse, + v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, + v2UploadTransferSchema, +} from '@/lib/api/contracts/v2/uploads' +import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' + +/** + * 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, + }) + .extend({ folderPath: v2FolderPathSchema }) +export type V2KnowledgeBase = z.output + +/** `{ knowledgeBase }` payload for single-KB reads and mutations. */ +export const v2KnowledgeBaseDataSchema = z.object({ knowledgeBase: v2KnowledgeBaseSchema }) +export type V2KnowledgeBaseData = z.output + +/** Delete acknowledgement — the id of the resource that was deleted. */ +export const v2KnowledgeDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2KnowledgeDeleteData = z.output + +/** + * Document core fields shared by the list item and the detail payload, reused + * from the first-party {@link documentDataSchema}. + */ +const v2KnowledgeDocumentCoreSchema = documentDataSchema.pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, +}) + +/** + * Document list item / upload acknowledgement. `createdAt` is the public rename + * of the underlying `uploadedAt` column. + */ +export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema.extend({ + createdAt: nullableWireDateSchema, +}) +export type V2KnowledgeDocumentSummary = z.output + +/** + * Document detail — the summary plus processing state and connector provenance. + * Every field is always present (nullable), mirroring the v1 detail projection. + */ +export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema.extend({ + processingError: z.string().nullable(), + processingStartedAt: nullableWireDateSchema, + processingCompletedAt: nullableWireDateSchema, + connectorId: z.string().nullable(), + connectorType: z.string().nullable(), + sourceUrl: z.string().nullable(), +}) +export type V2KnowledgeDocument = z.output + +/** `{ document }` payload for the upload acknowledgement (summary shape). */ +export const v2KnowledgeDocumentSummaryDataSchema = z.object({ + document: v2KnowledgeDocumentSummarySchema, +}) +export type V2KnowledgeDocumentSummaryData = z.output + +/** `{ document }` payload for the document detail read. */ +export const v2KnowledgeDocumentDataSchema = z.object({ document: v2KnowledgeDocumentSchema }) +export type V2KnowledgeDocumentData = z.output + +/** + * A single vector/tag search hit. `metadata` is the document's display-named tag + * map; values are user-defined and of mixed type (string/number/boolean/date), + * so they are carried as `unknown` and serialized as-is. + */ +export const v2KnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), +}) +export type V2KnowledgeSearchResult = z.output + +/** Search response payload — mirrors the v1 `data` object. */ +export const v2KnowledgeSearchDataSchema = z.object({ + results: z.array(v2KnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + topK: z.number(), + totalResults: z.number(), +}) +export type V2KnowledgeSearchData = z.output + +/** Upload carries the workspace as a query param so auth runs before the multipart body is buffered. */ +export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2UploadKnowledgeDocumentQuery = z.output + +export const v2KnowledgeDocumentUploadParamsSchema = knowledgeBaseParamsSchema.extend({ + uploadId: z.string().min(1, 'uploadId is required'), +}) +export type V2KnowledgeDocumentUploadParams = z.output + +const knowledgeDocumentUploadTagSchema = z + .string() + .max(1000, 'Knowledge document tag values cannot exceed 1000 characters') + .optional() + +export const v2KnowledgeDocumentUploadMetadataSchema = z + .object({ + tag1: knowledgeDocumentUploadTagSchema, + tag2: knowledgeDocumentUploadTagSchema, + tag3: knowledgeDocumentUploadTagSchema, + tag4: knowledgeDocumentUploadTagSchema, + tag5: knowledgeDocumentUploadTagSchema, + tag6: knowledgeDocumentUploadTagSchema, + tag7: knowledgeDocumentUploadTagSchema, + processingOptions: z + .object({ + recipe: z.string().max(255, 'recipe cannot exceed 255 characters').optional(), + lang: z.string().max(35, 'lang cannot exceed 35 characters').optional(), + }) + .strict() + .optional(), + }) + .strict() +export type V2KnowledgeDocumentUploadMetadata = z.output< + typeof v2KnowledgeDocumentUploadMetadataSchema +> + +export const v2CreateKnowledgeDocumentUploadBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE), + ...v2KnowledgeDocumentUploadMetadataSchema.shape, + }) + .strict() +export type V2CreateKnowledgeDocumentUploadBody = z.input< + typeof v2CreateKnowledgeDocumentUploadBodySchema +> + +export const v2KnowledgeDocumentUploadSchema = z.object({ + id: z.string(), + knowledgeBaseId: z.string(), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().positive(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + document: v2KnowledgeDocumentSummarySchema.nullable(), +}) +export type V2KnowledgeDocumentUpload = z.output + +export const v2CreateKnowledgeDocumentUploadDataSchema = z + .object({ + session: v2KnowledgeDocumentUploadSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type V2CreateKnowledgeDocumentUploadData = z.output< + typeof v2CreateKnowledgeDocumentUploadDataSchema +> + +export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] + +/** + * KB list query: v1's workspace scope plus the v2 search/sort convention and a + * folder filter. v1's own list query stays untouched — it does not implement + * these, and advertising a param a route ignores is worse than not having it. + */ +export const v2ListKnowledgeBasesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathInputSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + }) + .strict() + +export type V2ListKnowledgeBasesQuery = z.output + +export const v2CreateKnowledgeBaseBodySchema = v1CreateKnowledgeBaseBodySchema + .extend({ folderPath: v2FolderPathInputSchema.optional() }) + .strict() + +export const v2UpdateKnowledgeBaseBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v1CreateKnowledgeBaseBodySchema.shape.name.optional(), + description: v1CreateKnowledgeBaseBodySchema.shape.description, + chunkingConfig: v1CreateKnowledgeBaseBodySchema.shape.chunkingConfig.optional(), + folderPath: v2FolderPathInputSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.description === undefined && + body.chunkingConfig === undefined && + body.folderPath === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, chunkingConfig, or folderPath is required', + }) + } + }) + +/** + * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded + * per-workspace list), so today the cursor list is a single full page + * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list + * surface uniform; real pagination can be added later behind the opaque cursor. + * Search, folder filter, and sort all run in that query, not over its result. + */ +export const v2ListKnowledgeBasesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge', + query: v2ListKnowledgeBasesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeBaseSchema), + }, +}) + +export const v2CreateKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge', + body: v2CreateKnowledgeBaseBodySchema, + 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: v2UpdateKnowledgeBaseBodySchema, + 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 v2KnowledgeFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteKnowledgeFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), knowledgeBases: z.number().int() }), +}) + +export const v2ListKnowledgeFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateKnowledgeFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, +}) + +export const v2RelocateKnowledgeFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, +}) + +export const v2DeleteKnowledgeFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteKnowledgeFolderDataSchema) }, +}) + +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 v2CreateKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads', + params: knowledgeBaseParamsSchema, + body: v2CreateKnowledgeDocumentUploadBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + }, +}) + +export const v2AbortKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +export const v2CreateKnowledgeDocumentUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +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..8f769542b4c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -0,0 +1,156 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1ExecutionParamsSchema, + v1ListLogsQuerySchema, + v1LogParamsSchema, +} from '@/lib/api/contracts/v1/logs' +import { + v2CursorListResponse, + v2DataResponse, + v2FolderPathInputSchema, + v2FolderPathSchema, +} 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(), + folderPath: v2FolderPathSchema.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 v2ListLogsQuerySchema = v1ListLogsQuerySchema + .omit({ folderIds: true }) + .extend({ + folderPaths: z + .string() + .optional() + .transform((value, ctx) => { + if (value === undefined) return undefined + const paths = value.split(',') + if (paths.length === 0 || paths.some((path) => path.length === 0)) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) + return z.NEVER + } + + const normalizedPaths: string[] = [] + for (const path of paths) { + const parsed = v2FolderPathInputSchema.safeParse(path) + if (!parsed.success) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) + return z.NEVER + } + normalizedPaths.push(parsed.data) + } + return normalizedPaths.join(',') + }), + }) + .strict() + +export const v2ListLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs', + query: v2ListLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2LogListItemSchema), + }, +}) + +export const v2GetLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/[id]', + params: v1LogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogDetailSchema), + }, +}) + +export const v2GetExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + params: v1ExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecutionSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts new file mode 100644 index 00000000000..96b40e38b13 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -0,0 +1,233 @@ +import { z } from 'zod' +import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +/** + * v2 MCP server contracts. + * + * The routes are thin wrappers over `lib/mcp/orchestration`, but the public + * contract deliberately departs from the internal `/api/mcp/servers` shape in + * four places, each closing a hole that is merely awkward in a browser session + * and unsafe over an API key: + * + * 1. `headers` is write-only. The internal list returns the header map verbatim, + * which is where callers put `Authorization: Bearer …`; reusing that shape + * here would turn a read-scoped key into a token-exfiltration primitive. The + * public read exposes `hasHeaders` and `headerNames` only. + * 2. `url` must be a real absolute `http(s)` URL — the internal body accepts any + * string (or none at all). + * 3. `url` may not carry a `{{ENV_VAR}}` template. `lib/mcp/domain-check` skips + * both the domain allowlist and the SSRF resolve for templated hostnames, + * deferring validation to call time; over an API key that is a stored SSRF + * path. + * 4. Bodies are strict. An unrecognized field is a caller mistake, not something + * to silently pass through to storage. + */ + +/** A `{{ENV_VAR}}` reference anywhere in a URL defers domain/SSRF validation to call time. */ +function hasEnvVarTemplate(value: string): boolean { + return createEnvVarPattern().test(value) +} + +const v2McpServerUrlSchema = z + .string({ error: 'url is required' }) + .min(1, 'url is required') + .max(2048, 'url must be at most 2048 characters') + .refine((value) => !hasEnvVarTemplate(value), { + error: 'url must not contain {{ENV_VAR}} references on the public API', + }) + .refine( + (value) => { + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } + }, + { error: 'url must be an absolute http or https URL' } + ) + +const v2McpServerHeadersSchema = z.record( + z.string().min(1, 'Header names cannot be empty'), + z.string() +) + +/** + * Public MCP server projection. + * + * The field schemas are picked from {@link mcpServerSchema} so the legacy-row + * tolerance (`.catch()` on the free-text `transport`/`authType`/ + * `connection_status` columns) is shared with the internal surface. The pick is + * re-wrapped in a plain object so the result strips unknown keys instead of + * passing them through — that strip is what keeps `headers` and + * `oauthClientSecret` out of the response when a whole row is handed to it. + */ +export const v2McpServerSchema = z.object({ + ...mcpServerSchema.pick({ + id: true, + name: true, + description: true, + transport: true, + authType: true, + url: true, + timeout: true, + retries: true, + enabled: true, + connectionStatus: true, + lastError: true, + toolCount: true, + lastToolsRefresh: true, + lastConnected: true, + createdAt: true, + updatedAt: true, + oauthClientId: true, + }).shape, + /** Whether any request headers are configured. Values are never returned. */ + hasHeaders: z.boolean(), + /** Names of the configured request headers. Values are never returned. */ + headerNames: z.array(z.string()), + hasOauthClientSecret: z.boolean(), +}) +export type V2McpServer = z.output + +/** `{ mcpServer }` payload for single-server reads and mutations. */ +export const v2McpServerDataSchema = z.object({ mcpServer: v2McpServerSchema }) +export type V2McpServerData = z.output + +/** Delete acknowledgement — the id of the server that was deleted. */ +export const v2McpServerDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2McpServerDeleteData = z.output + +export const v2McpServerParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2McpServerParams = z.output + +export const v2McpServerWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2McpServerWorkspaceQuery = z.output + +export const v2McpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2McpServerSortBy = (typeof v2McpServerSortFields)[number] + +export const v2ListMcpServersQuerySchema = v2McpServerWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2McpServerSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListMcpServersQuery = z.output + +export const v2CreateMcpServerBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z + .string({ error: 'name is required' }) + .min(1, 'name is required') + .max(255, 'name must be at most 255 characters'), + description: z.string().max(2000, 'description must be at most 2000 characters').optional(), + transport: mcpTransportSchema.optional(), + url: v2McpServerUrlSchema, + authType: mcpAuthTypeSchema.optional(), + /** Write-only. Reads expose `hasHeaders` and `headerNames` instead. */ + headers: v2McpServerHeadersSchema.optional(), + timeout: z + .number() + .int('timeout must be an integer number of milliseconds') + .min(1000, 'timeout must be at least 1000ms') + .max(300000, 'timeout must be at most 300000ms') + .optional(), + retries: z + .number() + .int('retries must be an integer') + .min(0, 'retries cannot be negative') + .max(10, 'retries must be at most 10') + .optional(), + enabled: z.boolean().optional(), + oauthClientId: z.string().max(512, 'oauthClientId is too long').nullable().optional(), + /** Write-only. Reads expose `hasOauthClientSecret` instead. */ + oauthClientSecret: z.string().max(2048, 'oauthClientSecret is too long').nullable().optional(), + }) + .strict() +export type V2CreateMcpServerBody = z.input + +/** + * Update body. Every configuration field is optional; `workspaceId` stays + * required so the request is tenant-scoped before the server id is resolved. + */ +export const v2UpdateMcpServerBodySchema = v2CreateMcpServerBodySchema + .partial() + .extend({ workspaceId: workspaceIdSchema }) + .strict() +export type V2UpdateMcpServerBody = z.input + +/** + * MCP server list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListMcpServersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers', + query: v2ListMcpServersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2McpServerSchema), + }, +}) + +export const v2CreateMcpServerContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/mcp-servers', + body: v2CreateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2GetMcpServerContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2UpdateMcpServerContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + body: v2UpdateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2DeleteMcpServerContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..c5e291c1c0e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,199 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' + +/** + * Shared building blocks for the v2 API contract surface. + * + * v2 standardizes on a single response family across every endpoint: + * - single resource: `{ data: T }` + * - list: `{ data: T[], nextCursor: string | null }` + * - error: `{ error: { code, message, details? } }` + * + * Every list uses the opaque-cursor envelope (Stripe/Slack-style): `limit` + + * `cursor` in, `{ data, nextCursor }` out. Cursors are opaque so the underlying + * scheme (keyset / offset / full-set) can change without a contract change. + * Total counts are not returned on lists — they're available on the parent + * resource where relevant (e.g. `rowCount` on a table, `docCount` on a KB). + * + * Rate-limit state is carried in `X-RateLimit-*` response headers (not the + * body). Usage limits are available from the dedicated usage endpoint rather + * than being inlined into every response. + * + * ## Search, filtering, and sorting + * + * One convention, applied by every v2 list. It is deliberately the narrow + * scalar-param form the app's own list endpoints already speak — not a third + * dialect alongside the Logs filter set and the Tables predicate grammar. + * A list that needs a real expression tree (Tables) keeps its own `POST /query`. + * + * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match + * against the resource's *single* natural name field, and nothing else: + * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ + * skills, `title` for custom tools, `displayName` for credentials. It never + * matches ids, descriptions, or content. `%` and `_` in the term are matched + * literally, not as wildcards. Empty is rejected rather than silently + * ignored — omit the param instead. + * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a + * per-resource enum, never a free string, because the value selects a column + * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so + * an omitted sort is a defined order rather than whatever the planner + * returns. `position` names a resource's stored manual arrangement (the + * `sortOrder` *column* on workflows and folders) — it is spelled differently + * from the `sortOrder` *param* on purpose. + * - **Filters** — resource-specific and enumerated, reusing the names already + * on the surface (`scope`, `folderPath`, `deployedOnly`, `type`, `providerId`, + * `resourceType`). No generic filter expression. + * + * Every one of these is pushed into SQL. No v2 list fetches a full result set + * to filter or sort it in memory. + * + * ## Sort and the opaque cursor + * + * On the lists that paginate ({@link v2CursorListResponse} with a non-null + * `nextCursor` — files and workflows), the cursor is a keyset over the *active* + * sort, so its keys change when the sort does. The sort is therefore encoded + * into the cursor and re-checked on the way back in: replaying a cursor under a + * different `sortBy`/`sortOrder` is a 400, not a silently duplicated or skipped + * page. Change the sort by restarting pagination without a cursor. + */ + +/** Canonical v2 error envelope. */ +export const v2ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }), +}) + +/** `{ data: T }` */ +export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) + +/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ +export const v2CursorListResponse = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + nextCursor: z.string().nullable(), + }) + +/** + * The v2 `search` term: a case-insensitive substring match on the resource's + * natural name field. Bounded at 200 characters — a longer term cannot match + * any of the name columns it is aimed at, and every one of these matches is an + * unindexed scan. + */ +export const v2SearchSchema = z + .string() + .trim() + .min(1, 'search cannot be empty') + .max(200, 'search is too long') + .optional() + +export const v2SortOrderSchema = z.enum(['asc', 'desc']) + +export type V2SortOrder = z.output + +function canonicalFolderPathSchema(parser: (path: string) => string[]) { + return z.string().superRefine((path, ctx) => { + try { + parser(path) + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: + error instanceof FolderPathError ? error.message : 'Path must be a canonical folder path', + }) + } + }) +} + +/** Canonical slash-prefixed folder path. `/` is the workspace root. */ +export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath) +export type V2FolderPath = z.output + +/** Canonical path that identifies a real folder rather than the virtual root. */ +export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRootFolderPath) + +function normalizeFolderPathInput(path: string): string { + return path.length === 0 || path.startsWith('/') ? path : `/${path}` +} + +/** Input path that accepts an omitted leading slash and emits the canonical form. */ +export const v2FolderPathInputSchema = z + .string() + .transform(normalizeFolderPathInput) + .pipe(v2FolderPathSchema) + +/** Non-root input path that accepts an omitted leading slash and emits the canonical form. */ +export const v2NonRootFolderPathInputSchema = z + .string() + .transform(normalizeFolderPathInput) + .pipe(v2NonRootFolderPathSchema) + +export const v2FolderSchema = z.object({ + name: z.string(), + path: v2NonRootFolderPathSchema, + parentPath: v2FolderPathSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Folder = z.output + +export const v2FolderSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export const v2ListFoldersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + parentPath: v2FolderPathInputSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2FolderSortFields, { sortBy: 'name', sortOrder: 'asc' }), + }) + .strict() + +export const v2CreateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathInputSchema, + }) + .strict() + +export const v2RelocateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathInputSchema, + destinationPath: v2NonRootFolderPathInputSchema, + }) + .strict() + .superRefine((body, ctx) => { + if (body.path === body.destinationPath) { + ctx.addIssue({ + code: 'custom', + path: ['destinationPath'], + message: 'destinationPath must differ from path', + }) + } + }) + +export const v2DeleteFolderQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathInputSchema, + recursive: z.stringbool(), + }) + .strict() + +/** + * The `sortBy` + `sortOrder` pair for one resource. `fields` is the closed set + * of sortable fields — the value reaches the query as a column, so it can never + * be a free string — and both params always resolve to the given defaults. + */ +export function v2SortFields( + fields: F, + defaults: { sortBy: F[number]; sortOrder: V2SortOrder } +) { + return { + sortBy: z.enum(fields).default(defaults.sortBy), + sortOrder: v2SortOrderSchema.default(defaults.sortOrder), + } +} diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts new file mode 100644 index 00000000000..003151aef9f --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -0,0 +1,172 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' + +/** + * v2 skills contracts. + * + * Two departures from the internal `/api/skills` shape: + * + * 1. **Single-resource writes.** The internal `POST` takes an array, conflates + * create and update, and answers with the whole workspace skill list. v2 + * splits it into `POST /v2/skills` (201) and `PATCH /v2/skills/[id]`, each + * answering with the one skill that changed. + * 2. **`content` is detail-only.** A skill body is up to 50 000 characters, so + * the list returns summaries and the full body is fetched per skill from + * `GET /v2/skills/[id]`. + * + * Field validation lives in `lib/skills/orchestration`, so these schemas and the + * lib enforce the same limits — the schemas reuse the shared field primitives + * rather than restating them. + */ + +/** List item — everything but the skill body. */ +export const v2SkillSummarySchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + /** True for built-in template skills, which ship with Sim and cannot be written to. */ + readOnly: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2SkillSummary = z.output + +/** Detail — the summary plus the skill body. */ +export const v2SkillSchema = v2SkillSummarySchema.extend({ + content: z.string(), +}) +export type V2Skill = z.output + +/** `{ skill }` payload for single-skill reads and mutations. */ +export const v2SkillDataSchema = z.object({ skill: v2SkillSchema }) +export type V2SkillData = z.output + +export const v2SkillDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2SkillDeleteData = z.output + +export const v2SkillParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2SkillParams = z.output + +export const v2SkillWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2SkillWorkspaceQuery = z.output + +export const v2SkillSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2SkillSortBy = (typeof v2SkillSortFields)[number] + +export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2SkillSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListSkillsQuery = z.output + +export const v2CreateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema, + description: skillDescriptionSchema, + content: skillContentSchema, + }) + .strict() +export type V2CreateSkillBody = z.input + +/** + * Update body. Omitted fields keep their stored values, so a partial edit can + * never clobber a concurrent change to a field the caller did not send. + */ +export const v2UpdateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema.optional(), + description: skillDescriptionSchema.optional(), + content: skillContentSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.content === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or content is required', + }) + } + }) +export type V2UpdateSkillBody = z.input + +/** + * Skill list. The per-workspace set is small and bounded, so the full set is + * returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListSkillsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills', + query: v2ListSkillsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SkillSummarySchema), + }, +}) + +export const v2CreateSkillContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/skills', + body: v2CreateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2GetSkillContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2UpdateSkillContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + body: v2UpdateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2DeleteSkillContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts new file mode 100644 index 00000000000..c882f68c938 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -0,0 +1,1261 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + addWorkflowGroupBodySchema, + cancelTableRunsBodyBaseSchema, + createTableColumnBodySchema, + createTableViewBodySchema, + deleteTableColumnBodySchema, + deleteWorkflowGroupBodySchema, + exportTableAsyncBodySchema, + predicateSchema, + refineCancelTableRunsScope, + runColumnBodyBaseSchema, + runColumnExcludeMutexRefine, + runColumnScopeMutexRefine, + sortSpecSchema, + tableColumnSchema, + tableIdParamsSchema, + tableLocksSchema, + tableNameSchema, + tableRowParamsSchema, + tableRowsQueryBaseSchema, + tableViewConfigSchema, + tableViewParamsSchema, + updateRowsByFilterBodySchema, + updateTableColumnBodySchema, + updateTableRowBodySchema, + updateTableViewBodySchema, + updateWorkflowGroupBodySchema, + upsertTableRowBodySchema, + workflowGroupOutputColumnSchema, +} from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' +import { + v1CreateTableBodySchema, + v1CreateTableRowsBodySchema, + v1ListTablesQuerySchema, +} from '@/lib/api/contracts/v1/tables' +import { + v2CreateFolderBodySchema, + v2CursorListResponse, + v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, + v2UploadTransferSchema, +} from '@/lib/api/contracts/v2/uploads' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' + +/** + * v2 tables contracts. + * + * Request shapes (params/query/body) are reused from the v1 contract and the + * first-party `/api/table` contract where the surface is unchanged. Two things + * are upgraded relative to v1: + * + * - The response envelope is the canonical v2 family (`{ data }` for + * single/mutation, `{ data, nextCursor }` for lists). + * - Filters speak ONLY the typed predicate tree (`{ all | any: [...] }` — the + * same grammar the table_v2 block consumes). The legacy MongoDB-style + * `$`-operator object stays a v1-only dialect. Rich filtered reads live on + * the dedicated `POST /query` endpoint; the rows GET is a plain cursor page. + * + * The `data` item schemas are concrete and describe exactly what the route's + * `toApiTable`/`toApiRow` serializers emit. The first-party + * `tableDefinitionSchema`/`tableRowSchema` are NOT reused here because they are + * opaque (`z.custom`) and their inferred types include fields the public wire + * never carries (`executions`, `workspaceId`, `Date` timestamps, …). Column + * shape is reused from the concrete first-party `tableColumnSchema`. + */ + +/** Default page size when a row query/list `limit` is omitted. */ +export const V2_DEFAULT_ROW_LIMIT = 100 +/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or an export resource. */ +export const V2_MAX_ROW_LIMIT = 1000 +/** Keeps upload-token metadata comfortably below common 8 KiB request-header limits after signing. */ +export const V2_TABLE_IMPORT_OPTIONS_MAX_BYTES = 2 * 1024 + +/** + * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). + * Concrete so the v2 contract describes exactly what the wire carries. + */ +/** + * The table's current background job, or `null` when idle. + * + * Import and delete jobs are also derived onto the table (one write job per table at a time). + * Durable imports and exports have their own resource endpoints for complete lifecycle state. + */ +export const v2TableJobStateSchema = z.object({ + id: z.string().nullable(), + type: z.enum(['import', 'delete', 'export', 'backfill', 'update']).nullable(), + status: z.enum(['running', 'ready', 'failed', 'canceled']), + rowsProcessed: z.number(), + /** Failure reason for a `failed` job; `null` otherwise. */ + error: z.string().nullable(), +}) +export type V2TableJobState = z.output + +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(), + /** Canonical containing-folder path; `/` means the workspace root. */ + folderPath: v2FolderPathSchema, + /** + * Governance flags, read-only on the public API. They are enforced on every + * write (a locked verb returns 423), but flipping them is a first-party admin + * action — see {@link v2UpdateTableBodySchema}. + */ + locks: tableLocksSchema, + /** In-flight background job, or `null` when the table is idle. */ + job: v2TableJobStateSchema.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiTable = z.output + +/** + * Public row shape emitted by `toApiRow`: `{ id, data, createdAt, updatedAt }`, + * no storage internals (`position`/`orderKey`/`executions`). `data` is keyed by + * column NAME and select cells carry their option NAME; cell values are + * user-defined, so the map is `Record`. Timestamps ISO. + */ +export const v2ApiRowSchema = z.object({ + id: z.string(), + data: z.record(z.string(), z.unknown()), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiRow = z.output + +/** A single table definition payload. */ +export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) +export type V2TableData = z.output + +export const v2DeleteTableDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableData = z.output + +/** The table's full column list after a column mutation. */ +export const v2TableColumnsDataSchema = z.object({ columns: z.array(tableColumnSchema) }) +export type V2TableColumnsData = z.output + +/** A single row payload. */ +export const v2TableRowDataSchema = z.object({ row: v2ApiRowSchema }) +export type V2TableRowData = z.output + +/** Batch-insert payload. */ +export const v2BatchInsertRowsDataSchema = z.object({ + rows: z.array(v2ApiRowSchema), + insertedCount: z.number(), +}) +export type V2BatchInsertRowsData = z.output + +/** + * Bulk update-by-filter payload. v2 always returns `updatedRowIds` (`[]` when + * nothing matched) — v1 dropped the field on the zero-match branch. + */ +export const v2UpdateRowsDataSchema = z.object({ + updatedCount: z.number(), + updatedRowIds: z.array(z.string()), +}) +export type V2UpdateRowsData = z.output + +/** + * Bulk delete payload — one consistent shape for both id-based and + * filter-based deletes. `requestedCount`/`missingRowIds` are populated for the + * id-based delete (which has a requested set) and omitted for the filter-based + * delete; v1 emitted two divergent shapes here. + */ +export const v2DeleteRowsDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), + requestedCount: z.number().optional(), + missingRowIds: z.array(z.string()).optional(), +}) +export type V2DeleteRowsData = z.output + +/** Single-row delete payload — mirrors the bulk shape's required fields. */ +export const v2DeleteRowDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), +}) +export type V2DeleteRowData = z.output + +/** Upsert payload — the row object matches every other v2 row endpoint. */ +export const v2UpsertRowDataSchema = z.object({ + row: v2ApiRowSchema, + operation: z.enum(['insert', 'update']), +}) +export type V2UpsertRowData = z.output + +export const v2TableSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2TableSortBy = (typeof v2TableSortFields)[number] + +/** + * Table list query: the workspace scope every table route shares, plus the v2 + * search/sort convention and a folder filter. Kept separate from + * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that + * schema and have no list params. + */ +export const v2ListTablesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathInputSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), + }) + .strict() + +export type V2ListTablesQuery = z.output + +export const v2CreateTableBodySchema = v1CreateTableBodySchema + .omit({ folderId: true }) + .extend({ folderPath: v2FolderPathInputSchema.optional() }) + .strict() + +/** + * Table list. `listTables` returns every table in the workspace (a small, + * bounded per-workspace set), so today the cursor list is a single full page + * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the + * whole v2 list surface uniform, and real pagination can be added later behind + * the opaque cursor without an interface change. Search, folder filter, and + * sort all run in that query, not over its result. + */ +export const v2ListTablesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables', + query: v2ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiTableSchema), + }, +}) + +export const v2CreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables', + body: v2CreateTableBodySchema, + 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), + }, +}) + +/** + * Table update. Every field is optional but at least one must be present: + * `name` renames and `folderPath` moves the table. Omission leaves placement + * untouched; `/` moves it to the workspace root. + * + * `locks` is deliberately **not** accepted here, which is why this body is + * declared rather than reusing the first-party `updateTableBodySchema`. The + * governance flags are read-only on the public surface: an API key that can + * write a table must not also be able to clear the lock that was put there to + * stop it. Flipping a lock stays a first-party admin action. The body is + * `.strict()`, so a caller sending `locks` gets a 400 naming the field instead + * of a silent no-op that reads as success. + */ +export const v2UpdateTableBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: tableNameSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.folderPath === undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Provide a new name or folder', + path: ['name'], + }) + } + }) + +export const v2UpdateTableContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + body: v2UpdateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) +export type V2UpdateTableBody = z.input + +export const v2TableFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteTableFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), tables: z.number().int() }), +}) + +export const v2ListTableFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateTableFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, +}) + +export const v2RelocateTableFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, +}) + +export const v2DeleteTableFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteTableFolderDataSchema) }, +}) + +export const v2DeleteTableContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableDataSchema), + }, +}) + +export const v2AddTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: createTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2UpdateTableColumnContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: updateTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2DeleteTableColumnContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: deleteTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +/** + * Row list query: a plain cursor page over the default row order. Filtering and + * sorting are NOT part of this surface — rich reads go through the dedicated + * `POST /query` endpoint's predicate grammar. The opaque cursor encodes the + * underlying offset today; it can move to a keyset implementation later without + * an interface change. Total row count is available as `rowCount` on the table. + */ +export const v2TableRowsQuerySchema = tableRowsQueryBaseSchema + .pick({ workspaceId: true, limit: true }) + .extend({ + cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), + }) +export type V2TableRowsQuery = z.output + +/** Cursor-paginated row list. */ +export const v2ListTableRowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + query: v2TableRowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiRowSchema), + }, +}) + +/** + * Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec, + * field refs keyed by column NAME. `limit`: omitted → + * {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result or a 400 + * `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap. + */ +export const v2QueryRowsBodySchema = z.object({ + workspaceId: workspaceIdSchema, + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional(), + limit: z + .number({ error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') + .max( + V2_MAX_ROW_LIMIT, + `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or create an export resource for large datasets` + ) + .optional(), + cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), +}) +export type V2QueryRowsBody = z.input + +/** + * Rich filtered/sorted row read with cursor pagination — the v2 read surface + * for anything beyond a plain page. POST because the predicate tree is a + * structured body, not a querystring dialect. + */ +export const v2QueryRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/query', + params: tableIdParamsSchema, + body: v2QueryRowsBodySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiRowSchema), + }, +}) + +/** + * Single contract for `POST /rows` — the body is the single|batch union so the + * route can dispatch in one `parseRequest`, and the response is the matching + * union (`{ data: { row } }` for a single insert, `{ data: { rows, + * insertedCount } }` for a batch). + */ +export const v2CreateTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v1CreateTableRowsBodySchema, + response: { + mode: 'json', + schema: z.union([ + v2DataResponse(v2TableRowDataSchema), + v2DataResponse(v2BatchInsertRowsDataSchema), + ]), + }, +}) + +/** Bulk update body — v2 accepts ONLY the predicate tree as the filter. */ +export const v2UpdateRowsByPredicateBodySchema = updateRowsByFilterBodySchema.extend({ + filter: predicateSchema, +}) +export type V2UpdateRowsByPredicateBody = z.input + +export const v2UpdateRowsByFilterContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v2UpdateRowsByPredicateBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpdateRowsDataSchema), + }, +}) + +/** Bulk delete body — either row ids or a predicate-tree filter, never both. */ +export const v2DeleteTableRowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + filter: predicateSchema.optional(), + limit: z + .number({ error: 'Limit must be a number' }) + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .max( + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + `Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + .optional(), + rowIds: z + .array(z.string().min(1)) + .min(1, 'At least one row ID is required') + .max( + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, + `Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + .optional(), + }) + .refine((data) => Boolean(data.filter) !== Boolean(data.rowIds), { + message: 'Provide either filter or rowIds, but not both', + }) +export type V2DeleteTableRowsBody = z.input + +export const v2DeleteTableRowsContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v2DeleteTableRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowsDataSchema), + }, +}) + +export const v2GetTableRowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2UpdateTableRowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + body: updateTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2DeleteTableRowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowDataSchema), + }, +}) + +export const v2UpsertTableRowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + params: tableIdParamsSchema, + body: upsertTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertRowDataSchema), + }, +}) + +/** + * Body for the endpoints whose only input is the workspace the table must + * belong to. Present so every v2 mutation carries the same scope check the rest + * of the surface applies through `resolveWorkspaceScope`. + */ +export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2WorkspaceScopedBody = z.input + +/** + * A saved view: a named preset of `{ filter, sort, column layout }` over a + * table. Presentation state only — a view narrows what a reader sees by + * default, it is never an access boundary, and every row it hides stays + * reachable by reading the table without it. Timestamps ISO-serialized. + */ +export const v2ApiViewSchema = z.object({ + id: z.string(), + tableId: z.string(), + name: z.string(), + config: tableViewConfigSchema, + isDefault: z.boolean(), + /** User who saved the view; `null` for views whose author is gone. */ + createdBy: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiView = z.output + +/** A single view payload. */ +export const v2TableViewDataSchema = z.object({ view: v2ApiViewSchema }) +export type V2TableViewData = z.output + +/** Delete confirmation — the id of the view that was removed. */ +export const v2DeleteTableViewDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableViewData = z.output + +/** + * Every saved view on a table, oldest first. A table carries a small bounded + * set of views, so this is a single full page (`nextCursor` is always `null`); + * the cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListTableViewsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiViewSchema), + }, +}) + +export const v2CreateTableViewContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + body: createTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2GetTableViewContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2UpdateTableViewContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + body: updateTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +/** Deleting the default view simply leaves the table unfiltered. */ +export const v2DeleteTableViewContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableViewDataSchema), + }, +}) + +/** + * One workflow/enrichment column group: a backing workflow (or registry + * enrichment) plus the output columns its runs populate. Read-only on v2 — + * groups are authored in the workflow builder, and the public surface exposes + * them so a caller can discover the `groupIds` the run endpoints take. + */ +export const v2WorkflowGroupSchema = z.object({ + id: z.string(), + /** Backing workflow id for `manual` groups; `''` for enrichment groups. */ + workflowId: z.string(), + /** Registry enrichment id for `enrichment` groups. */ + enrichmentId: z.string().optional(), + name: z.string().optional(), + type: z.enum(['manual', 'enrichment']).optional(), + dependencies: z.object({ columns: z.array(z.string()).optional() }).optional(), + outputs: z.array( + z.object({ + blockId: z.string(), + path: z.string(), + outputId: z.string().optional(), + columnName: z.string(), + }) + ), + inputMappings: z.array(z.object({ inputName: z.string(), columnName: z.string() })).optional(), + deploymentMode: z.enum(['live', 'deployed']).optional(), + /** When `false` the group never auto-fires; it runs only on an explicit request. */ + autoRun: z.boolean().optional(), +}) +export type V2WorkflowGroup = z.output + +/** + * The table's workflow/enrichment groups. Bounded per table, so a single full + * page (`nextCursor` is always `null`). + */ +export const v2ListWorkflowGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowGroupSchema), + }, +}) + +/** + * Output column of a group, as the public surface accepts it. The first-party + * shape carries `workflowGroupId` because the client mints the group id before + * posting; v2 server-generates it, so the field is stamped from the group being + * written rather than being a caller's to supply (and get wrong). + */ +const v2WorkflowGroupOutputColumnSchema = workflowGroupOutputColumnSchema.omit({ + workflowGroupId: true, +}) + +/** + * A group names its producer two mutually exclusive ways, and the underlying + * shape leaves both optional. Rejecting the mismatch here means the route never + * has to guess which one a half-specified group meant. + */ +function refineGroupSource( + group: { type?: 'manual' | 'enrichment'; workflowId?: string; enrichmentId?: string }, + ctx: z.RefinementCtx, + path: (string | number)[] +): void { + // `manual` is the workflow-backed default — it does not mean hand-entered. + const type = group.type ?? 'manual' + if (type === 'enrichment' && !group.enrichmentId) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'enrichmentId'], + message: 'enrichmentId is required when type is "enrichment"', + }) + } + if (type === 'manual' && !group.workflowId) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'workflowId'], + message: 'workflowId is required when type is "manual"', + }) + } +} + +/** + * Create a group and the columns its runs populate, in one call. + * + * Two deliberate departures from the first-party body: + * - `group.id` is optional and server-generated. The UI mints an id so it can + * render optimistically; a public caller has no such need and a client-chosen + * id is a collision waiting to happen. + * - `autoRun` defaults to **false**. On the first-party surface it defaults to + * true so a UI add fills cells immediately, but here it would make one POST + * fan out a metered run across every existing row. Callers opt in, or fire + * explicitly via `POST /columns/run`. + */ +export const v2AddWorkflowGroupBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + group: addWorkflowGroupBodySchema.shape.group.extend({ + id: z.string().min(1).optional(), + }), + outputColumns: z.array(v2WorkflowGroupOutputColumnSchema).min(1), + autoRun: z.boolean().optional().default(false), + }) + .strict() + .superRefine((body, ctx) => refineGroupSource(body.group, ctx, ['group'])) +export type V2AddWorkflowGroupBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowGroupBodySchema = updateWorkflowGroupBodySchema + .extend({ + newOutputColumns: z.array(v2WorkflowGroupOutputColumnSchema).optional(), + }) + .strict() +export type V2UpdateWorkflowGroupBody = z.input + +export const v2DeleteWorkflowGroupBodySchema = deleteWorkflowGroupBodySchema.strict() +export type V2DeleteWorkflowGroupBody = z.input + +/** + * Create and update both mutate the group *and* the table's columns, so both + * are returned — otherwise a caller has to re-read the table to learn which + * columns it just got. + */ +export const v2WorkflowGroupDataSchema = z.object({ + group: v2WorkflowGroupSchema, + columns: z.array(tableColumnSchema), +}) +export type V2WorkflowGroupData = z.output + +/** + * Delete acknowledgement. Removing a group removes the columns it fed, so the + * surviving column list is returned rather than left for the caller to guess. + */ +export const v2DeleteWorkflowGroupDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + columns: z.array(tableColumnSchema), +}) +export type V2DeleteWorkflowGroupData = z.output + +export const v2AddWorkflowGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2AddWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGroupDataSchema), + }, +}) + +export const v2UpdateWorkflowGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2UpdateWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGroupDataSchema), + }, +}) + +export const v2DeleteWorkflowGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2DeleteWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowGroupDataSchema), + }, +}) + +/** + * Run-column body. Identical to the first-party shape except `filter`, which v2 + * narrows to the typed predicate tree — the legacy `$`-operator dialect stays + * v1-only across the whole v2 surface. + */ +export const v2RunColumnBodySchema = runColumnBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) +export type V2RunColumnBody = z.input + +/** + * A started run. `dispatchId` identifies the `table_run_dispatches` row the + * dispatcher walks; it is `null` in deployments without a background runner, + * where cells execute inline and no dispatch row is created. + */ +export const v2RunColumnDataSchema = z.object({ dispatchId: z.string().nullable() }) +export type V2RunColumnData = z.output + +/** + * Runs one or more workflow/enrichment groups across the table or a row subset. + * Asynchronous: the response acknowledges the dispatch, and cell values land as + * the runs complete. Poll the rows endpoints for results. + */ +export const v2RunTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + params: tableIdParamsSchema, + body: v2RunColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +export const v2RowEnrichmentParamsSchema = tableRowParamsSchema.extend({ + groupId: z.string().min(1), +}) +export type V2RowEnrichmentParams = z.output + +/** + * The single-cell case of {@link v2RunTableColumnContract}: runs one group for + * one row. The scope lives entirely in the path, so the body carries only the + * workspace. + */ +export const v2RunRowEnrichmentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + params: v2RowEnrichmentParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +/** + * Lookup body: a case-insensitive substring search across every cell, narrowed + * by the same predicate/sort grammar as `POST /query`. POST because the + * predicate tree is a structured body, not a querystring dialect. + */ +export const v2FindRowsBodySchema = z.object({ + workspaceId: workspaceIdSchema, + q: z.string().min(1, 'q must be a non-empty search string'), + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional(), +}) +export type V2FindRowsBody = z.input + +/** + * One matching cell. `ordinal` is the row's 0-based index in the + * predicate-filtered, sorted view, so it lines up with the same page a + * `POST /query` with the same predicate and sort would return. `column` is the + * column NAME, matching how row `data` is keyed everywhere on the public wire. + */ +export const v2RowMatchSchema = z.object({ + ordinal: z.number(), + rowId: z.string(), + column: z.string(), +}) +export type V2RowMatch = z.output + +/** + * Match set. `truncated` is `true` when the search hit the server-side cap and + * more cells match than were returned — narrow the predicate rather than + * paging, since matches have no cursor. + */ +export const v2FindRowsDataSchema = z.object({ + matches: z.array(v2RowMatchSchema), + truncated: z.boolean(), +}) +export type V2FindRowsData = z.output + +export const v2FindTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + params: tableIdParamsSchema, + body: v2FindRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FindRowsDataSchema), + }, +}) + +export const v2TableImportParamsSchema = z.object({ importId: z.string().min(1) }) +export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1) }) +export const v2TableTransferWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) + +export const v2TableUploadImportSourceSchema = z + .object({ + type: z.literal('upload'), + name: z.string().trim().min(1, 'name is required').max(255), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE), + }) + .strict() + +export const v2TableWorkspaceFileImportSourceSchema = z + .object({ type: z.literal('workspace_file'), fileId: z.string().min(1) }) + .strict() + +export const v2TableImportSourceSchema = z.discriminatedUnion('type', [ + v2TableUploadImportSourceSchema, + v2TableWorkspaceFileImportSourceSchema, +]) +export type V2TableImportSource = z.input + +export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('new'), + name: tableNameSchema, + folderPath: v2FolderPathInputSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal('existing'), + tableId: z.string().min(1), + mode: z.enum(['append', 'replace']), + }) + .strict(), +]) +export type V2TableImportTarget = z.input + +const v2CsvHeaderSchema = z + .string() + .min(1, 'CSV header must not be empty') + .max( + TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, + `CSV header must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + ) + +const v2CsvColumnNameSchema = z + .string() + .min(1, 'Column name must not be empty') + .max( + TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, + `Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + ) + +export const v2CsvImportMappingSchema = z + .record(v2CsvHeaderSchema, v2CsvColumnNameSchema.nullable()) + .refine( + (mapping) => Object.keys(mapping).length <= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, + `mapping cannot contain more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} entries` + ) + +export const v2CsvImportCreateColumnsSchema = z + .array(v2CsvHeaderSchema) + .max( + TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, + `createColumns cannot contain more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} items` + ) + +export const v2CreateTableImportBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + source: v2TableImportSourceSchema, + target: v2TableImportTargetSchema, + mapping: v2CsvImportMappingSchema.optional(), + createColumns: v2CsvImportCreateColumnsSchema.optional(), + timezone: ianaTimezoneSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.target.type === 'new' && body.mapping !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['mapping'], + message: 'mapping is only supported for an existing table target', + }) + } + if (body.target.type === 'new' && body.createColumns !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['createColumns'], + message: 'createColumns is only supported for an existing table target', + }) + } + const serializedOptions = JSON.stringify({ + ...(body.mapping !== undefined ? { mapping: body.mapping } : {}), + ...(body.createColumns !== undefined ? { createColumns: body.createColumns } : {}), + }) + if ( + new TextEncoder().encode(serializedOptions).byteLength > V2_TABLE_IMPORT_OPTIONS_MAX_BYTES + ) { + ctx.addIssue({ + code: 'custom', + path: [body.mapping !== undefined ? 'mapping' : 'createColumns'], + message: `mapping and createColumns must serialize to at most ${V2_TABLE_IMPORT_OPTIONS_MAX_BYTES} bytes because upload metadata is carried in a signed request token`, + }) + } + }) +export type V2CreateTableImportBody = z.input + +export const v2TableImportStatusSchema = z.enum([ + 'uploading', + 'queued', + 'processing', + 'completed', + 'failed', + 'canceled', + 'expired', +]) +export type V2TableImportStatus = z.output + +export const v2TableImportSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + status: v2TableImportStatusSchema, + source: v2TableImportSourceSchema, + target: v2TableImportTargetSchema, + tableId: z.string().nullable(), + rowsProcessed: z.number().int().nonnegative(), + error: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) +export type V2TableImport = z.output + +const v2UploadBackedTableImportSchema = v2TableImportSchema.extend({ + source: v2TableUploadImportSourceSchema, +}) + +const v2WorkspaceFileTableImportSchema = v2TableImportSchema.extend({ + source: v2TableWorkspaceFileImportSourceSchema, +}) + +export const v2CreateTableImportDataSchema = z.union([ + z + .object({ + session: v2UploadBackedTableImportSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict(), + z + .object({ + session: v2WorkspaceFileTableImportSchema, + uploadToken: z.null(), + transfer: z.null(), + }) + .strict(), +]) +export type V2CreateTableImportData = z.output + +export const v2CreateTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, +}) + +export const v2GetTableImportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CancelTableImportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CreateTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2TableExportStatusSchema = z.enum([ + 'queued', + 'processing', + 'completed', + 'failed', + 'canceled', +]) +export type V2TableExportStatus = z.output + +export const v2TableExportSchema = z.object({ + id: z.string(), + tableId: z.string(), + workspaceId: z.string(), + format: z.enum(['csv', 'json']), + status: v2TableExportStatusSchema, + rowsProcessed: z.number().int().nonnegative(), + error: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) +export type V2TableExport = z.output + +export const v2CreateTableExportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/exports', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2GetTableExportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2CancelTableExportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2TableExportDownloadDataSchema = z.object({ + url: z.string().url(), + fileName: z.string(), + expiresAt: z.string().datetime(), +}) + +export const v2TableExportDownloadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/exports/[exportId]/download', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, +}) + +/** + * Cancel-runs body. Identical to the first-party shape except `filter`, which + * v2 narrows to the typed predicate tree. + */ +export const v2CancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } + }) +export type V2CancelTableRunsBody = z.input + +/** How many in-flight cell runs the cancel actually stopped. */ +export const v2CancelTableRunsDataSchema = z.object({ cancelled: z.number() }) +export type V2CancelTableRunsData = z.output + +/** + * Stops in-flight and pending workflow/enrichment cell runs — the counterpart + * to `POST /columns/run`. Import and export work is canceled by deleting its + * resource instead. + */ +export const v2CancelTableRunsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + params: tableIdParamsSchema, + body: v2CancelTableRunsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelTableRunsDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/tables/index.ts b/apps/sim/lib/api/contracts/v2/tables/index.ts deleted file mode 100644 index 34e3902f01d..00000000000 --- a/apps/sim/lib/api/contracts/v2/tables/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { - predicateSchema, - sortSpecSchema, - tableColumnSchema, - tableIdParamsSchema, -} from '@/lib/api/contracts/tables' -import { defineRouteContract } from '@/lib/api/contracts/types' - -/** - * Public v2 tables API — typed predicate grammar, cursor paging. - * - * Filters are the `{ all | any: [...] }` predicate tree (same shape the engine - * consumes); no string querystring dialect. Response bodies are fully typed. Row - * data is name-keyed and carries no storage internals (`position`/`orderKey`/ - * `executions`) — the public wire is `{ id, data, createdAt, updatedAt }`. - */ - -/** Default page size when `limit` is omitted (bounded, unlike the internal surface). */ -export const V2_DEFAULT_ROW_LIMIT = 100 -/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` or the async export. */ -export const V2_MAX_ROW_LIMIT = 1000 - -const successResponseSchema = (dataSchema: T) => - z.object({ - success: z.literal(true), - data: dataSchema, - }) - -const v2TableSummarySchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - schema: z.object({ columns: z.array(tableColumnSchema) }), - rowCount: z.number(), - maxRows: z.number(), - createdAt: z.string(), - updatedAt: z.string(), -}) - -/** Public row shape: name-keyed data, no storage internals. */ -export const v2TableRowSchema = z.object({ - id: z.string(), - data: z.record(z.string(), z.unknown()), - createdAt: z.string(), - updatedAt: z.string(), -}) - -export const v2ListTablesQuerySchema = z.object({ - workspaceId: workspaceIdSchema, -}) - -/** - * Rows query body. `predicate`/`sort` are the typed predicate tree / sort spec. - * `limit`: omitted → {@link V2_DEFAULT_ROW_LIMIT}; `0` → unbounded (whole result - * or a 400 `TABLE_QUERY_RESULT_TOO_LARGE`); `1..{@link V2_MAX_ROW_LIMIT}` → page cap. - */ -export const v2QueryRowsBodySchema = z.object({ - workspaceId: workspaceIdSchema, - predicate: predicateSchema.optional(), - sort: sortSpecSchema.optional(), - limit: z - .number({ error: 'Limit must be a number' }) - .int('Limit must be an integer') - .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') - .max( - V2_MAX_ROW_LIMIT, - `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or the async export for large datasets` - ) - .optional(), - cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), -}) - -export type V2ListTablesQuery = z.output -export type V2QueryRowsBody = z.input -export type V2TableRow = z.output - -export const v2ListTablesContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/tables', - query: v2ListTablesQuerySchema, - response: { - mode: 'json', - schema: successResponseSchema( - z.object({ - tables: z.array(v2TableSummarySchema), - totalCount: z.number(), - }) - ), - }, -}) - -export const v2QueryRowsContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/[tableId]/query', - params: tableIdParamsSchema, - body: v2QueryRowsBodySchema, - response: { - mode: 'json', - schema: successResponseSchema( - z.object({ - rows: z.array(v2TableRowSchema), - rowCount: z.number(), - totalCount: z.number().nullable(), - limit: z.number(), - /** Opaque, short-lived token. Non-null when more rows remain; stop on null. */ - nextCursor: z.string().nullable(), - }) - ), - }, -}) - -export type V2ListTablesResponse = z.output<(typeof v2ListTablesContract)['response']['schema']> -export type V2QueryRowsResponse = z.output<(typeof v2QueryRowsContract)['response']['schema']> diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts new file mode 100644 index 00000000000..0e91d768b83 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' + +export const v2UploadStatusSchema = z.enum([ + 'uploading', + 'finalizing', + 'completed', + 'failed', + 'aborted', + 'expired', +]) +export type V2UploadStatus = z.output + +export const v2UploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header is required'), +}) +export type V2UploadTokenHeaders = z.input + +export const v2OptionalUploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header cannot be empty').optional(), +}) + +export const v2CompletedPartSchema = z + .object({ + partNumber: z.number().int().min(1), + etag: z.string().min(1).optional(), + }) + .strict() +export type V2CompletedPart = z.input + +const v2CompleteMultipartUploadBodySchema = z + .object({ + parts: z.array(v2CompletedPartSchema).min(1).max(640), + }) + .strict() + +const v2CompletePutUploadBodySchema = z.object({}).strict() + +export const v2CompleteUploadBodySchema = z.union([ + v2CompleteMultipartUploadBodySchema, + v2CompletePutUploadBodySchema, +]) +export type V2CompleteUploadBody = z.input + +export const v2PutUploadTransferSchema = z + .object({ + method: z.literal('put'), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + }) + .strict() +export type V2PutUploadTransfer = z.output + +export const v2MultipartUploadTransferSchema = z + .object({ + method: z.literal('multipart'), + partSize: z.number().int().positive(), + partCount: z.number().int().positive().max(640), + }) + .strict() +export type V2MultipartUploadTransfer = z.output + +export const v2UploadTransferSchema = z.discriminatedUnion('method', [ + v2PutUploadTransferSchema, + v2MultipartUploadTransferSchema, +]) +export type V2UploadTransfer = z.output + +export const v2PartUrlsBodySchema = z + .object({ + partNumbers: z.array(z.number().int().min(1)).min(1).max(100), + }) + .strict() +export type V2PartUrlsBody = z.input + +export const v2UploadPartUrlSchema = z.object({ + partNumber: z.number().int().min(1), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + expiresAt: z.string().datetime(), +}) +export type V2UploadPartUrl = z.output + +export const v2PartUrlsDataSchema = z.object({ parts: z.array(v2UploadPartUrlSchema).max(100) }) +export type V2PartUrlsData = z.output 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..10e0faa7c45 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,551 @@ +import { z } from 'zod' +import { + deployedWorkflowStateSchema, + deploymentVersionParamsSchema, + deploymentVersionSchema, +} from '@/lib/api/contracts/deployments' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + V1_IMPORT_NAME_MAX_LENGTH, + v1DeployWorkflowDataSchema, + v1ImportWorkflowBodySchema, + v1RollbackWorkflowDataSchema, + v1WorkflowExportPayloadSchema, +} from '@/lib/api/contracts/v1/workflows' +import { + v2CreateFolderBodySchema, + v2CursorListResponse, + v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' +import { + cancelWorkflowExecutionReasonSchema, + workflowExecutionParamsSchema, + workflowExecutionPausedDetailSchema, + workflowExecutionStatusQuerySchema, + workflowIdParamsSchema, +} from '@/lib/api/contracts/workflows' + +/** + * v2 workflows contracts. Request shapes are reused from v1 (the `[id]` param + * is unchanged, and the list query extends v1's with the v2 search/sort + * convention); only the response envelope is upgraded to the canonical v2 + * shapes with concrete item/detail schemas. The deploy/rollback/undeploy data + * payloads reuse the already-concrete v1 schemas, re-wrapped in + * `v2DataResponse` (the v1 `limits` body field is dropped — v2 carries + * rate-limit state in headers and usage on a dedicated endpoint). + * + * The create/update bodies have no v1 counterpart and are v2-native: they carry + * only the fields a public caller owns (name, description, folder placement). + * `sortOrder`, `locked`, and `forkSyncExcluded` are workspace-UI concerns and + * are not part of the public surface. + */ + +/** + * Sortable workflow fields. `position` is the workspace's manual arrangement + * (the `sort_order` column the sidebar writes), kept as the default so a bare + * list still returns workflows in the order the workspace put them in. + */ +export const v2WorkflowSortFields = [ + 'position', + 'name', + 'createdAt', + 'updatedAt', + 'runCount', +] as const + +export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] + +/** + * List query: v1's workspace/folder/deployment filters plus the v2 search and + * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor + * carries the sort it was minted under and is rejected once that changes. + */ +export const v2ListWorkflowsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathInputSchema.optional(), + deployedOnly: booleanQueryFlagSchema.optional().default(false), + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), + search: v2SearchSchema, + ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), + }) + .strict() + +export type V2ListWorkflowsQuery = z.output + +export const v2WorkflowListItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + folderPath: v2FolderPathSchema, + workspaceId: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + runCount: z.number(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V2WorkflowListItem = z.output + +/** A single trigger input field extracted from the workflow's input-definition block. */ +const v2WorkflowInputFieldSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +export const v2WorkflowDetailSchema = v2WorkflowListItemSchema.extend({ + /** + * Workflow-scoped variables keyed by variable id. Each value is a structured + * variable object (`{ id, name, type, value, ... }`); only the inner `value` + * is user-defined/free-form. Kept as `unknown` to tolerate legacy/unstamped + * rows — tightening to a concrete object schema later is consumer-safe (the + * wire already carries the full object), so it stays additively evolvable. + */ + variables: z.record(z.string(), z.unknown()), + inputs: z.array(v2WorkflowInputFieldSchema), +}) + +export type V2WorkflowDetail = z.output + +/** + * Undeploy returns the deployment state without a version number. Derived from + * the exported v1 deploy data schema (its private base is not exported) so the + * shape stays in lockstep with v1. + */ +const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: true }) + +export const v2ListWorkflowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows', + query: v2ListWorkflowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2GetWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDetailSchema), + }, +}) + +/** + * Create body. `workspaceId` is required — personal (workspace-less) workflows + * are not creatable on any surface. Name collisions inside the target folder + * are a 409 rather than being silently deduplicated: a public caller that asked + * for a name should learn it was taken, not discover "My Agent (2)" later. + */ +export const v2CreateWorkflowBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + /** Omission creates the workflow at the workspace root. */ + folderPath: v2FolderPathInputSchema.optional(), + }) + .strict() +export type V2CreateWorkflowBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowBodySchema = z + .object({ + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + folderPath: v2FolderPathInputSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.description === undefined && + body.folderPath === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or folderPath is required', + }) + } + }) +export type V2UpdateWorkflowBody = z.input + +export const v2DeleteWorkflowDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2DeleteWorkflowData = z.output + +export const v2CreateWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows', + body: v2CreateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2UpdateWorkflowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + body: v2UpdateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2DeleteWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowDataSchema), + }, +}) + +export const v2WorkflowFolderSchema = v2FolderSchema.extend({ locked: z.boolean() }) +export type V2WorkflowFolder = z.output + +export const v2WorkflowFolderDataSchema = z.object({ folder: v2WorkflowFolderSchema }) + +export const v2DeleteWorkflowFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), workflows: z.number().int() }), +}) + +export const v2ListWorkflowFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowFolderSchema) }, +}) + +export const v2CreateWorkflowFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, +}) + +export const v2RelocateWorkflowFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, +}) + +export const v2DeleteWorkflowFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteWorkflowFolderDataSchema) }, +}) + +/** + * A deployment version as the public surface sees it: the internal row minus + * `createdBy`, which is a raw user id with no public resolution path — + * `deployedBy` already carries the human-readable name. + */ +export const v2WorkflowVersionSchema = deploymentVersionSchema.omit({ createdBy: true }) +export type V2WorkflowVersion = z.output + +/** + * Version listing is cursor-paginated: a workflow accrues one version per + * deploy and nothing prunes them, so the set is unbounded. The cursor is keyed + * on the version number, which is dense and strictly descending. + */ +export const v2ListWorkflowVersionsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) +export type V2ListWorkflowVersionsQuery = z.output + +/** + * A single version plus the workflow state it pins. `state` is the deployed + * graph snapshot — the same portable blob the internal deployment reader + * serves — and is the thing a caller diffs before rolling back to it. + */ +export const v2WorkflowVersionDetailSchema = z.object({ + id: z.string(), + version: z.number().int().positive(), + name: z.string().nullable(), + description: z.string().nullable(), + isActive: z.boolean(), + createdAt: z.string(), + state: deployedWorkflowStateSchema, +}) +export type V2WorkflowVersionDetail = z.output + +export const v2ListWorkflowVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + params: workflowIdParamsSchema, + query: v2ListWorkflowVersionsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowVersionSchema), + }, +}) + +export const v2GetWorkflowVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + params: deploymentVersionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowVersionDetailSchema), + }, +}) + +export const v2DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1DeployWorkflowDataSchema), + }, +}) + +export const v2UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowDataSchema), + }, +}) + +export const v2RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1RollbackWorkflowDataSchema), + }, +}) + +/** + * Structured execution error — mirrors `WorkflowExecutionErrorCode` in + * `@/executor/utils/errors` (duplicated literally: contracts are + * client-importable and must not pull executor modules). APPEND-ONLY: callers + * route on these instead of substring-matching messages. + */ +export const v2ExecutionErrorSchema = z.object({ + message: z.string(), + code: z.enum([ + 'TIMEOUT', + 'CANCELLED', + 'USAGE_LIMIT_EXCEEDED', + 'INVALID_INPUT', + 'BLOCK_EXECUTION_FAILED', + 'CHILD_WORKFLOW_FAILED', + 'OUTPUT_TOO_LARGE', + 'EXECUTION_FAILED', + ]), + /** Failing block, when attributable. Deliberately crosses the workspace boundary for shared/child workflows — the executionId + block context is the reproducible handle a caller hands the workflow provider. */ + blockId: z.string().optional(), + blockName: z.string().optional(), + blockType: z.string().optional(), +}) +export type V2ExecutionError = z.output + +/** + * Strict public execute body. Async is body-selected (`async: true`) — v2 has + * no `X-Execution-Mode`/`X-Stream-Response` headers. Internal caller facts + * (triggerType, draft state, deployment pinning) are NEVER wire fields; they + * are typed options on the execution service. + */ +export const v2ExecuteWorkflowBodySchema = z + .object({ + input: z.record(z.string(), z.unknown()).optional(), + async: z.boolean().optional().default(false), + stream: z.boolean().optional().default(false), + selectedOutputs: z.array(z.string().min(1)).max(100).optional(), + includeThinking: z.boolean().optional().default(false), + includeToolCalls: z.boolean().optional().default(false), + includeFileBase64: z.boolean().optional(), + /** Caps inline base64 file hydration; bounded (v1 leaves it unbounded). */ + base64MaxBytes: z + .number() + .int() + .positive() + .max(10 * 1024 * 1024) + .optional(), + }) + .strict() +export type V2ExecuteWorkflowBody = z.input + +/** + * The execution result resource. In-band run failures are `status: 'failed'` + * with a structured `error` — never an HTTP error: **an `executionId` means + * 200/202 + `data`; no `executionId` means the `v2Error` envelope.** The sync + * timeout is `status:'failed'` + `error.code:'TIMEOUT'` (v1 returned 408). + */ +export const v2ExecuteWorkflowDataSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: z.enum(['completed', 'failed', 'paused', 'cancelled']), + output: z.unknown(), + error: v2ExecutionErrorSchema.nullable(), + startedAt: z.string().optional(), + endedAt: z.string().optional(), + durationMs: z.number().optional(), +}) +export type V2ExecuteWorkflowData = z.output + +/** 202 receipt for `async: true` — poll `statusUrl` (the v2 executions resource). */ +export const v2ExecuteWorkflowQueuedSchema = z.object({ + executionId: z.string(), + statusUrl: z.string(), +}) +export type V2ExecuteWorkflowQueued = z.output + +export const v2ExecuteWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/execute', + params: workflowIdParamsSchema, + body: v2ExecuteWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecuteWorkflowDataSchema), + }, +}) + +/** + * The polled execution resource. `queued` is backfilled from the async job + * queue before the worker writes the durable log row — v1's jobs endpoint 404 + * window doesn't exist here. `error` is the same structured object the execute + * response carries. + */ +export const v2WorkflowExecutionStatusSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: z.enum(['queued', 'pending', 'running', 'completed', 'failed', 'cancelled', 'paused']), + trigger: z.string().nullable(), + startedAt: z.string().nullable(), + endedAt: z.string().nullable(), + durationMs: z.number().nullable(), + paused: workflowExecutionPausedDetailSchema.nullable(), + cost: z.object({ total: z.number() }).nullable(), + error: v2ExecutionErrorSchema.nullable(), + /** Populated only with `includeOutput=true` on completed runs. */ + output: z.unknown().nullable(), + blockOutputs: z.record(z.string(), z.unknown()).nullable(), +}) +export type V2WorkflowExecutionStatus = z.output + +export const v2GetWorkflowExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/executions/[executionId]', + params: workflowExecutionParamsSchema, + query: workflowExecutionStatusQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowExecutionStatusSchema), + }, +}) + +export const v2CancelWorkflowExecutionDataSchema = z.object({ + success: z.boolean(), + executionId: z.string(), + redisAvailable: z.boolean(), + durablyRecorded: z.boolean(), + locallyAborted: z.boolean(), + pausedCancelled: z.boolean(), + reason: cancelWorkflowExecutionReasonSchema.optional(), +}) +export type V2CancelWorkflowExecutionData = z.output + +export const v2CancelWorkflowExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', + params: workflowExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelWorkflowExecutionDataSchema), + }, +}) + +export const v2WorkflowExportPayloadSchema = v1WorkflowExportPayloadSchema.extend({ + workflow: v1WorkflowExportPayloadSchema.shape.workflow + .omit({ folderId: true }) + .extend({ folderPath: v2FolderPathSchema }), +}) + +export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema + .omit({ folderId: true, name: true, description: true }) + .extend({ + folderPath: v2FolderPathInputSchema.optional(), + name: z + .string() + .min(1, 'name cannot be empty') + .max( + V1_IMPORT_NAME_MAX_LENGTH, + `name must be at most ${V1_IMPORT_NAME_MAX_LENGTH} characters` + ) + .optional(), + description: z + .string() + .max( + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + `description must be at most ${V1_IMPORT_DESCRIPTION_MAX_LENGTH} characters` + ) + .optional(), + }) + .strict() + +export const v2ImportWorkflowDataSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + workspaceId: z.string(), + folderPath: v2FolderPathSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + +export const v2ExportWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/export', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowExportPayloadSchema), + }, +}) + +export const v2ImportWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/import', + body: v2ImportWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ImportWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 89a926caf38..8847adc6630 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -554,7 +554,7 @@ const workflowExecutionStatusEnum = z.enum([ 'cancelled', ]) -const workflowExecutionPausedDetailSchema = z.object({ +export const workflowExecutionPausedDetailSchema = z.object({ pausedAt: z.string(), resumeAt: z.string().nullable(), pauseKind: z.enum(['time', 'human']).nullable(), @@ -583,7 +583,7 @@ const workflowExecutionStatusResponseSchema = z.object({ export type WorkflowExecutionStatusResponse = z.output -const workflowExecutionStatusQuerySchema = z.object({ +export const workflowExecutionStatusQuerySchema = z.object({ includeOutput: z .enum(['true', 'false']) .optional() @@ -601,6 +601,21 @@ const workflowExecutionStatusQuerySchema = z.object({ ), }) +/** + * Full cancellation-outcome vocabulary — mirrors + * `CancelWorkflowExecutionReason` in `lib/execution/cancel-workflow-execution` + * (contracts stay import-clean of server modules). The paused-HITL path emits + * the two `paused_*` values; a narrower copy of this enum previously lived in + * `contracts/logs.ts` and made the client reject those responses. + */ +export const cancelWorkflowExecutionReasonSchema = z.enum([ + 'recorded', + 'redis_unavailable', + 'redis_write_failed', + 'paused_event_publish_failed', + 'paused_database_cancel_failed', +]) + const cancelWorkflowExecutionResponseSchema = z.object({ success: z.boolean(), executionId: z.string(), @@ -608,7 +623,7 @@ const cancelWorkflowExecutionResponseSchema = z.object({ durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), - reason: z.string().optional(), + reason: cancelWorkflowExecutionReasonSchema.optional(), }) const resumeWorkflowExecutionContextResponseSchema = z diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 1351e5f9527..e917e7f5ffb 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { inlineFileRefQuerySchema } from '@/lib/api/contracts/primitives' +import { + folderIdSchema, + inlineFileRefQuerySchema, + isCanonicalBase64, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -12,7 +17,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export const workspaceFileScopeSchema = z.enum(['active', 'archived']) export const workspaceFilesParamsSchema = z.object({ - id: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), + id: workspaceIdSchema, }) export const workspaceFileParamsSchema = workspaceFilesParamsSchema.extend({ @@ -38,10 +43,11 @@ export const getInlineWorkspaceFileContract = defineRouteContract({ }, }) -const workspaceFileNameSchema = z +export const workspaceFileNameSchema = z .string({ error: 'Name is required' }) .trim() .min(1, 'Name is required') + .max(255, 'Name is too long') .refine( (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), 'Name cannot contain path separators or dot segments' @@ -51,10 +57,46 @@ export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, }) -export const updateWorkspaceFileContentBodySchema = z.object({ - content: z.string(), - encoding: z.enum(['base64', 'utf-8']).optional(), -}) +export const updateWorkspaceFileContentBodySchema = z + .object({ + content: z.string().max(70_000_000, 'Content is too large'), + encoding: z.enum(['base64', 'utf-8']).optional(), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'Content must be valid base64', + }) + } + }) + +export const createWorkspaceFileBodySchema = z + .object({ + name: workspaceFileNameSchema, + contentType: z + .string() + .trim() + .min(1, 'Content type cannot be empty') + .max(255, 'Content type is too long') + .optional(), + folderId: folderIdSchema.optional(), + content: z.string().max(70_000_000, 'Content is too large').default(''), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'Content must be valid base64', + }) + } + }) + .strict() + +export type CreateWorkspaceFileBody = z.input export const workspaceFileRecordSchema = z.object({ id: z.string(), @@ -96,6 +138,19 @@ export const listWorkspaceFilesContract = defineRouteContract({ }, }) +export const createWorkspaceFileContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files', + params: workspaceFilesParamsSchema, + body: createWorkspaceFileBodySchema, + response: { + mode: 'json', + schema: workspaceFileSuccessSchema.extend({ + file: workspaceFileRecordSchema, + }), + }, +}) + export const renameWorkspaceFileContract = defineRouteContract({ method: 'PATCH', path: '/api/workspaces/[id]/files/[fileId]', @@ -195,78 +250,3 @@ export const workspaceFileCompiledCheckContract = defineRouteContract({ schema: compiledCheckResponseSchema, }, }) - -export const workspacePresignedUploadBodySchema = z.object({ - fileName: workspaceFileNameSchema, - contentType: z.string().min(1, 'contentType is required'), - fileSize: z.number().nonnegative('fileSize must be a non-negative number'), - folderId: z.string().nullable().optional(), -}) - -export type WorkspacePresignedUploadBody = z.input - -const workspacePresignedFileInfoSchema = z.object({ - path: z.string(), - key: z.string(), - name: z.string(), - size: z.number(), - type: z.string(), -}) - -const workspacePresignedUploadResponseSchema = z.object({ - fileName: z.string(), - presignedUrl: z.string(), - fileInfo: workspacePresignedFileInfoSchema, - uploadHeaders: z.record(z.string(), z.string()).optional(), - directUploadSupported: z.boolean(), -}) - -export const workspacePresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/files/presigned', - params: workspaceFilesParamsSchema, - body: workspacePresignedUploadBodySchema, - response: { - mode: 'json', - schema: workspacePresignedUploadResponseSchema, - }, -}) - -export const registerWorkspaceFileBodySchema = z.object({ - key: z.string().min(1, 'key is required'), - name: workspaceFileNameSchema, - contentType: z.string().min(1, 'contentType is required'), - folderId: z.string().nullable().optional(), -}) - -export type RegisterWorkspaceFileBody = z.input - -const registeredWorkspaceFileSchema = z.object({ - id: z.string(), - name: z.string(), - url: z.string(), - size: z.number(), - type: z.string(), - key: z.string(), - context: z.string().optional(), -}) - -const registerWorkspaceFileResponseSchema = z.object({ - success: z.boolean(), - file: registeredWorkspaceFileSchema.optional(), - error: z.string().optional(), - isDuplicate: z.boolean().optional(), -}) - -export type RegisterWorkspaceFileResponse = z.output - -export const registerWorkspaceFileContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/files/register', - params: workspaceFilesParamsSchema, - body: registerWorkspaceFileBodySchema, - response: { - mode: 'json', - schema: registerWorkspaceFileResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/list-convention.test.ts b/apps/sim/lib/api/list-convention.test.ts new file mode 100644 index 00000000000..fcb2594dc26 --- /dev/null +++ b/apps/sim/lib/api/list-convention.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + * + * One test per v2 list-backing query, asserting the same two things everywhere: + * `search` becomes a bound case-insensitive substring predicate on that + * resource's natural name column, and `sortBy` selects the ordering columns. + * + * This is the surface-wide guard the convention needs. `list-query.test.ts` + * proves the generated SQL is parameterized and wildcard-escaped; what can still + * go wrong per resource is aiming the search at the wrong column, or a sort that + * never reaches `ORDER BY` — both visible only in the query each lib builds. + * + * Files (`queryWorkspaceFiles`) and workflows have their own suites, since they + * additionally paginate. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: vi.fn() })) +vi.mock('@/lib/billing/core/usage', () => ({ ensureUserStatsExists: vi.fn() })) +vi.mock('@/lib/billing/storage', () => ({ + applyStorageUsageDeltasInTx: vi.fn(), + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) +vi.mock('@/lib/table/billing', () => ({ + assertRowCapacity: vi.fn(), + notifyTableRowUsage: vi.fn(), +})) +vi.mock('@/lib/table/jobs/service', () => ({ + EMPTY_JOB_FIELDS: {}, + latestJobForTable: vi.fn(async () => null), + latestJobsForTables: vi.fn(async () => new Map()), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/realtime/notify', () => ({ + mergeEditIntoLiveFileDoc: vi.fn(), + notifyWorkspaceFilesChanged: vi.fn(), + notifyWorkspaceTablesChanged: vi.fn(), +})) +vi.mock('@/lib/skills/access', () => ({ getEditableSkillIds: vi.fn() })) +vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ + BUILTIN_SKILLS: [], + getBuiltinSkillById: vi.fn(), + isBuiltinSkillId: vi.fn(() => false), +})) + +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { getKnowledgeBases } from '@/lib/knowledge/service' +import { listWorkspaceMcpServers } from '@/lib/mcp/queries' +import { listTables } from '@/lib/table/service' +import { listWorkspaceCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listSkills } from '@/lib/workflows/skills/operations' + +const WS = 'workspace-1' + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +const searchNode = () => lastConditions().find((c) => c.type === 'ilike') + +interface ListCase { + name: string + /** Column the resource's `search` must match on. */ + column: unknown + /** Rows table to queue against, so the chain resolves. */ + table: unknown + run: (options: { + search?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + }) => Promise + /** A non-default sort, and the columns it must order by. */ + sort: { sortBy: string; sortOrder: 'asc' | 'desc'; columns: unknown[] } +} + +const CASES: ListCase[] = [ + { + name: 'folders', + column: schemaMock.folder.name, + table: schemaMock.folder, + run: ({ search, sortBy, sortOrder }) => + listFoldersForWorkspace(WS, 'active', 'workflow', { + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'name', + sortOrder: 'desc', + columns: [schemaMock.folder.name, schemaMock.folder.createdAt], + }, + }, + { + name: 'tables', + column: schemaMock.userTableDefinitions.name, + table: schemaMock.userTableDefinitions, + run: ({ search, sortBy, sortOrder }) => + listTables(WS, { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'updatedAt', + sortOrder: 'desc', + columns: [ + schemaMock.userTableDefinitions.updatedAt, + schemaMock.userTableDefinitions.createdAt, + ], + }, + }, + { + name: 'knowledge bases', + column: schemaMock.knowledgeBase.name, + table: schemaMock.knowledgeBase, + run: ({ search, sortBy, sortOrder }) => + getKnowledgeBases('user-1', WS, 'active', { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.knowledgeBase.name, schemaMock.knowledgeBase.createdAt], + }, + }, + { + name: 'credentials', + column: schemaMock.credential.displayName, + table: schemaMock.credential, + run: ({ search, sortBy, sortOrder }) => + listVisibleWorkspaceCredentials({ + workspaceId: WS, + userId: 'user-1', + workspaceAccess: { canAdmin: false }, + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'displayName', + sortOrder: 'asc', + columns: [schemaMock.credential.displayName, schemaMock.credential.id], + }, + }, + { + name: 'MCP servers', + column: schemaMock.mcpServers.name, + table: schemaMock.mcpServers, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceMcpServers({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.mcpServers.name, schemaMock.mcpServers.id], + }, + }, + { + name: 'custom tools', + column: schemaMock.customTools.title, + table: schemaMock.customTools, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceCustomTools({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'title', + sortOrder: 'asc', + columns: [schemaMock.customTools.title, schemaMock.customTools.id], + }, + }, + { + name: 'skills', + column: schemaMock.skill.name, + table: schemaMock.skill, + run: ({ search, sortBy, sortOrder }) => + listSkills({ + workspaceId: WS, + search, + sort: sortBy ? { sortBy: sortBy as never, sortOrder: sortOrder ?? 'desc' } : undefined, + }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.skill.name, schemaMock.skill.id], + }, + }, +] + +describe.each(CASES)('$name list query', (listCase) => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(listCase.table, []) + }) + + it('narrows the query with a case-insensitive substring match on its name column', async () => { + await listCase.run({ search: 'quarterly' }) + + expect(searchNode()).toMatchObject({ column: listCase.column, pattern: '%quarterly%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + await listCase.run({ search: '50%_off' }) + + expect(searchNode()).toMatchObject({ pattern: '%50\\%\\_off%' }) + }) + + it('adds no search condition when the caller did not search', async () => { + await listCase.run({}) + + expect(searchNode()).toBeUndefined() + }) + + it('orders by the requested field and direction', async () => { + const { sortBy, sortOrder, columns } = listCase.sort + + await listCase.run({ sortBy, sortOrder }) + + expect(lastOrderBy()).toEqual(columns.map((column) => ({ type: sortOrder, column }))) + }) +}) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts new file mode 100644 index 00000000000..4da992aa2f4 --- /dev/null +++ b/apps/sim/lib/api/list-query.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + * + * The v2 list convention's SQL half. These run against REAL drizzle (the global + * `drizzle-orm` mock is lifted for this file) and render the generated SQL, so + * the assertions are about the query that would actually be sent — the point + * being that a caller's `search` term only ever arrives as a bound parameter. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('drizzle-orm') + +import { integer, PgDialect, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { + encodeKeyset, + escapeLikePattern, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' + +const thing = pgTable('thing', { + id: text('id').primaryKey(), + name: text('name').notNull(), + size: integer('size').notNull(), + createdAt: timestamp('created_at').notNull(), +}) + +const dialect = new PgDialect() + +function render(fragment: Parameters[0]) { + return dialect.sqlToQuery(fragment) +} + +describe('escapeLikePattern', () => { + it('neutralizes the LIKE wildcards so a caller cannot widen its own match', () => { + expect(escapeLikePattern('100%')).toBe('100\\%') + expect(escapeLikePattern('a_b')).toBe('a\\_b') + expect(escapeLikePattern('back\\slash')).toBe('back\\\\slash') + }) + + it('leaves an ordinary term untouched', () => { + expect(escapeLikePattern('quarterly report')).toBe('quarterly report') + }) +}) + +describe('searchFilter', () => { + it('binds the caller term as a parameter instead of inlining it into the SQL', () => { + const { sql, params } = render(searchFilter(thing.name, "o'brien; drop table thing --")!) + + expect(sql).toBe('"thing"."name" ilike $1') + expect(params).toEqual(["%o'brien; drop table thing --%"]) + expect(sql).not.toContain('drop table') + }) + + it('escapes wildcards inside the bound pattern', () => { + const { params } = render(searchFilter(thing.name, '50%_off')!) + + expect(params).toEqual(['%50\\%\\_off%']) + }) + + it('is case-insensitive (ILIKE, not LIKE)', () => { + const { sql } = render(searchFilter(thing.name, 'Report')!) + + expect(sql).toContain('ilike') + }) + + it('drops out of the WHERE clause entirely when no term was given', () => { + expect(searchFilter(thing.name, undefined)).toBeUndefined() + }) +}) + +describe('listOrderBy', () => { + it('applies the direction to every key so the ordering is total', () => { + const [first, second] = listOrderBy([thing.name, thing.id], 'desc') + + expect(render(first).sql).toBe('"thing"."name" desc') + expect(render(second).sql).toBe('"thing"."id" desc') + }) +}) + +interface Row { + id: string + name: string + createdAt: Date +} + +const nameKey = textKey(thing.name, (r) => r.name) +const idKey = textKey(thing.id, (r) => r.id) +const createdKey = timestampKey(thing.createdAt, (r) => r.createdAt) + +describe('timestampKey', () => { + /** + * The regression this exists for: Postgres keeps microseconds, a cursor value + * round-trips through a millisecond-only JS Date, and comparing the raw column + * against the truncated value re-admits the page's own last row. + */ + it('orders on the millisecond-truncated column so the cursor can express the ordering', () => { + expect(render(createdKey.expr as never).sql).toBe( + `date_trunc('milliseconds', "thing"."created_at")` + ) + }) + + it('truncates the bound cursor value to match, binding it through the column encoder', () => { + const { sql: text, params } = render(createdKey.bind('2024-01-01T00:00:00.123Z')!) + + expect(text).toBe(`date_trunc('milliseconds', $1)`) + expect(params).toEqual(['2024-01-01T00:00:00.123Z']) + }) + + it('rejects a cursor value that is not a parseable timestamp', () => { + expect(createdKey.bind('not-a-date')).toBeNull() + expect(createdKey.bind(1700000000000)).toBeNull() + }) +}) + +describe('cursor key value validation', () => { + it('rejects a non-string for a text key', () => { + expect(nameKey.bind(42)).toBeNull() + expect(nameKey.bind('ok')).not.toBeNull() + }) + + it('rejects a non-finite or non-numeric value for a numeric key', () => { + const sizeKey = numberKey(thing.size, () => 0) + + expect(sizeKey.bind('12')).toBeNull() + expect(sizeKey.bind(Number.NaN)).toBeNull() + expect(sizeKey.bind(Number.POSITIVE_INFINITY)).toBeNull() + expect(sizeKey.bind(12)).not.toBeNull() + }) +}) + +describe('encodeKeyset / keysetColumns', () => { + it('reads the cursor values and the ordering expressions in key order', () => { + const row: Row = { id: 'file-7', name: 'data.csv', createdAt: new Date('2024-03-04T05:06:07Z') } + + expect(encodeKeyset([nameKey, idKey], row)).toEqual(['data.csv', 'file-7']) + expect(keysetColumns([nameKey, idKey])).toEqual([thing.name, thing.id]) + }) +}) + +describe('keysetAfter', () => { + it('expands lexicographically so a tie on a leading key falls through', () => { + const { sql: text, params } = render( + keysetAfter([nameKey, idKey], ['data.csv', 'file-7'], 'asc')! + ) + + expect(text).toBe('("thing"."name" > $1 or ("thing"."name" = $2 and "thing"."id" > $3))') + expect(params).toEqual(['data.csv', 'data.csv', 'file-7']) + }) + + it('flips the comparison for a descending sort', () => { + const { sql: text } = render(keysetAfter([nameKey, idKey], ['b', 'x'], 'desc')!) + + expect(text).toContain('"thing"."name" < $1') + expect(text).not.toContain('>') + }) + + it('binds every keyset value as a parameter', () => { + const { sql: text, params } = render(keysetAfter([idKey], ["'; delete from thing --"], 'asc')!) + + expect(text).toBe('"thing"."id" > $1') + expect(params).toEqual(["'; delete from thing --"]) + }) + + it('compares the truncated timestamp on both sides', () => { + const { sql: text, params } = render( + keysetAfter([createdKey, idKey], ['2024-01-01T00:00:00.123Z', 'file-7'], 'asc')! + ) + + expect(text).toBe( + `(date_trunc('milliseconds', "thing"."created_at") > date_trunc('milliseconds', $1) or ` + + `(date_trunc('milliseconds', "thing"."created_at") = date_trunc('milliseconds', $2) and ` + + `"thing"."id" > $3))` + ) + expect(params).toEqual(['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z', 'file-7']) + }) + + /** A caller controls the cursor's contents, so a bad value is a 400, not a 500 from SQL. */ + it('refuses a cursor carrying a value its key cannot hold', () => { + expect(keysetAfter([createdKey, idKey], ['not-a-date', 'file-7'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], [7, 'file-7'], 'asc')).toBeNull() + }) + + it('refuses a cursor with the wrong number of keys for the sort', () => { + expect(keysetAfter([nameKey, idKey], ['only-one'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], ['a', 'b', 'c'], 'asc')).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts new file mode 100644 index 00000000000..8740d11a541 --- /dev/null +++ b/apps/sim/lib/api/list-query.ts @@ -0,0 +1,176 @@ +import { + and, + asc, + type Column, + desc, + eq, + gt, + ilike, + lt, + or, + type SQL, + type SQLWrapper, + sql, +} from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' + +/** + * Runtime half of the v2 list convention declared in + * `lib/api/contracts/v2/shared.ts`: turns a validated `search` term and a + * validated `sortBy`/`sortOrder` pair into SQL. + * + * Nothing here accepts a caller string as SQL. `search` becomes a bound ILIKE + * parameter, a sort is only ever expressed as one of the keys the resource + * itself listed (the contract enum is what makes that lookup total), and a + * cursor's values are type-checked against their key before they are bound. + */ + +/** + * Escapes LIKE/ILIKE wildcards so `%`, `_`, and `\` in a caller's term match + * themselves. Postgres treats `\` as the default LIKE escape character, so no + * explicit `ESCAPE` clause is needed. + * + * `lib/table/sql.ts` carries its own copy for the JSONB predicate engine; the + * two are worth folding together, but that module is table-specific and pulls + * the whole column-type registry with it. + */ +export function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + +/** + * Case-insensitive substring predicate for a v2 `search` term, or `undefined` + * when the caller did not search (which drops out of an `and(...)`). + */ +export function searchFilter(column: Column, term: string | undefined): SQL | undefined { + if (term === undefined) return undefined + return ilike(column, `%${escapeLikePattern(term)}%`) +} + +/** A cursor key value, as it survives the base64-JSON round trip. */ +export type CursorKey = string | number + +/** Caller-facing message for a cursor that cannot be resumed under the requested sort. */ +export const INVALID_CURSOR_MESSAGE = + 'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.' + +/** + * One column of a keyset ordering, with the codec that moves its value through + * the opaque cursor. + * + * `bind` returning `null` is how a malformed cursor becomes a 400. The values + * inside a cursor are caller-controlled, so "the sort stamp and key count + * match" is not enough — a non-numeric `size` or an unparseable timestamp has + * to be rejected at the boundary instead of reaching the query as `NaN` or an + * `Invalid Date`, which surfaces as a 500. + */ +export interface KeysetKey { + /** The expression this key both orders and compares on. */ + expr: SQLWrapper + /** This key's cursor value for `row`. */ + encode: (row: Row) => CursorKey + /** The cursor value as bindable SQL, or `null` when this key cannot hold it. */ + bind: (value: CursorKey) => SQL | null +} + +/** A text key — names, titles, ids. */ +export function textKey(column: Column, read: (row: Row) => string): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'string' ? sql`${value}` : null), + } +} + +/** A numeric key — sizes, counts, manual positions. */ +export function numberKey(column: SQLWrapper, read: (row: Row) => number): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'number' && Number.isFinite(value) ? sql`${value}` : null), + } +} + +/** + * A timestamp key, ordered and compared at millisecond precision. + * + * Postgres keeps microseconds and `defaultNow()` populates them, but a cursor + * value round-trips through a JS `Date`, which cannot represent them. Ordering + * on the raw column while comparing against a truncated cursor value re-admits + * the page's own last row — `stored > truncated` is true for it — which + * duplicates that row and stalls pagination outright at a page size of one. + * Truncating both sides makes the SQL ordering exactly the ordering a cursor + * can express, so the `id` tiebreaker is what actually separates rows inside a + * millisecond. + * + * `date_trunc` rules out an index-ordered scan, but none of the timestamp + * columns sorted here are indexed, so it costs nothing today. Adding an index + * to serve one of these sorts means indexing this same expression. + */ +export function timestampKey(column: Column, read: (row: Row) => Date): KeysetKey { + return { + expr: sql`date_trunc('milliseconds', ${column})`, + encode: (row) => read(row).toISOString(), + bind: (value) => { + if (typeof value !== 'string') return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + // Bound through the column so drizzle's own timestamp encoder serializes it. + return sql`date_trunc('milliseconds', ${sql.param(date, column)})` + }, + } +} + +export function sortDirection(order: V2SortOrder): typeof asc { + return order === 'asc' ? asc : desc +} + +/** + * `ORDER BY` for an ordered key list, every key taking the requested direction. + * On a paginated list these are the keyset's keys; on a single-page list they + * are just the sort plus its tiebreaker. + */ +export function listOrderBy(keys: readonly SQLWrapper[], order: V2SortOrder): SQL[] { + const direction = sortDirection(order) + return keys.map((key) => direction(key)) +} + +/** The `expr` of each keyset key, for `ORDER BY`. */ +export function keysetColumns(keys: readonly KeysetKey[]): SQLWrapper[] { + return keys.map((key) => key.expr) +} + +/** The cursor values for `row`, in key order. */ +export function encodeKeyset(keys: readonly KeysetKey[], row: Row): CursorKey[] { + return keys.map((key) => key.encode(row)) +} + +/** + * The `WHERE` half of the keyset: strictly after `values` in the requested + * direction, expanded lexicographically so ties on a leading key fall through + * to the next one. + * + * Returns `null` when the cursor does not fit this sort — wrong number of keys, + * or a value the key cannot hold. Callers render that as a 400 rather than + * paging from a nonsense position. + */ +export function keysetAfter( + keys: readonly KeysetKey[], + values: CursorKey[], + order: V2SortOrder +): SQL | null { + if (values.length !== keys.length) return null + + const bound: SQL[] = [] + for (const [i, key] of keys.entries()) { + const value = key.bind(values[i]) + if (value === null) return null + bound.push(value) + } + + const beyond = order === 'asc' ? gt : lt + const clauses = keys.map((key, i) => + and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i])) + ) + return or(...clauses) ?? null +} diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index 59a11264c85..cea29279136 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -67,16 +67,16 @@ export function formatCreditCost( /** * Renders an already-apportioned integer `creditCost` (see {@link apportionCredits}) - * alongside its raw `dollarCost`, so a row can legitimately apportion to 0 - * credits — once a sibling absorbs the shared rounding remainder — without - * reading as a flat, misleading "0 credits" for an event that had a real, - * positive charge. Mirrors {@link formatCreditCost}'s zero/sub-credit - * wording, but never recomputes credits from `dollarCost` (that would - * double-convert a value the caller already apportioned). + * alongside whether the row carried any real charge, so a row can legitimately + * apportion to 0 credits — once a sibling absorbs the shared rounding + * remainder — without reading as a flat, misleading "0 credits" for an event + * that had a real, positive charge. Mirrors {@link formatCreditCost}'s + * zero/sub-credit wording; `hasCost` is a boolean rather than the raw dollar + * cost because dollar amounts never go on the wire. */ -export function formatApportionedCreditCost(creditCost: number, dollarCost: number): string { +export function formatApportionedCreditCost(creditCost: number, hasCost: boolean): string { if (creditCost > 0) return formatCreditsLabel(creditCost) - return dollarCost > 0 ? '<1 credit' : '0 credits' + return hasCost ? '<1 credit' : '0 credits' } /** diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 5496ad64a5d..59e829499df 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -6,6 +6,7 @@ export { getStorageUsageForBillingContext, getUserStorageLimit, getUserStorageUsage, + StorageLimitExceededError, } from './limits' export { applyStorageUsageDeltasInTx, diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 34b01d97b07..96d27a82265 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -21,9 +21,24 @@ import type { StorageBillingContext } from '@/lib/billing/storage/context' import { getLegacyStorageBillingEntity } from '@/lib/billing/storage/entity' import { getEnv } from '@/lib/core/config/env' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' const logger = createLogger('StorageLimits') +/** + * Thrown when accepting a write would push its payer past its storage quota. + * + * An {@link OrchestrationError} so every surface reaches 413 by class. The bare + * `Error` this replaced was classified by searching the message for "storage + * limit", which the UI, v1, and v2 knowledge routes each re-implemented. + */ +export class StorageLimitExceededError extends OrchestrationError { + constructor(message: string) { + super('payload_too_large', message) + this.name = 'StorageLimitExceededError' + } +} + type StorageLimits = ReturnType interface StorageLimitResolutionInput { diff --git a/apps/sim/lib/billing/storage/payer-transfer.ts b/apps/sim/lib/billing/storage/payer-transfer.ts index 6eb9ca9337a..50d7ee2fe76 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.ts @@ -82,7 +82,7 @@ async function getExactWorkspaceStorageBytes(tx: DbOrTx, workspaceId: string): P const [row] = await tx.execute(sql` SELECT COALESCE(( - SELECT SUM(${workspaceFiles.size}::bigint) + SELECT SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) FROM ${workspaceFiles} WHERE ${workspaceFiles.workspaceId} = ${workspaceId} AND ${workspaceFiles.context} = 'workspace' @@ -171,7 +171,7 @@ async function getExactWorkspaceStorageBytesBatch( FROM ( SELECT ${workspaceFiles.workspaceId} AS workspace_id, - SUM(${workspaceFiles.size}::bigint) AS workspace_file_bytes, + SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) AS workspace_file_bytes, 0::bigint AS document_bytes FROM ${workspaceFiles} WHERE ${inArray(workspaceFiles.workspaceId, workspaceIds)} diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 1fab48fa652..0de9b03507b 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -18,6 +18,7 @@ import { getUserStorageLimit, getUserStorageUsage, isStorageEnforcementEnabled, + StorageLimitExceededError, } from '@/lib/billing/storage/limits' import { getFreeTierLimit, isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import type { DbOrTx } from '@/lib/db/types' @@ -362,7 +363,7 @@ export async function applyStorageUsageDeltasInTx( payerDelta.maximumUsage !== undefined && nextUsage > payerDelta.maximumUsage ) { - throw new Error( + throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(nextUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(payerDelta.maximumUsage / 1024 ** 3).toFixed(0)}GB` ) } @@ -471,7 +472,7 @@ async function mutateWorkspaceStorageUsage( currentPayerUsage + bytes > maximumUsage ) { const newUsage = currentPayerUsage + bytes - throw new Error( + throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(newUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(maximumUsage / 1024 ** 3).toFixed(0)}GB` ) } diff --git a/apps/sim/lib/collab-doc/converter.ts b/apps/sim/lib/collab-doc/converter.ts index 990e0f906c6..2a5fafab0c9 100644 --- a/apps/sim/lib/collab-doc/converter.ts +++ b/apps/sim/lib/collab-doc/converter.ts @@ -70,6 +70,8 @@ function ensureDomForTipTap(): void { // temporal dead zone ("Cannot access 'window' before initialization"). const { JSDOM } = require('jsdom') as typeof import('jsdom') const { window: jsdomWindow } = new JSDOM('') + // double-cast-allowed: assigning the jsdom shims onto the global needs an + // index-signature view of `globalThis`, whose declared type has none. const g = globalThis as unknown as Record g.window = jsdomWindow g.document = jsdomWindow.document diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index 95bd20be60a..c68debf0af0 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -1,12 +1,13 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions' -import { captureServerEvent } from '@/lib/posthog/server' -import { getSkillActorContext } from '@/lib/skills/access' -import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, +} from '@/lib/skills/orchestration' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -73,35 +74,16 @@ export async function executeManageSkill( } } - const { skills: resultSkills } = await upsertSkills({ - skills: [{ name: params.name, description: params.description, content: params.content }], + const result = await performCreateSkill({ workspaceId, userId: context.userId, + name: params.name, + description: params.description, + content: params.content, + source: 'tool_input', }) - const created = resultSkills.find((s) => s.name === params.name) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: created?.id, - resourceName: params.name, - description: `Created skill "${params.name}"`, - metadata: { source: 'tool_input' }, - }) - if (created?.id) { - captureServerEvent( - context.userId, - 'skill_created', - { - skill_id: created.id, - skill_name: params.name, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to create skill' } } return { @@ -109,9 +91,9 @@ export async function executeManageSkill( output: { success: true, operation, - skillId: created?.id, - name: params.name, - message: `Created skill "${params.name}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Created skill "${result.skill.name}"`, }, } } @@ -127,66 +109,28 @@ export async function executeManageSkill( } } - if (isBuiltinSkillId(params.skillId)) { - return { success: false, error: 'Built-in skills are read-only and cannot be modified' } - } - - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: editing skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - // Partial update: omitted fields keep their current values server-side. - await upsertSkills({ - skills: [ - { - id: params.skillId, - ...(params.name ? { name: params.name } : {}), - ...(params.description ? { description: params.description } : {}), - ...(params.content ? { content: params.content } : {}), - }, - ], + const result = await performUpdateSkill({ workspaceId, userId: context.userId, + skillId: params.skillId, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + ...(params.content ? { content: params.content } : {}), + source: 'tool_input', }) - - const updatedName = params.name || actor.skill.name - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_UPDATED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - resourceName: updatedName, - description: `Updated skill "${updatedName}"`, - metadata: { source: 'tool_input' }, - }) - captureServerEvent( - context.userId, - 'skill_updated', - { - skill_id: params.skillId, - skill_name: updatedName, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to update skill' } + } return { success: true, output: { success: true, operation, - skillId: params.skillId, - name: updatedName, - message: `Updated skill "${updatedName}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Updated skill "${result.skill.name}"`, }, } } @@ -196,39 +140,15 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } - if (!isBuiltinSkillId(params.skillId)) { - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: deleting skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - } - - const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) - if (!deleted) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - description: 'Deleted skill', - metadata: { source: 'tool_input' }, + userId: context.userId, + skillId: params.skillId, + source: 'tool_input', }) - captureServerEvent( - context.userId, - 'skill_deleted', - { skill_id: params.skillId, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to delete skill' } + } return { success: true, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index e5b7ffefda4..c0fa82f81c6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -17,12 +17,14 @@ import { encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { generateRequestId } from '@/lib/core/utils/request' +import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { deleteKnowledgeBase, getKnowledgeBases, updateKnowledgeBase, } from '@/lib/knowledge/service' -import { deleteTable, listTables, renameTable } from '@/lib/table/service' +import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' +import { listTables } from '@/lib/table/service' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -33,13 +35,7 @@ import { resolveWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - performCreateFolder, - performDeleteFolder, - performDeleteWorkflow, - performUpdateFolder, - performUpdateWorkflow, -} from '@/lib/workflows/orchestration' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { @@ -502,7 +498,8 @@ function makeWorkflowFolderEnsurer( continue } await assertFolderMutable(parentId) - const created = await performCreateFolder({ + const created = await createFolder({ + resourceType: 'workflow', workspaceId, userId, name: segment, @@ -689,7 +686,8 @@ async function mutateWorkflows( continue } await assertFolderMutable(targetFolderId) - const result = await performUpdateFolder({ + const result = await updateFolder({ + resourceType: 'workflow', folderId: ref.folderId, workspaceId, userId: context.userId, @@ -759,11 +757,19 @@ async function renameFlatResource( return { success: false, error: `Table not found at ${sources[0]}` } } assertMutationNotAborted(context) - const renamed = await renameTable(match.id, newName, generateRequestId()) + const renameOutcome = await performRenameTable({ + table: match, + newName, + userId: context.userId, + requestId: generateRequestId(), + }) + if (!renameOutcome.success) { + return { success: false, error: renameOutcome.error ?? 'Failed to rename table' } + } return buildResult(verb, [ { from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, + to: `tables/${normalizeVfsSegment(newName)}`, kind, id: match.id, }, @@ -980,7 +986,12 @@ async function removeWorkflowPath( if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } await assertFolderMutable(folderId) - const result = await performDeleteFolder({ folderId, workspaceId, userId: context.userId }) + const result = await deleteFolder({ + resourceType: 'workflow', + folderId, + workspaceId, + userId: context.userId, + }) if (!result.success) { return { from: path, @@ -1018,7 +1029,14 @@ async function removeTablePath( ) if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } - await deleteTable(match.id, generateRequestId(), context.userId) + const outcome = await performDeleteTable({ + table: match, + userId: context.userId, + requestId: generateRequestId(), + }) + if (!outcome.success) { + return { from: path, kind: 'table', error: outcome.error ?? 'Failed to archive table' } + } logger.info('Archived table via rm', { tableId: match.id, workspaceId }) return { from: path, kind: 'table', id: match.id } } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index bbc3956c7a9..caeefc28f9c 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -2,34 +2,33 @@ * @vitest-environment node */ import { knowledgeConnector } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock, resetUrlsMock, urlsMockFns } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, mockCheckKnowledgeBaseWriteAccess, - mockFetch, - mockGenerateInternalToken, - mockSerializeBillingAttributionHeader, + mockGetKnowledgeBaseById, + mockPerformCreateKnowledgeConnector, + mockPerformDeleteKnowledgeBase, + mockPerformDeleteKnowledgeConnector, + mockPerformSyncKnowledgeConnector, } = vi.hoisted(() => ({ mockAssertBillingAttributionSnapshot: vi.fn(), mockCheckKnowledgeBaseWriteAccess: vi.fn(), - mockFetch: vi.fn(), - mockGenerateInternalToken: vi.fn(), - mockSerializeBillingAttributionHeader: vi.fn(), + mockGetKnowledgeBaseById: vi.fn(), + mockPerformCreateKnowledgeConnector: vi.fn(), + mockPerformDeleteKnowledgeBase: vi.fn(), + mockPerformDeleteKnowledgeConnector: vi.fn(), + mockPerformSyncKnowledgeConnector: vi.fn(), })) -vi.mock('@/lib/auth/internal', () => ({ - generateInternalToken: mockGenerateInternalToken, -})) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkActorUsageLimits: vi.fn(), })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ - BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, checkAttributedUsageLimits: vi.fn(), - serializeBillingAttributionHeader: mockSerializeBillingAttributionHeader, })) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ KnowledgeBase: { id: 'knowledge_base' }, @@ -37,28 +36,24 @@ vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ vi.mock('@/lib/copilot/tools/server/base-tool', () => ({ assertServerToolNotAborted: vi.fn(), })) -beforeAll(() => { - urlsMockFns.mockGetInternalApiBaseUrl.mockReturnValue('http://internal.test') -}) - -afterAll(resetUrlsMock) -vi.mock('@/lib/knowledge/documents/service', () => ({ - createSingleDocument: vi.fn(), - deleteDocument: vi.fn(), - processDocumentAsync: vi.fn(), - updateDocument: vi.fn(), -})) vi.mock('@/lib/knowledge/embeddings', () => ({ - EMBEDDING_DIMENSIONS: 1536, generateSearchEmbedding: vi.fn(), - getConfiguredEmbeddingModel: vi.fn(), recordSearchEmbeddingUsage: vi.fn(), })) +vi.mock('@/lib/knowledge/orchestration', () => ({ + performCreateKnowledgeBase: vi.fn(), + performDeleteKnowledgeBase: mockPerformDeleteKnowledgeBase, + performCreateKnowledgeConnector: mockPerformCreateKnowledgeConnector, + performDeleteKnowledgeConnector: mockPerformDeleteKnowledgeConnector, + performDeleteKnowledgeDocument: vi.fn(), + performSyncKnowledgeConnector: mockPerformSyncKnowledgeConnector, + performUpdateKnowledgeBase: vi.fn(), + performUpdateKnowledgeConnector: vi.fn(), + performUpdateKnowledgeDocument: vi.fn(), + performUploadKnowledgeDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/service', () => ({ - createKnowledgeBase: vi.fn(), - deleteKnowledgeBase: vi.fn(), - getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), + getKnowledgeBaseById: mockGetKnowledgeBaseById, })) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), @@ -73,6 +68,7 @@ vi.mock('@/lib/uploads', () => ({ StorageService: {} })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) +vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) vi.mock('@/app/api/knowledge/search/utils', () => ({ executeKnowledgeSearch: vi.fn(), })) @@ -101,6 +97,12 @@ const BILLING_ATTRIBUTION = { payerSubscription: null, } +const CONTEXT = { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, +} + describe('knowledge base connector Copilot operations', () => { afterAll(() => { resetDbChainMock() @@ -109,11 +111,8 @@ describe('knowledge base connector Copilot operations', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - vi.stubGlobal('fetch', mockFetch) queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) - mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') - mockGenerateInternalToken.mockResolvedValue('internal-token') mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -122,21 +121,21 @@ describe('knowledge base connector Copilot operations', () => { name: 'Paid KB', }, }) - mockFetch.mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ - success: true, - data: { - id: 'connector-1', - connectorType: 'notion', - status: 'active', - }, - }), + mockPerformCreateKnowledgeConnector.mockResolvedValue({ + success: true, + connector: { id: 'connector-1', connectorType: 'notion', status: 'active' }, + }) + mockPerformSyncKnowledgeConnector.mockResolvedValue({ success: true }) + mockPerformDeleteKnowledgeConnector.mockResolvedValue({ + success: true, + documentsDeleted: 0, + documentsKept: 3, }) }) it.each([ { + operation: 'add_connector', params: { operation: 'add_connector', args: { @@ -145,37 +144,89 @@ describe('knowledge base connector Copilot operations', () => { apiKey: 'api-key', }, }, - expectedPath: '/api/knowledge/knowledge-base-1/connectors', + perform: mockPerformCreateKnowledgeConnector, }, { - params: { - operation: 'sync_connector', - args: { connectorId: 'connector-1' }, - }, - expectedPath: '/api/knowledge/knowledge-base-1/connectors/connector-1/sync', + operation: 'sync_connector', + params: { operation: 'sync_connector', args: { connectorId: 'connector-1' } }, + perform: mockPerformSyncKnowledgeConnector, }, - ])( - 'forwards immutable billing attribution for $params.operation', - async ({ params, expectedPath }) => { - const result = await knowledgeBaseServerTool.execute(params, { - userId: 'external-admin', - workspaceId: 'workspace-paid', - billingAttribution: BILLING_ATTRIBUTION, - }) - - expect(result.success).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - `http://internal.test${expectedPath}`, - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer internal-token', - 'x-sim-billing-attribution': 'serialized-attribution', - }), - }) - ) - expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(BILLING_ATTRIBUTION) - } - ) + ])('forwards immutable billing attribution for $operation', async ({ params, perform }) => { + const result = await knowledgeBaseServerTool.execute(params, CONTEXT) + + expect(result.success).toBe(true) + // The operation runs in-process now. The payer travels as a value on the + // orchestration call rather than as a serialized header on an internal + // HTTP self-call back into this same process. + const call = perform.mock.calls[0][0] + expect(await call.resolveBillingAttribution()).toEqual(BILLING_ATTRIBUTION) + expect(call.source).toBe('agent') + expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + }) + + it('reports a failed knowledge base delete as failed, not as missing', async () => { + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + }) + mockPerformDeleteKnowledgeBase.mockResolvedValue({ + success: false, + error: 'Knowledge base is locked', + errorCode: 'conflict', + }) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + CONTEXT + ) + + // A knowledge base that exists but could not be archived is neither deleted + // nor missing — folding it into notFound told the user it was never there. + expect(result.data.notFound).toEqual([]) + expect(result.data.failed).toEqual([ + { id: 'knowledge-base-1', name: 'Paid KB', reason: 'Knowledge base is locked' }, + ]) + expect(result.message).toContain('Knowledge base is locked') + }) + + it('never relays an unclassified fault to the agent verbatim', async () => { + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + }) + mockPerformDeleteKnowledgeBase.mockResolvedValue({ + success: false, + error: 'select "id" from "knowledge_base" — connection terminated', + errorCode: 'internal', + }) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + CONTEXT + ) + + expect(result.data.failed[0].reason).toBe('Failed to delete knowledge base') + expect(result.message).not.toContain('connection terminated') + }) + + it('reports that a deleted connector kept its documents, because it did', async () => { + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete_connector', args: { connectorId: 'connector-1' } }, + CONTEXT + ) + + // The old wording claimed the documents "have been removed". They never + // were: the tool reached the route over HTTP with no query string, so the + // route's keep-documents default always applied. + expect(result.success).toBe(true) + expect(result.message).toContain('3 document(s) were kept') + expect(result.message).not.toContain('removed') + expect(mockPerformDeleteKnowledgeConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: 'connector-1', source: 'agent' }) + ) + }) }) describe('knowledge base add_file usage gate', () => { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index e86ef78fa2f..89f3e02867a 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -1,18 +1,16 @@ import { db } from '@sim/db' import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { filterUndefined } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' -import { generateInternalToken } from '@/lib/auth/internal' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, - BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, checkAttributedUsageLimits, - serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { @@ -20,25 +18,24 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { - createSingleDocument, - deleteDocument, - processDocumentAsync, - updateDocument, -} from '@/lib/knowledge/documents/service' + messageForOrchestrationError, + type OrchestrationErrorCode, +} from '@/lib/core/orchestration/types' +import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { - EMBEDDING_DIMENSIONS, - generateSearchEmbedding, - getConfiguredEmbeddingModel, - recordSearchEmbeddingUsage, -} from '@/lib/knowledge/embeddings' -import { - createKnowledgeBase, - deleteKnowledgeBase, - getKnowledgeBaseById, - updateKnowledgeBase, -} from '@/lib/knowledge/service' + performCreateKnowledgeBase, + performCreateKnowledgeConnector, + performDeleteKnowledgeBase, + performDeleteKnowledgeConnector, + performDeleteKnowledgeDocument, + performSyncKnowledgeConnector, + performUpdateKnowledgeBase, + performUpdateKnowledgeConnector, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, +} from '@/lib/knowledge/orchestration' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { createTagDefinition, deleteTagDefinition, @@ -50,6 +47,7 @@ import { } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getCredential } from '@/app/api/auth/oauth/utils' import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, @@ -73,6 +71,20 @@ function requireKnowledgeBillingAttribution( return attribution } +/** + * The message the agent — and therefore the user — is shown for a failed + * operation. Mirrors `messageForOrchestrationError` on the HTTP surfaces: a + * classified failure is caller-fixable and safe to relay, an unclassified one + * carries whatever text the fault happened to have (a driver's failed SQL, say) + * and is replaced by the operation's own wording. + */ +function agentFacingError( + outcome: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): string { + return messageForOrchestrationError(outcome, fallback) +} + type KnowledgeBaseArgs = { operation: string args?: Record @@ -109,6 +121,17 @@ export const knowledgeBaseServerTool: BaseServerTool ({ + userId: context.userId as string, + source: 'agent' as const, + requestId, + }) try { switch (operation) { @@ -129,29 +152,21 @@ export const knowledgeBaseServerTool: BaseServerTool { - logger.error('Background document processing failed', { - documentId: doc.id, - error: toError(err).message, - }) + startProcessing: 'async', + billingAttribution, }) + if (!outcome.success) { + failedFiles.push(fileRef) + continue + } - added.push({ documentId: doc.id, filename: fileRecord.name }) - - logger.info('Workspace file added to knowledge base via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - documentId: doc.id, - fileName: fileRecord.name, - userId: context.userId, - }) + added.push({ documentId: outcome.document.id, filename: fileRecord.name }) } const addedNames = added.map((a) => a.filename).join(', ') @@ -472,13 +471,20 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const notFound: string[] = [] + // A knowledge base that exists but could not be archived is neither + // deleted nor missing. Folding it into `notFound` told the user it was + // never there instead of why the delete failed. + const failed: Array<{ id: string; name: string; reason: string }> = [] for (const kbId of kbIds) { const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) @@ -521,23 +531,42 @@ export const knowledgeBaseServerTool: BaseServerTool 0 ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` : null, + failed.length > 0 + ? `Failed: ${failed.map((f) => `${f.name} (${f.reason})`).join(', ')}` + : null, + ] + .filter(Boolean) + .join('. ') + return { success: deleted.length > 0, - message: - deleted.length > 0 - ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` - : 'No knowledge bases found', - data: { deleted, notFound }, + message: deleteSummary || 'No knowledge bases found', + data: { deleted, notFound, failed }, } } @@ -568,8 +597,16 @@ export const knowledgeBaseServerTool: BaseServerTool = { - connectorType: args.connectorType, - sourceConfig: args.sourceConfig ?? {}, - syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, - } - - if (args.credentialId) { - createBody.credentialId = args.credentialId - } - if (args.apiKey) { - createBody.apiKey = args.apiKey - } - + const sourceConfig: Record = { ...(args.sourceConfig ?? {}) } if (args.disabledTagIds?.length) { - ;(createBody.sourceConfig as Record).disabledTagIds = - args.disabledTagIds + sourceConfig.disabledTagIds = args.disabledTagIds } + const requestId = generateId().slice(0, 8) assertNotAborted() - const createRes = await connectorApiCall( - context.userId, - `/api/knowledge/${args.knowledgeBaseId}/connectors`, - 'POST', - createBody, - billingAttribution - ) - - if (!createRes.success) { - return { success: false, message: createRes.error ?? 'Failed to create connector' } - } - - const connector = createRes.data - logger.info('Connector created via copilot', { - connectorId: connector.id, + const outcome = await performCreateKnowledgeConnector({ + ...actor(requestId), + knowledgeBase: { + id: args.knowledgeBaseId, + name: writeAccess.knowledgeBase.name, + workspaceId: connectorWorkspaceId, + }, connectorType: args.connectorType, - knowledgeBaseId: args.knowledgeBaseId, - userId: context.userId, + credentialId: args.credentialId, + apiKey: args.apiKey, + sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, + resolveBillingAttribution: async () => billingAttribution, + resolveAccessToken: async (credentialId) => + (await getCredential(requestId, credentialId, context.userId as string)) + ?.accessToken ?? null, }) + if (!outcome.success) { + return { success: false, message: agentFacingError(outcome, 'Failed to add connector') } + } + const connector = outcome.connector return { success: true, message: `Connector "${args.connectorType}" added to knowledge base. Initial sync started.`, data: { id: connector.id, - connectorType: connector.connectorType ?? connector.connector_type, + connectorType: connector.connectorType, status: connector.status, knowledgeBaseId: args.knowledgeBaseId, }, @@ -973,41 +1016,38 @@ export const knowledgeBaseServerTool: BaseServerTool = {} - if (args.sourceConfig !== undefined) updateBody.sourceConfig = args.sourceConfig - if (args.syncIntervalMinutes !== undefined) - updateBody.syncIntervalMinutes = args.syncIntervalMinutes - if (args.connectorStatus !== undefined) updateBody.status = args.connectorStatus - - if (Object.keys(updateBody).length === 0) { - return { - success: false, - message: - 'At least one of sourceConfig, syncIntervalMinutes, or connectorStatus is required', - } + const updates = { + sourceConfig: args.sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes, + status: args.connectorStatus, } + const requestId = generateId().slice(0, 8) assertNotAborted() - const updateRes = await connectorApiCall( - context.userId, - `/api/knowledge/${kbId}/connectors/${args.connectorId}`, - 'PATCH', - updateBody - ) - - if (!updateRes.success) { - return { success: false, message: updateRes.error ?? 'Failed to update connector' } - } - - logger.info('Connector updated via copilot', { + // No `validateSourceConfig`: the agent has no requesting identity to + // resolve the connector's OAuth token with, so a replacement config is + // stored unvalidated and the next sync reports any problem with it. + const outcome = await performUpdateKnowledgeConnector({ + ...actor(requestId), + knowledgeBase: { + id: kbId, + name: writeAccess.knowledgeBase.name, + workspaceId: writeAccess.knowledgeBase.workspaceId ?? null, + }, connectorId: args.connectorId, - userId: context.userId, + updates, }) + if (!outcome.success) { + return { + success: false, + message: agentFacingError(outcome, 'Failed to update connector'), + } + } return { success: true, message: 'Connector updated successfully', - data: { id: args.connectorId, ...updateBody }, + data: { id: args.connectorId, ...filterUndefined(updates) }, } } @@ -1026,26 +1066,39 @@ export const knowledgeBaseServerTool: BaseServerTool 0 + ? `Connector deleted successfully. Its ${outcome.documentsKept} document(s) were kept in the knowledge base.` + : 'Connector deleted successfully.', + data: { + id: args.connectorId, + documentsKept: outcome.documentsKept, + documentsDeleted: outcome.documentsDeleted, + }, } } @@ -1075,23 +1128,24 @@ export const knowledgeBaseServerTool: BaseServerTool billingAttribution, }) + if (!outcome.success) { + return { + success: false, + message: agentFacingError(outcome, 'Failed to sync connector'), + } + } return { success: true, @@ -1122,42 +1176,6 @@ export const knowledgeBaseServerTool: BaseServerTool, - billingAttribution?: BillingAttributionSnapshot -): Promise<{ success: boolean; data?: any; error?: string }> { - const token = await generateInternalToken(userId) - const baseUrl = getInternalApiBaseUrl() - - const res = await fetch(`${baseUrl}${path}`, { - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...(billingAttribution - ? { - [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), - } - : {}), - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) - - const json = await res.json().catch(() => ({})) - - if (!res.ok) { - return { - success: false, - error: json.error || `API returned ${res.status}`, - } - } - - return { success: true, data: json.data } -} - async function resolveKnowledgeBaseId(connectorId: string): Promise { const rows = await db .select({ knowledgeBaseId: knowledgeConnector.knowledgeBaseId }) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index adebd05bd7a..512401f65ea 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -137,10 +137,7 @@ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits, })) -import { - normalizeSelectOptionsInput, - userTableServerTool, -} from '@/lib/copilot/tools/server/table/user-table' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { encodeCursor } from '@/lib/table/rows/cursor' function buildTable(overrides: Partial = {}): TableDefinition { @@ -172,60 +169,6 @@ async function flushDetached(): Promise { await Promise.resolve() } -describe('normalizeSelectOptionsInput', () => { - it('generates a stable id for a bare-name string option', () => { - const [opt] = normalizeSelectOptionsInput(['Open']) ?? [] - expect(opt.name).toBe('Open') - expect(typeof opt.id).toBe('string') - expect(opt.id.length).toBeGreaterThan(0) - }) - - it('generates an id for an object option without one', () => { - const [opt] = normalizeSelectOptionsInput([{ name: 'Closed' }]) ?? [] - expect(opt.name).toBe('Closed') - expect(opt.id.length).toBeGreaterThan(0) - }) - - it('preserves an explicitly supplied id', () => { - const result = normalizeSelectOptionsInput([{ id: 'opt_keep', name: 'Open' }]) - expect(result).toEqual([{ id: 'opt_keep', name: 'Open' }]) - }) - - it('reuses the id of an existing option with the same name', () => { - // The agent re-sends options as bare names on every edit. Minting fresh ids - // would orphan every cell holding them — silently clearing the column. - const existing = [ - { id: 'opt_low', name: 'Low' }, - { id: 'opt_high', name: 'High' }, - ] - const result = normalizeSelectOptionsInput(['Low', 'Medium', 'High'], existing) ?? [] - - expect(result[0]).toEqual({ id: 'opt_low', name: 'Low' }) - expect(result[2]).toEqual({ id: 'opt_high', name: 'High' }) - // Only the genuinely new option gets a fresh id. - expect(result[1].name).toBe('Medium') - expect(result[1].id).not.toBe('opt_low') - expect(result[1].id).not.toBe('opt_high') - }) - - it('matches an existing option name case-insensitively', () => { - const result = normalizeSelectOptionsInput(['open'], [{ id: 'opt_open', name: 'Open' }]) ?? [] - expect(result[0].id).toBe('opt_open') - expect(result[0].name).toBe('open') - }) - - it('mints a fresh id when there is no existing column to match against', () => { - const result = normalizeSelectOptionsInput(['Open']) ?? [] - expect(result[0].id.length).toBeGreaterThan(0) - expect(result[0].name).toBe('Open') - }) - - it('returns undefined for a non-array (validation rejects it downstream)', () => { - expect(normalizeSelectOptionsInput(undefined)).toBeUndefined() - expect(normalizeSelectOptionsInput('Open')).toBeUndefined() - }) -}) - describe('userTableServerTool.import_file', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index dd81326fefc..4cdf2f37b10 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,7 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId, generateShortId } from '@sim/utils/id' +import { generateId } from '@sim/utils/id' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -10,7 +10,6 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' -import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, COLUMN_TYPES, @@ -27,23 +26,13 @@ import { validateMapping, } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' -import { - buildIdByName, - columnMatchesRef, - rowDataNameToId, - sortSpecNamesToIds, -} from '@/lib/table/column-keys' +import { buildIdByName, rowDataNameToId, sortSpecNamesToIds } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { columnTypeById } from '@/lib/table/column-types' import { addTableColumn, deleteColumn, deleteColumns, renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' @@ -51,6 +40,11 @@ import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/ev import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' +import { + performDeleteTable, + performRenameTable, + performUpdateTableColumn, +} from '@/lib/table/orchestration' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' @@ -67,13 +61,13 @@ import { updateRow, updateRowsByFilter, } from '@/lib/table/rows/service' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' import { predicateToStorage } from '@/lib/table/select-values' -import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service' +import { createTable, deleteTable, getTableById } from '@/lib/table/service' import type { ColumnDefinition, Filter, RowData, - SelectOption, SortSpec, TableDefinition, TableDeleteJobPayload, @@ -335,30 +329,6 @@ function limitError(limit: unknown): string | null { * cell data survives the update. Non-array input returns `undefined`, letting * downstream validation reject a malformed / missing option set. */ -export function normalizeSelectOptionsInput( - raw: unknown, - existing: SelectOption[] = [] -): SelectOption[] | undefined { - if (!Array.isArray(raw)) return undefined - // Cells reference the option id, so an edit that re-sends the same option by - // name must reuse its id — minting a fresh one would orphan every cell - // holding it, silently clearing the column. - const idByName = new Map() - for (const option of existing) { - const key = option.name.toLowerCase() - if (!idByName.has(key)) idByName.set(key, option.id) - } - const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() - - return raw.map((entry) => { - if (typeof entry === 'string') return { id: resolveId(entry), name: entry } - const e = (entry ?? {}) as { id?: unknown; name?: unknown } - const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') - const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) - return { id, name } - }) -} - /** Rewrites every `select` column's options in an agent-authored create schema. */ function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { if (!schema || !Array.isArray(schema.columns)) return schema @@ -524,13 +494,14 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - await deleteTable(tableId, requestId, context.userId) - captureServerEvent( - context.userId, - 'table_deleted', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) + const deleteOutcome = await performDeleteTable({ + table, + userId: context.userId, + requestId, + }) + if (!deleteOutcome.success) { + return { success: false, message: deleteOutcome.error ?? 'Failed to delete table' } + } deleted.push(tableId) } @@ -1698,118 +1669,39 @@ export const userTableServerTool: BaseServerTool message: `Invalid currency code "${currencyCode}". Use an ISO 4217 code, e.g. USD`, } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) - // The agent authors options by name; mint ids here, reusing the id of - // any option whose name already exists so its cells survive the edit. - const currentColumn = tableForUpdate.schema.columns.find((c) => - columnMatchesRef(c, colName) - ) - const existingOptions = currentColumn?.options ?? [] - const options = normalizeSelectOptionsInput(rawOptions, existingOptions) - // An agent restating the current type alongside new options must not - // go through `updateColumnType` — it early-returns on an unchanged - // type and would drop them. Mirrors the HTTP columns route. - const typeChanging = newType !== undefined && newType !== currentColumn?.type - let result: TableDefinition | undefined if (newType !== undefined && !(COLUMN_TYPES as readonly string[]).includes(newType)) { return { success: false, message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`, } } - // Each write below is its own locked transaction, so pairing any of - // them with a constraint write that is going to fail commits and then - // errors. Gate on the type the column ENDS UP with — an options-only - // update on an existing select column carries the same hazard as a - // conversion. Same guard the HTTP column routes apply. - const resultingType = newType ?? currentColumn?.type - if (uniqFlag === true && !columnTypeById(resultingType).supportsUnique) { - return { - success: false, - message: `Cannot set column "${colName}" as unique: ${resultingType} columns cannot be unique.`, - } - } - if (typeChanging) { - assertNotAborted() - result = await updateColumnType( - { - tableId: args.tableId, - columnName: colName, - newType: newType as (typeof COLUMN_TYPES)[number], - options, - multiple, - ...(currencyCode !== undefined ? { currencyCode } : {}), - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) - } else if (currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Mirrors the HTTP columns routes. - if (currentColumn?.type !== 'currency') { - return { - success: false, - message: `Column "${colName}" is not a currency column. Pass newType: "currency" with currencyCode to convert it.`, - } - } - assertNotAborted() - result = await updateColumnCurrency( - { - tableId: args.tableId, - columnName: colName, - currencyCode, - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) - } else if (options !== undefined || multiple !== undefined) { - // Editing an existing select column's option set / mode without a - // type change. `multiple` alone is a valid update — the catalog - // documents it as independent — so fall back to the column's current - // options rather than demanding the caller resend the whole list. - const nextOptions = options ?? existingOptions - if (nextOptions.length === 0) { - return { - success: false, - message: `Column "${colName}" is not a select column. Pass newType: "select" with options to convert it.`, - } - } - assertNotAborted() - result = await updateColumnOptions( - { - tableId: args.tableId, - columnName: colName, - options: nextOptions, - multiple, - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) + const tableForUpdate = await getTableById(args.tableId) + if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } } - // Skipped when a typed write ran: that write already applied and - // validated the constraint, in one transaction with the change it - // accompanies. Mirrors the HTTP columns routes. - if (uniqFlag !== undefined && result === undefined) { - assertNotAborted() - result = await updateColumnConstraints( - { tableId: args.tableId, columnName: colName, unique: uniqFlag }, - requestId - ) + assertNotAborted() + const outcome = await performUpdateTableColumn({ + table: tableForUpdate, + columnName: colName, + userId: context.userId, + updates: { + ...(newType !== undefined ? { type: newType as (typeof COLUMN_TYPES)[number] } : {}), + ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), + ...(rawOptions !== undefined ? { options: rawOptions } : {}), + ...(multiple !== undefined ? { multiple } : {}), + ...(currencyCode !== undefined ? { currencyCode } : {}), + }, + }) + if (!outcome.success || !outcome.table) { + return { success: false, message: outcome.error ?? 'Failed to update column' } } - if (result) signalTableSchemaChanged(args.tableId) + signalTableSchemaChanged(args.tableId) return { success: true, message: `Updated column "${colName}"`, - // A payload that only restates the current type is a no-op; still - // report the live schema rather than an undefined one. - data: { schema: (result ?? tableForUpdate).schema }, + data: { schema: outcome.table.schema }, } } - case 'rename': { if (!args.tableId) { return { success: false, message: 'Table ID is required' } @@ -1829,13 +1721,21 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - const renamed = await renameTable(args.tableId, newName, requestId, context.userId) + const renameOutcome = await performRenameTable({ + table, + newName, + userId: context.userId, + requestId, + }) + if (!renameOutcome.success) { + return { success: false, message: renameOutcome.error ?? 'Failed to rename table' } + } signalTableSchemaChanged(args.tableId) return { success: true, - message: `Renamed table to "${renamed.name}"`, - data: { table: { id: renamed.id, name: renamed.name } }, + message: `Renamed table to "${newName}"`, + data: { table: { id: args.tableId, name: newName } }, } } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a2decad9680..32ab565c8c0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -488,6 +488,7 @@ export const env = createEnv({ SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block) + V2_API: z.boolean().optional(), // Enable the /api/v2 HTTP surface (all v2 routes 404 when off) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2989f04bcf3..22c64e1708b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -104,6 +104,15 @@ const FEATURE_FLAGS = { 'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.', fallback: 'DEPLOY_AS_BLOCK', }, + 'v2-api': { + description: + 'Gate the whole /api/v2 HTTP surface (workflows incl. execute/executions, tables, logs, ' + + 'knowledge, files, audit-logs, billing). One check per request, immediately after auth: ' + + 'when off, every v2 route returns 404 as if the surface does not exist. The gate is keyed ' + + 'on userId only — it never reads workspace/org membership, so an ungated caller learns ' + + 'nothing beyond "no such route". Off-AppConfig falls back to V2_API.', + fallback: 'V2_API', + }, 'tables-v2-api': { description: 'Gate the v2 tables HTTP API — the public read API (GET /api/v2/tables, POST ' + diff --git a/apps/sim/lib/workflows/orchestration/types.test.ts b/apps/sim/lib/core/orchestration/types.test.ts similarity index 82% rename from apps/sim/lib/workflows/orchestration/types.test.ts rename to apps/sim/lib/core/orchestration/types.test.ts index 26be2502be4..af8104841a1 100644 --- a/apps/sim/lib/workflows/orchestration/types.test.ts +++ b/apps/sim/lib/core/orchestration/types.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' describe('statusForOrchestrationError', () => { it.each([ diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts new file mode 100644 index 00000000000..eb42af6bf65 --- /dev/null +++ b/apps/sim/lib/core/orchestration/types.ts @@ -0,0 +1,100 @@ +export type OrchestrationErrorCode = + | 'validation' + /** + * The credentials this operation depends on are no longer usable — a stored + * third-party token that will not refresh, not an unauthenticated caller. + * Distinct from `forbidden`, which is the caller lacking permission. + */ + | 'unauthorized' + | 'not_found' + | 'forbidden' + | 'conflict' + | 'locked' + | 'payload_too_large' + | 'internal' + +/** + * Transport-neutral failure classes returned by every `lib/[resource]/orchestration` + * module, so the UI routes, the public API, and the copilot tools map the same + * failure to the same status. + */ +export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { + if (code === 'validation') return 400 + if (code === 'unauthorized') return 401 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + if (code === 'locked') return 423 + if (code === 'payload_too_large') return 413 + return 500 +} + +/** + * The message a JSON route should render for an orchestration failure. + * + * A classified failure is caller-fixable, so its message is written for the + * caller and is safe to return. An unclassified one carries whatever text the + * fault happened to have — a driver's failed SQL, say — so the caller gets the + * route's own generic wording instead. `v2ErrorForOrchestration` applies the + * same rule for the v2 envelope. + */ +export function messageForOrchestrationError( + result: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): string { + if (!result.errorCode || result.errorCode === 'internal') return fallback + return result.error ?? fallback +} + +/** + * A domain failure that already knows its own class. + * + * Services throw this instead of a bare `Error` whenever the failure is + * caller-fixable, so the layers above classify by `instanceof` and read `code` + * rather than searching the message for a phrase. Message text is then free to + * be reworded, translated, or made more specific without silently changing the + * status every caller returns — the failure mode this replaced, where adding + * "already exists" to a message demoted a 409 to a 400. + * + * The code is transport-neutral on purpose: `statusForOrchestrationError` maps + * it for the UI and v1 routes, `v2ErrorForOrchestration` maps it to the v2 + * error vocabulary, and the copilot tools surface `message` with no status at + * all. An anything-else error stays unclassified and becomes a generic 500, + * which is what an unexpected fault should be. + */ +export class OrchestrationError extends Error { + constructor( + readonly code: OrchestrationErrorCode, + message: string + ) { + super(message) + this.name = 'OrchestrationError' + } +} + +/** + * The {@link OrchestrationError} in `error`'s cause chain, or `null` when the + * failure is not a classified one. + * + * Walks `cause` rather than testing `error` alone because drizzle wraps a throw + * raised inside a transaction callback in a `DrizzleQueryError` whose own + * message is the failed SQL — the same reason the message-matching this + * replaced had to dig for a root cause before it could classify anything. + */ +export function asOrchestrationError(error: unknown): OrchestrationError | null { + let current: unknown = error + while (current instanceof Error) { + if (current instanceof OrchestrationError) return current + current = current.cause + } + return null +} + +/** + * The slice of an HTTP request the audit log reads for client IP and user-agent + * capture. Optional on every orchestration function so the non-HTTP callers — + * copilot tools, background jobs — can omit what they do not have. + */ +export interface OrchestrationRequestContext { + headers: { get(name: string): string | null } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts new file mode 100644 index 00000000000..b3c2561e9a5 --- /dev/null +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -0,0 +1,592 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { account, credential, credentialMember } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' +import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import { + ServiceAccountSecretError, + verifyAndBuildServiceAccountSecret, +} from '@/lib/credentials/service-account-secret' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { getServiceConfigByProviderId } from '@/lib/oauth' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CredentialCreateOrchestration') + +/** + * Credential creation, shared by the session surface (`POST /api/credentials`) + * and the public API (`POST /api/v2/credentials`). + * + * Everything here was previously inline in the session route: the per-type + * source resolution, the existing-credential replay rules, the organization + * mutation locks and in-transaction re-authorization, and the audit. Callers + * render the outcome in their own envelope from `errorCode` / + * `providerErrorCode`. + */ + +type CredentialRow = typeof credential.$inferSelect +type CredentialType = CredentialRow['type'] +type DbOrTx = typeof db | Parameters[0]>[0] + +/** + * Raised by the in-transaction duplicate guard when a concurrent request slipped + * a row in between the outer existence check and the INSERT. + */ +class DuplicateCredentialError extends Error { + constructor() { + super('duplicate_display_name') + this.name = 'DuplicateCredentialError' + } +} + +export interface PerformCreateCredentialParams { + workspaceId: string + type: CredentialType + userId: string + actorName?: string | null + actorEmail?: string | null + displayName?: string + description?: string + providerId?: string + accountId?: string + envKey?: string + envOwnerUserId?: string + serviceAccountJson?: string + apiToken?: string + domain?: string + signingSecret?: string + botToken?: string + clientId?: string + clientSecret?: string + orgId?: string + dataCenter?: string + /** + * Client-supplied credential id, honored only for `slack-custom-bot`: the + * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before + * secrets exist, so the id must be known up front. + */ + id?: string + request?: NextRequest +} + +export interface PerformCreateCredentialResult { + success: boolean + error?: string + errorCode?: CredentialOrchestrationErrorCode + /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ + providerErrorCode?: string + /** A provider outage rather than a rejected secret — callers surface 502, not 400. */ + providerUnavailable?: boolean + credential?: CredentialRow + /** False when an existing credential matched the source and was returned instead. */ + created?: boolean +} + +interface ExistingCredentialSourceParams { + workspaceId: string + type: CredentialType + accountId?: string | null + envKey?: string | null + envOwnerUserId?: string | null + displayName?: string | null + providerId?: string | null +} + +/** + * Finds the credential that already occupies a source slot. Each type keys on a + * different tuple, matching the partial unique indexes on the table. + */ +async function findExistingCredentialBySourceWith( + exec: DbOrTx, + params: ExistingCredentialSourceParams +): Promise { + const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params + + if (type === 'oauth' && accountId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'oauth'), + eq(credential.accountId, accountId) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'env_workspace' && envKey) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + eq(credential.envKey, envKey) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'env_personal' && envKey && envOwnerUserId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envKey, envKey), + eq(credential.envOwnerUserId, envOwnerUserId) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'service_account' && displayName && providerId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, providerId), + eq(credential.displayName, displayName) + ) + ) + .limit(1) + return row ?? null + } + + return null +} + +function failure( + error: string, + errorCode: CredentialOrchestrationErrorCode, + extra: Partial = {} +): PerformCreateCredentialResult { + return { success: false, error, errorCode, ...extra } +} + +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const { workspaceId, type, userId } = params + + try { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.canWrite) { + return failure('Write permission required', 'forbidden') + } + + let resolvedDisplayName = params.displayName?.trim() ?? '' + const resolvedDescription = params.description?.trim() || null + let resolvedProviderId: string | null = params.providerId ?? null + let resolvedAccountId: string | null = params.accountId ?? null + const resolvedEnvKey: string | null = params.envKey + ? normalizeCredentialEnvKey(params.envKey) + : null + let resolvedEnvOwnerUserId: string | null = null + let resolvedEncryptedServiceAccountKey: string | null = null + const extraAuditMetadata: Record = {} + + if (type === 'oauth') { + const [accountRow] = await db + .select({ + id: account.id, + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where(eq(account.id, params.accountId!)) + .limit(1) + + if (!accountRow) return failure('OAuth account not found', 'not_found') + + if (accountRow.userId !== userId) { + return failure( + 'Only account owners can create oauth credentials for an account', + 'forbidden' + ) + } + + if (params.providerId !== accountRow.providerId) { + return failure('providerId does not match the selected OAuth account', 'validation') + } + if (!resolvedDisplayName) { + resolvedDisplayName = + getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId + } + } else if (type === 'service_account') { + try { + const secret = await verifyAndBuildServiceAccountSecret(params.providerId ?? '', { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + serviceAccountJson: params.serviceAccountJson, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, + dataCenter: params.dataCenter, + }) + resolvedProviderId = secret.providerId + resolvedAccountId = null + resolvedEnvOwnerUserId = null + if (!resolvedDisplayName) resolvedDisplayName = secret.displayName + resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey + Object.assign(extraAuditMetadata, secret.auditMetadata) + } catch (error) { + if (error instanceof ServiceAccountSecretError) { + return failure(error.message, 'validation') + } + throw error + } + } else if (type === 'env_personal') { + resolvedEnvOwnerUserId = params.envOwnerUserId ?? userId + if (resolvedEnvOwnerUserId !== userId) { + return failure( + 'Only the current user can create personal env credentials for themselves', + 'forbidden' + ) + } + resolvedProviderId = null + resolvedAccountId = null + resolvedDisplayName = resolvedEnvKey || '' + } else { + resolvedProviderId = null + resolvedAccountId = null + resolvedEnvOwnerUserId = null + resolvedDisplayName = resolvedEnvKey || '' + } + + if (!resolvedDisplayName) return failure('Display name is required', 'validation') + + const existingCredential = await findExistingCredentialBySourceWith(db, { + workspaceId, + type, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + + if (existingCredential) { + /** + * A retried custom-bot create with the SAME pre-generated id is an + * idempotent replay and falls through to the normal existing-credential + * path. Any other name collision must fail loudly: returning the existing + * row as success would orphan the new id already embedded in the user's + * Slack Request URL (Slack would post to a URL no credential resolves). + */ + if ( + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && + params.id && + existingCredential.id !== params.id + ) { + return failure( + `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + /** + * Token service-account creates always carry a fresh token that must be + * stored — falling through to the existing-credential path would return + * the old credential as success and silently drop the submitted token. + */ + if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + return failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + const access = await getCredentialActorContext(existingCredential.id, userId, { + workspaceAccess, + }) + + if (!access.member && !access.isAdmin) { + return failure('A credential with this source already exists in this workspace', 'conflict') + } + + const shouldUpdateDisplayName = + type === 'oauth' && + resolvedDisplayName && + resolvedDisplayName !== existingCredential.displayName + const shouldUpdateDescription = + params.description !== undefined && + (existingCredential.description ?? null) !== resolvedDescription + + if (access.isAdmin && (shouldUpdateDisplayName || shouldUpdateDescription)) { + await db + .update(credential) + .set({ + ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}), + ...(shouldUpdateDescription ? { description: resolvedDescription } : {}), + updatedAt: new Date(), + }) + .where(eq(credential.id, existingCredential.id)) + + const [updatedCredential] = await db + .select() + .from(credential) + .where(eq(credential.id, existingCredential.id)) + .limit(1) + + return { + success: true, + credential: updatedCredential ?? existingCredential, + created: false, + } + } + + return { success: true, credential: existingCredential, created: false } + } + + const now = new Date() + const credentialId = + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && params.id ? params.id : generateId() + + const creationResult = await db.transaction(async (tx) => { + /** + * Discover the organization lock scope inside this transaction, then + * acquire the same organization → user → membership locks as org + * removal/transfer and re-authorize from the transaction before writing. + * + * If this insert wins, transfer sees the new source-owned personal + * credential and blocks. If transfer wins, its permission/member cleanup + * is visible to the authoritative re-read below and the insert is refused. + */ + const plannedContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + }) + if (!plannedContext) return failure('Write permission required', 'forbidden') + + await acquireOrganizationUserMutationLocks(tx, { + userId, + organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], + }) + + const currentContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + forUpdate: true, + }) + if (!currentContext) return failure('Write permission required', 'forbidden') + if (currentContext.organizationId !== plannedContext.organizationId) { + return failure( + 'Workspace organization changed while creating the credential. Please retry.', + 'conflict' + ) + } + if (!currentContext.canWrite) return failure('Write permission required', 'forbidden') + + /** + * `service_account` has no DB-level unique index on (workspaceId, + * providerId, displayName), so re-check inside the tx. OAuth/env_* are + * guarded by partial unique indexes and fall through to the 23505 handler. + */ + if (type === 'service_account') { + const innerExisting = await findExistingCredentialBySourceWith(tx, { + workspaceId, + type, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + if (innerExisting) throw new DuplicateCredentialError() + } + + await tx.insert(credential).values({ + id: credentialId, + workspaceId, + type, + displayName: resolvedDisplayName, + description: resolvedDescription, + providerId: resolvedProviderId, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + + if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { + for (const memberUserId of currentContext.memberUserIds) { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId: memberUserId, + role: memberUserId === userId ? 'admin' : 'member', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + } else { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + + return { success: true as const } + }) + + if (!creationResult.success) return creationResult + + const [created] = await db + .select() + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + captureServerEvent( + userId, + 'credential_connected', + { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: resolvedDisplayName, + description: `Created ${type} credential "${resolvedDisplayName}"`, + metadata: { + // Provider metadata spreads first so this path's own keys stay + // authoritative and can never be shadowed, matching the update path. + ...extraAuditMetadata, + credentialType: type, + providerId: resolvedProviderId, + }, + request: params.request, + }) + + return { success: true, credential: created, created: true } + } catch (error: unknown) { + if (error instanceof AtlassianValidationError) { + logger.warn(`Atlassian credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) + } + if (error instanceof TokenServiceAccountValidationError) { + logger.warn(`Token service-account credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + // A provider outage is an infra failure, not a bad request. + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) + } + if (error instanceof DuplicateCredentialError) { + return failure('A credential with that name already exists in this workspace.', 'conflict', { + providerErrorCode: 'duplicate_display_name', + }) + } + + const pgCode = getPostgresErrorCode(error) + if (pgCode === '23505') { + return failure('A credential with this source already exists', 'conflict') + } + if (pgCode === '23503') { + return failure('Invalid credential reference or membership target', 'validation') + } + if (pgCode === '23514') { + return failure('Credential source data failed validation checks', 'validation') + } + + const errAsRecord = + typeof error === 'object' && error !== null ? (error as Record) : {} + logger.error('Credential create failure details', { + code: pgCode, + detail: errAsRecord.detail, + constraint: errAsRecord.constraint, + table: errAsRecord.table, + message: errAsRecord.message, + }) + logger.error('Failed to create credential', { error }) + return failure('Internal server error', 'internal') + } +} + +/** + * Provider error codes that mean the upstream service could not be reached, + * rather than that the caller's secret was rejected. Each provider family names + * its own — Atlassian raises `atlassian_unavailable`, the token service accounts + * raise `provider_unavailable` — and both must map to 503, not 400. Kept as one + * set so a new provider family is added in a single place instead of being + * missed on whichever call path nobody re-checked. + */ +const PROVIDER_OUTAGE_CODES = new Set(['provider_unavailable', 'atlassian_unavailable']) + +export function isProviderOutageCode(code: string | undefined): boolean { + return code !== undefined && PROVIDER_OUTAGE_CODES.has(code) +} + +/** HTTP status for a credential orchestration failure, shared by every route surface. */ +export function statusForCredentialOrchestrationError( + code: CredentialOrchestrationErrorCode | undefined, + options: { providerUnavailable?: boolean } = {} +): number { + if (options.providerUnavailable) return 502 + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 18e0cfb0d1d..efcaaf4f4e5 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -32,6 +32,7 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, })) vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ + CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: {}, isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, })) vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) @@ -277,6 +278,22 @@ describe('performUpdateCredential — service-account secret rotation', () => { ) }) + it('fails the reconnect when the stored secret cannot be decrypted', async () => { + mockCredential() + queueTableRows(schemaMock.credential, [{ key: 'stored-cipher' }]) + mockDecryptSecret.mockRejectedValue(new Error('decrypt failed')) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(result).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('surfaces a rebuild failure as a validation error and writes nothing', async () => { mockCredential() mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index b36ca844047..72cc3d20a1b 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -29,6 +29,14 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCreateCredentialResult, + performCreateCredential, + statusForCredentialOrchestrationError, +} from './credential-create' + /** * Google's stored blob is the raw GCP JSON key, whose own `type` discriminator * is `service_account`. @@ -49,25 +57,24 @@ const IDENTITY_DERIVED_DISPLAY_NAME_PROVIDERS: ReadonlySet = new Set([ ]) /** - * Read and decrypt a service-account credential's stored secret blob. Returns - * null on any failure - a blob that cannot be read must never block a - * reconnect; each caller degrades to the behaviour it had without the blob. + * Read and decrypt a service-account credential's stored secret blob. A + * credential without a stored key returns `null`; unreadable or malformed + * stored data throws so reconnect cannot silently discard existing metadata. */ async function readStoredSecretBlob(credentialId: string): Promise | null> { - try { - const rows = await db - .select({ key: credential.encryptedServiceAccountKey }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - const key = rows[0]?.key - if (!key) return null - const { decrypted } = await decryptSecret(key) - const blob: unknown = JSON.parse(decrypted) - return blob !== null && typeof blob === 'object' ? (blob as Record) : null - } catch { - return null + const rows = await db + .select({ key: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + const key = rows[0]?.key + if (!key) return null + const { decrypted } = await decryptSecret(key) + const blob: unknown = JSON.parse(decrypted) + if (blob === null || typeof blob !== 'object' || Array.isArray(blob)) { + throw new Error('Stored credential secret must be a JSON object') } + return blob as Record } /** diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts new file mode 100644 index 00000000000..de0dac667d6 --- /dev/null +++ b/apps/sim/lib/credentials/queries.ts @@ -0,0 +1,147 @@ +import { db } from '@sim/db' +import { credential, credentialMember } from '@sim/db/schema' +import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' +import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Workspace-scoped credential reads shared by the session surface and the public + * API, so the visibility rules cannot drift between them. + */ + +export type CredentialRow = typeof credential.$inferSelect + +export interface VisibleWorkspaceCredential { + id: string + workspaceId: string + type: CredentialRow['type'] + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + envOwnerUserId: string | null + createdBy: string + createdAt: Date + updatedAt: Date + hasServiceAccountKey: boolean + role: 'admin' | 'member' +} + +/** + * The credentials a user may see in a workspace. + * + * Visibility is an explicit `credential_member` row, plus — for workspace + * admins — every shared-type credential, plus the caller's own personal env + * credentials. Encrypted secret material is never selected. + */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so credentials sharing a display name + * or a timestamp still come back in a stable order. + */ +const CREDENTIAL_SORTS = { + displayName: [credential.displayName, credential.id], + createdAt: [credential.createdAt, credential.id], + updatedAt: [credential.updatedAt, credential.id], +} satisfies Record + +export async function listVisibleWorkspaceCredentials(params: { + workspaceId: string + userId: string + workspaceAccess: Pick + type?: WorkspaceCredentialType + providerId?: string + /** Case-insensitive substring match on the credential display name. */ + search?: string + sortBy?: V2CredentialSortBy + sortOrder?: V2SortOrder +}): Promise { + const { + workspaceId, + userId, + workspaceAccess, + type, + providerId, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = params + + const whereClauses = [eq(credential.workspaceId, workspaceId)] + if (type) whereClauses.push(eq(credential.type, type)) + if (providerId) whereClauses.push(eq(credential.providerId, providerId)) + + const isWorkspaceAdmin = workspaceAccess.canAdmin + const accessClause = isWorkspaceAdmin + ? or( + isNotNull(credentialMember.id), + inArray(credential.type, SHARED_CREDENTIAL_TYPES), + eq(credential.envOwnerUserId, userId) + ) + : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, userId)) + + const rows = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + description: credential.description, + providerId: credential.providerId, + accountId: credential.accountId, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + createdBy: credential.createdBy, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + memberRole: credentialMember.role, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active') + ) + ) + .where(and(...whereClauses, accessClause, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) + + return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ + ...rest, + hasServiceAccountKey: Boolean(encryptedServiceAccountKey), + role: + // An `env_personal` credential's own env owner administers it regardless of + // workspace role — otherwise the owner of a personal secret can't manage it. + (rest.type === 'env_personal' && rest.envOwnerUserId === userId) || + (isWorkspaceAdmin && isSharedCredentialType(rest.type)) + ? 'admin' + : (memberRole ?? 'member'), + })) +} + +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and(eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId)) + ) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts new file mode 100644 index 00000000000..8a1b35af297 --- /dev/null +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -0,0 +1,296 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { and, eq } from 'drizzle-orm' +import { + type ExecutionCancellationRecordResult, + markExecutionCancelled, +} from '@/lib/execution/cancellation' +import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' +import { abortManualExecution } from '@/lib/execution/manual-cancellation' +import { captureServerEvent } from '@/lib/posthog/server' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' + +const logger = createLogger('CancelWorkflowExecution') +const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 +const PAUSED_CANCELLATION_DB_RETRY_MS = 200 + +/** + * Cancellation outcome vocabulary. `recorded`/`redis_unavailable`/ + * `redis_write_failed` come from the Redis record step; the two `paused_*` + * values from the paused-HITL path. + */ +export type CancelWorkflowExecutionReason = + | 'recorded' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' + +export interface CancelWorkflowExecutionResult { + success: boolean + executionId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: CancelWorkflowExecutionReason +} + +async function completePausedCancellationWithRetry( + executionId: string, + workflowId: string +): Promise { + for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { + try { + const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) + if (cancelled) { + logger.info('Paused execution cancelled in database', { executionId, attempt }) + return true + } + logger.warn('Paused execution cancellation could not be completed in database', { + executionId, + attempt, + }) + return false + } catch (error) { + logger.warn('Failed to complete paused execution cancellation in database', { + executionId, + attempt, + error, + }) + if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) { + await sleep(PAUSED_CANCELLATION_DB_RETRY_MS) + } + } + } + return false +} + +async function ensurePausedCancellationEventPublished( + executionId: string, + workflowId: string, + context: { workspaceId?: string; userId?: string } = {} +): Promise { + const metaState = await readExecutionMetaState(executionId) + if (metaState.status === 'found' && metaState.meta.status === 'cancelled') { + return true + } + + const writer = createExecutionEventWriter(executionId, { + workspaceId: context.workspaceId, + workflowId, + userId: context.userId, + }) + try { + await writer.writeTerminal( + { + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { duration: 0 }, + }, + 'cancelled' + ) + return true + } catch (error) { + logger.warn('Failed to publish paused execution cancellation event', { + executionId, + error, + }) + return false + } finally { + await writer.close().catch((error) => { + logger.warn('Failed to close paused cancellation event writer', { + executionId, + error, + }) + }) + } +} + +export interface CancelWorkflowExecutionInput { + executionId: string + workflowId: string + /** Actor for the analytics event. */ + userId: string + /** Workflow's workspace; feeds the event writer + analytics grouping. */ + workspaceId?: string +} + +/** + * Cancels a workflow execution across the Redis abort record, the in-process + * aborter, and the paused-HITL machinery. The interleaving is order-sensitive + * and shared verbatim by the v1 and v2 cancel routes. Auth is the caller's + * responsibility; this throws on unexpected infrastructure errors. + */ +export async function cancelWorkflowExecution( + input: CancelWorkflowExecutionInput +): Promise { + const { executionId, workflowId, userId, workspaceId } = input + + let pausedCancellationStarted = false + let pausedCancelled = false + try { + pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( + executionId, + workflowId + ) + } catch (error) { + logger.warn('Failed to begin paused execution cancellation in database', { + executionId, + error, + }) + } + const pendingPausedCancellation = pausedCancellationStarted + ? null + : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) + const isPausedCancellationPath = pausedCancellationStarted || pendingPausedCancellation !== null + + const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath + ? { durablyRecorded: false, reason: 'redis_unavailable' } + : await markExecutionCancelled(executionId) + const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) + + if (pausedCancellationStarted) { + logger.info('Paused execution cancellation reserved in database', { executionId }) + } else if (cancellation.durablyRecorded) { + logger.info('Execution marked as cancelled in Redis', { executionId }) + } else if (locallyAborted) { + logger.info('Execution cancelled via local in-process fallback', { executionId }) + } else if (!pausedCancellationStarted) { + logger.warn('Execution cancellation was not durably recorded', { + executionId, + reason: cancellation.reason, + }) + } + + if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { + await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to block queued paused resumes after cancellation', { + executionId, + error, + }) + } + ) + } else if (!isPausedCancellationPath) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after unsuccessful cancellation', { + executionId, + error, + }) + } + ) + } + + let pausedCancellationPublished = false + let pausedCancellationPublishFailed = false + if (pausedCancellationStarted) { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } else { + if (pendingPausedCancellation === 'cancelled') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + pausedCancelled = pausedCancellationPublished + } else if (pendingPausedCancellation === 'cancelling') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } + } + + if ( + pausedCancellationPublishFailed && + (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') + ) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after publish failure', { + executionId, + error, + }) + } + ) + } + + if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { + try { + await db + .update(workflowExecutionLogs) + .set({ status: 'cancelled', endedAt: new Date() }) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.status, 'running') + ) + ) + } catch (dbError) { + logger.warn('Failed to update execution log status directly', { + executionId, + error: dbError, + }) + } + } + + const success = + (isPausedCancellationPath + ? pausedCancelled && pausedCancellationPublished + : cancellation.durablyRecorded) || locallyAborted + + if (success) { + captureServerEvent( + userId, + 'workflow_execution_cancelled', + { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + + const durablyRecorded = isPausedCancellationPath + ? pausedCancellationPublished + : pausedCancelled || cancellation.durablyRecorded + const reason: CancelWorkflowExecutionReason = pausedCancellationPublishFailed + ? 'paused_event_publish_failed' + : !pausedCancelled && isPausedCancellationPath + ? 'paused_database_cancel_failed' + : pausedCancelled && !pausedCancellationPublished + ? 'paused_event_publish_failed' + : pausedCancelled || isPausedCancellationPath + ? 'recorded' + : cancellation.reason + + return { + success, + executionId, + redisAvailable: + isPausedCancellationPath || pausedCancelled + ? pausedCancellationPublished + : cancellation.reason !== 'redis_unavailable', + durablyRecorded, + locallyAborted, + pausedCancelled, + reason, + } +} diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 7b74e1ca1d8..8c1852aad1f 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -56,6 +56,12 @@ export interface PreprocessExecutionOptions { requestId: string checkRateLimit?: boolean + /** + * Which execution token bucket the rate-limit gate debits. Ignored when + * `checkRateLimit` is false. Defaults to `'sync'` — the historical behavior + * for every surface, including async-queued v1 runs. + */ + rateLimitCounter?: 'sync' | 'async' checkDeployment?: boolean skipUsageLimits?: boolean /** @@ -91,6 +97,8 @@ export interface PreprocessExecutionError { statusCode: number code?: string retryable?: boolean + /** Populated on rate-limit denials so callers can emit `Retry-After`. */ + retryAfterMs?: number cause?: Record } @@ -127,6 +135,7 @@ export async function preprocessExecution( reservationId = executionId, requestId, checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat', + rateLimitCounter = 'sync', checkDeployment = triggerType !== 'manual', skipUsageLimits = false, skipConcurrencyReservation = false, @@ -569,7 +578,7 @@ export async function preprocessExecution( actorUserId, actorSubscription, triggerType, - false + rateLimitCounter === 'async' ) if (!info.allowed) { @@ -585,6 +594,13 @@ export async function preprocessExecution( error: { message: `Rate limit exceeded. Please try again later.`, statusCode: 429, + /** + * Distinguishes quota exhaustion from the concurrency-slot 429 + * (`EXECUTION_CONCURRENCY_LIMIT`, retryable in seconds) — the two + * need different caller behavior. + */ + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: info.retryAfterMs ?? Math.max(0, info.resetAt.getTime() - Date.now()), }, }, recordError: { diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index 02e803f5749..8dea1441f48 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -57,7 +57,7 @@ export interface FolderDeleteRejection { /** * Everything that differs between the four folder-bearing resource types, expressed as - * data. The folder engine in `lib/folders/lifecycle.ts` and the cascade in + * data. The folder engine in `lib/folders/orchestration.ts` and the cascade in * `lib/folders/cascade.ts` read this instead of branching on `resourceType`, so * create/update/delete/restore/reorder each exist exactly once and adding a fifth * foldered resource means adding one entry here. @@ -304,7 +304,7 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise { + await tx.execute( + sql`select set_config('lock_timeout', ${`${FOLDER_MUTATION_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`resource_folders:${resourceType}:${workspaceId}`}, 0))` + ) +} + +/** + * Keeps path resolution stable while a resource mutation commits against the + * resolved folder. The callback may use `tx` for reads; folder writers for the + * same workspace and resource type cannot proceed until it returns. + */ +export async function withFolderTreeLock( + workspaceId: string, + resourceType: FolderResourceType, + operation: (tx: DbOrTx) => Promise +): Promise { + return db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) + return operation(tx) + }) +} diff --git a/apps/sim/lib/folders/lifecycle.test.ts b/apps/sim/lib/folders/orchestration.test.ts similarity index 88% rename from apps/sim/lib/folders/lifecycle.test.ts rename to apps/sim/lib/folders/orchestration.test.ts index 8da406145db..1a3d1c9fdb2 100644 --- a/apps/sim/lib/folders/lifecycle.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -23,6 +23,7 @@ const { mockRestoreFolderChildren, mockRestoreFolderRows, mockWouldCreateFolderCycle, + mockLoadActiveFolderPathIndex, resourceConfig, } = vi.hoisted(() => ({ mockArchiveFolderCascade: vi.fn(), @@ -35,6 +36,7 @@ const { mockRestoreFolderChildren: vi.fn(), mockRestoreFolderRows: vi.fn(), mockWouldCreateFolderCycle: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), resourceConfig: { current: {} as Record }, })) @@ -58,13 +60,24 @@ vi.mock('@/lib/folders/config', () => ({ vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName })) -vi.mock('@/lib/folders/queries', () => ({ wouldCreateFolderCycle: mockWouldCreateFolderCycle })) +vi.mock('@/lib/folders/queries', () => ({ + wouldCreateFolderCycle: mockWouldCreateFolderCycle, + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' +import { + createFolder, + createFolderAtPath, + deleteFolder, + deleteFolderByPath, + relocateFolderByPath, + restoreFolder, + updateFolder, +} from '@/lib/folders/orchestration' const CHILD_TABLE = { name: 'child_table' } @@ -124,6 +137,11 @@ beforeEach(() => { resetDbChainMock() setConfig() mockWouldCreateFolderCycle.mockResolvedValue(false) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) mockDeduplicateFolderName.mockImplementation( async (_tx: unknown, _ws: string, _parent: string | null, name: string) => name ) @@ -312,6 +330,87 @@ describe('createFolder', () => { }) }) +describe('path-owned folder mutations', () => { + it('creates only the addressed leaf under an existing canonical parent path', async () => { + const parent = folderRow({ id: 'parent-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['parent-1', parent]]), + pathById: new Map([['parent-1', '/Reports']]), + idByPath: new Map([['/Reports', 'parent-1']]), + }) + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + folderRow({ id: 'folder-2', name: 'Q1', parentId: 'parent-1', sortOrder: -1 }), + ]) + + const result = await createFolderAtPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports/Q1', + }) + + expect(result).toMatchObject({ success: true, path: '/Reports/Q1' }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Q1', parentId: 'parent-1' }) + ) + }) + + it('rejects relocating a folder beneath its own descendant before writing', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + destinationPath: '/Reports/Archive', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('requires recursive deletion when the path has descendant folders', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + const child = folderRow({ id: 'folder-2', name: 'Q1', parentId: 'folder-1' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([ + ['folder-1', source], + ['folder-2', child], + ]), + pathById: new Map([ + ['folder-1', '/Reports'], + ['folder-2', '/Reports/Q1'], + ]), + idByPath: new Map([ + ['/Reports', 'folder-1'], + ['/Reports/Q1', 'folder-2'], + ]), + }) + + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + recursive: false, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder is not empty', + errorCode: 'conflict', + }) + expect(mockArchiveFolderCascade).not.toHaveBeenCalled() + }) +}) + describe('updateFolder', () => { const baseUpdate = { resourceType: 'table' as const, diff --git a/apps/sim/lib/folders/lifecycle.ts b/apps/sim/lib/folders/orchestration.ts similarity index 57% rename from apps/sim/lib/folders/lifecycle.ts rename to apps/sim/lib/folders/orchestration.ts index a3984f7fe8a..0514a46a31a 100644 --- a/apps/sim/lib/folders/lifecycle.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -2,10 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { withTransactionRetry } from '@/lib/db/transaction' import type { DbOrTx } from '@/lib/db/types' import { archiveFolderCascade, @@ -16,13 +18,19 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { folderResourceConfig } from '@/lib/folders/config' +import { acquireFolderMutationLock, withFolderTreeLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' -import { wouldCreateFolderCycle } from '@/lib/folders/queries' +import { + type FolderPathIndex, + folderNameFromPath, + parentFolderPath, + requireNonRootFolderPath, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, wouldCreateFolderCycle } from '@/lib/folders/queries' import type { FolderMutationErrorCode } from '@/lib/folders/status' import { notifyFolderResourceChanged } from '@/lib/realtime/notify' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' -const logger = createLogger('FolderLifecycle') +const logger = createLogger('FolderOrchestration') const DUPLICATE_NAME_ERROR = 'A folder with this name already exists in this location' @@ -60,6 +68,7 @@ export interface DeleteFolderParams { workspaceId: string userId: string folderName?: string + folderPath?: string } export interface DeleteFolderResult { @@ -84,6 +93,271 @@ export interface RestoreFolderResult { restoredItems?: FolderCascadeCountsApi } +export interface FolderPathMutationResult extends FolderMutationResult { + path?: string +} + +export interface DeleteFolderByPathParams { + resourceType: FolderResourceType + workspaceId: string + userId: string + path: string + recursive: boolean +} + +export interface DeleteFolderByPathResult extends DeleteFolderResult { + path?: string +} + +function validatePathLeafName(path: string): string { + const name = folderNameFromPath(path) + if (name.trim() !== name || name.length === 0 || name.length > 255) { + throw new Error('Folder path leaf must be between 1 and 255 characters without outer spaces') + } + return name +} + +function resolveRequiredFolderId(index: FolderPathIndex, path: string): string { + const folderId = index.idByPath.get(path) + if (!folderId) throw new Error('Folder not found') + return folderId +} + +function isEffectivelyLocked(index: FolderPathIndex, id: string) { + let currentId: string | null = id + while (currentId) { + const row = index.rowById.get(currentId) + if (!row) throw new Error('Folder hierarchy references a missing ancestor') + if (row.locked) return true + currentId = row.parentId + } + return false +} + +function pathMutationError(error: unknown): FolderPathMutationResult { + const message = getErrorMessage(error, 'Internal server error') + if (message === 'Folder not found' || message === 'Parent folder not found') { + return { success: false, error: message, errorCode: 'not_found' } + } + if ( + message === DUPLICATE_NAME_ERROR || + message === 'Folder is not empty' || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message === 'Folder is locked') { + return { success: false, error: message, errorCode: 'locked' } + } + if ( + message.includes('Folder path') || + message.includes('root path') || + message.includes('descendant') || + message.includes('canonical folder path') + ) { + return { success: false, error: message, errorCode: 'validation' } + } + logger.error('Folder path mutation failed', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } +} + +/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ +export async function createFolderAtPath( + params: Omit & { path: string } +): Promise { + try { + requireNonRootFolderPath(params.path) + const name = validatePathLeafName(params.path) + const folder = await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + if (index.idByPath.has(params.path)) throw new Error(DUPLICATE_NAME_ERROR) + + const parentPath = parentFolderPath(params.path) + const parentId = parentPath === '/' ? null : index.idByPath.get(parentPath) + if (parentPath !== '/' && !parentId) throw new Error('Parent folder not found') + if ( + parentId && + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, parentId) + ) { + throw new Error('Folder is locked') + } + + const sortOrder = await nextFolderSortOrder( + params.resourceType, + params.workspaceId, + parentId, + tx + ) + const [created] = await tx + .insert(folderTable) + .values({ + id: generateId(), + resourceType: params.resourceType, + name, + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return created + }, + { label: 'create-folder-at-path' } + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, + metadata: { path: params.path, folderResourceType: params.resourceType }, + }) + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + return { success: true, folder, path: params.path } + } catch (error) { + return pathMutationError(error) + } +} + +/** Renames, moves, or both by replacing one canonical path with another. */ +export async function relocateFolderByPath(params: { + resourceType: FolderResourceType + workspaceId: string + userId: string + path: string + destinationPath: string +}): Promise { + try { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + const name = validatePathLeafName(params.destinationPath) + + const folder = await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if (index.idByPath.has(params.destinationPath)) throw new Error(DUPLICATE_NAME_ERROR) + + const destinationParentPath = parentFolderPath(params.destinationPath) + if ( + destinationParentPath === params.path || + destinationParentPath.startsWith(`${params.path}/`) + ) { + throw new Error('Cannot move a folder into one of its descendants') + } + const resolvedParentId = + destinationParentPath === '/' ? null : index.idByPath.get(destinationParentPath) + if (resolvedParentId === undefined) throw new Error('Parent folder not found') + const parentId = resolvedParentId + + const config = folderResourceConfig(params.resourceType) + if ( + config.supportsLocking && + (isEffectivelyLocked(index, folderId) || + (parentId !== null && isEffectivelyLocked(index, parentId))) + ) { + throw new Error('Folder is locked') + } + + const [updated] = await tx + .update(folderTable) + .set({ name, parentId, updatedAt: new Date() }) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + if (!updated) throw new Error('Folder not found') + return updated + }, + { label: 'relocate-folder-by-path' } + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, + metadata: { + sourcePath: params.path, + destinationPath: params.destinationPath, + folderResourceType: params.resourceType, + }, + }) + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + return { success: true, folder, path: params.destinationPath } + } catch (error) { + return pathMutationError(error) + } +} + +/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ +export async function deleteFolderByPath( + params: DeleteFolderByPathParams +): Promise { + try { + requireNonRootFolderPath(params.path) + const result = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if ( + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, folderId) + ) { + throw new Error('Folder is locked') + } + + if (!params.recursive) { + const hasChildFolder = [...index.pathById.values()].some((candidate) => + candidate.startsWith(`${params.path}/`) + ) + const config = folderResourceConfig(params.resourceType) + const [child] = await tx + .select({ id: config.idColumn }) + .from(config.table) + .where( + and( + eq(config.folderIdColumn, folderId), + eq(config.workspaceColumn, params.workspaceId), + isNull(config.deletedColumn), + config.scope + ) + ) + .limit(1) + if (hasChildFolder || child) throw new Error('Folder is not empty') + } + + const row = index.rowById.get(folderId) + if (!row) throw new Error('Folder not found') + return deleteFolderWithoutTreeLock({ + resourceType: params.resourceType, + folderId, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: row.name, + folderPath: params.path, + }) + }) + return { ...result, path: result.success ? params.path : undefined } + } catch (error) { + return pathMutationError(error) + } +} + /** * Verifies that a prospective parent folder exists, belongs to the target workspace, is of * the same `resourceType`, and is not archived. @@ -95,9 +369,10 @@ export interface RestoreFolderResult { async function assertParentFolderInWorkspace( resourceType: FolderResourceType, parentId: string, - workspaceId: string + workspaceId: string, + tx: DbOrTx = db ): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { - const [parent] = await db + const [parent] = await tx .select({ workspaceId: folderTable.workspaceId, archivedAt: folderTable.deletedAt, @@ -177,35 +452,46 @@ export async function createFolder(params: CreateFolderParams): Promise { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + if (parentId) { + const parentError = await assertParentFolderInWorkspace( + params.resourceType, + parentId, + params.workspaceId, + tx + ) + if (parentError) return { parentError } + } - const [folder] = await db - .insert(folderTable) - .values({ - id: folderId, - resourceType: params.resourceType, - name: params.name.trim(), - userId: params.userId, - workspaceId: params.workspaceId, - parentId, - sortOrder, - }) - .returning() + const sortOrder = + params.sortOrder !== undefined + ? params.sortOrder + : await nextFolderSortOrder(params.resourceType, params.workspaceId, parentId, tx) + + const [folder] = await tx + .insert(folderTable) + .values({ + id: folderId, + resourceType: params.resourceType, + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return { folder } + }, + { label: 'create-folder' } + ) + if ('parentError' in outcome) return { success: false, ...outcome.parentError } + const { folder } = outcome logger.info('Created folder', { folderId, @@ -255,28 +541,6 @@ export async function updateFolder(params: UpdateFolderParams): Promise`: the loose type is what // let `color`/`isExpanded` survive an earlier cutover after the create path dropped them. const updates: Partial = { updatedAt: new Date() } @@ -294,18 +558,52 @@ export async function updateFolder(params: UpdateFolderParams): Promise { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + if (params.parentId) { + const parentError = await assertParentFolderInWorkspace( + params.resourceType, + params.parentId, + params.workspaceId, + tx + ) + if (parentError) return { parentError } + + const wouldCreateCycle = await wouldCreateFolderCycle( + params.folderId, + params.parentId, + params.resourceType, + tx + ) + if (wouldCreateCycle) { + return { + parentError: { + error: 'Cannot create circular folder reference', + errorCode: 'validation' as const, + }, + } + } + } + + const [folder] = await tx + .update(folderTable) + .set(updates) + .where( + and( + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + return { folder } + }, + { label: 'update-folder' } + ) + if ('parentError' in outcome) return { success: false, ...outcome.parentError } + const { folder } = outcome if (!folder) { return { success: false, error: 'Folder not found', errorCode: 'not_found' } @@ -340,7 +638,15 @@ export async function updateFolder(params: UpdateFolderParams): Promise { - const { resourceType, folderId, workspaceId, userId, folderName } = params + return withFolderTreeLock(params.workspaceId, params.resourceType, () => + deleteFolderWithoutTreeLock(params) + ) +} + +async function deleteFolderWithoutTreeLock( + params: DeleteFolderParams +): Promise { + const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) const [existing] = await db @@ -387,9 +693,10 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { +async function restoreFolderWithoutTreeLock( + params: RestoreFolderParams +): Promise { const { resourceType, folderId, workspaceId, userId, folderName } = params const config = folderResourceConfig(resourceType) @@ -563,3 +872,10 @@ export async function restoreFolder(params: RestoreFolderParams): Promise { + return withFolderTreeLock(params.workspaceId, params.resourceType, () => + restoreFolderWithoutTreeLock(params) + ) +} diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts new file mode 100644 index 00000000000..0048e480251 --- /dev/null +++ b/apps/sim/lib/folders/paths.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + v2CreateFolderBodySchema, + v2DeleteFolderQuerySchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, +} from '@/lib/api/contracts/v2/shared' +import { + buildFolderPath, + buildFolderPathIndex, + encodeFolderPathSegment, + MAX_FOLDER_PATH_SEGMENTS, + parseFolderPath, + ROOT_FOLDER_PATH, +} from '@/lib/folders/paths' + +describe('canonical folder paths', () => { + it('round-trips exact names without case or Unicode normalization', () => { + const segments = ['Reports', 'Q1 / 100%', 'é', '.', '..'] + const path = buildFolderPath(segments) + + expect(path).toBe('/Reports/Q1%20%2F%20100%25/e%CC%81/%2E/%2E%2E') + expect(parseFolderPath(path)).toEqual(segments) + expect(buildFolderPath([])).toBe(ROOT_FOLDER_PATH) + expect(encodeFolderPathSegment('é')).not.toBe(encodeFolderPathSegment('é')) + }) + + it.each([ + '', + 'Reports', + '/Reports/', + '/Reports//Q1', + '/Reports Q1', + '/R%C3%A9sum%c3%a9', + '/%52eports', + '/.', + '/..', + '/%E0%A4%A', + ])('rejects noncanonical path %s', (path) => { + expect(() => parseFolderPath(path)).toThrow() + }) + + it('builds a bidirectional index and rejects corrupt hierarchies', () => { + const rows = [ + { id: 'a', name: 'Reports', parentId: null }, + { id: 'b', name: 'Q1', parentId: 'a' }, + ] + const index = buildFolderPathIndex(rows) + + expect(index.pathById.get('b')).toBe('/Reports/Q1') + expect(index.idByPath.get('/Reports/Q1')).toBe('b') + expect( + buildFolderPathIndex([ + { id: 'upper', name: 'Reports', parentId: null }, + { id: 'lower', name: 'reports', parentId: null }, + ]).idByPath + ).toEqual( + new Map([ + ['/Reports', 'upper'], + ['/reports', 'lower'], + ]) + ) + expect(() => + buildFolderPathIndex([ + { id: 'a', name: 'Reports', parentId: null }, + { id: 'b', name: 'Reports', parentId: null }, + ]) + ).toThrow('duplicate path') + expect(() => buildFolderPathIndex([{ id: 'a', name: 'Reports', parentId: 'missing' }])).toThrow( + 'missing folder' + ) + expect(() => + buildFolderPathIndex([ + { id: 'a', name: 'A', parentId: 'b' }, + { id: 'b', name: 'B', parentId: 'a' }, + ]) + ).toThrow('cycle') + }) + + it('enforces segment and byte limits', () => { + expect(() => + buildFolderPath(Array.from({ length: MAX_FOLDER_PATH_SEGMENTS + 1 }, () => 'x')) + ).toThrow('segments') + expect(() => buildFolderPath(['x'.repeat(4096)])).toThrow('bytes') + }) + + it('keeps public folder mutations path-only and rejects the virtual root', () => { + expect(v2ListFoldersQuerySchema.parse({ workspaceId: 'workspace-1', parentPath: '/' })).toEqual( + { + workspaceId: 'workspace-1', + parentPath: '/', + sortBy: 'name', + sortOrder: 'asc', + } + ) + expect( + v2CreateFolderBodySchema.safeParse({ workspaceId: 'workspace-1', path: '/' }).success + ).toBe(false) + expect( + v2CreateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/Reports', + folderId: 'internal-id', + }).success + ).toBe(false) + expect( + v2RelocateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/Reports', + destinationPath: '/Reports', + }).success + ).toBe(false) + expect( + v2DeleteFolderQuerySchema.parse({ + workspaceId: 'workspace-1', + path: '/Reports', + recursive: 'true', + }).recursive + ).toBe(true) + expect( + v2DeleteFolderQuerySchema.safeParse({ workspaceId: 'workspace-1', path: '/Reports' }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts new file mode 100644 index 00000000000..9a8524e6f42 --- /dev/null +++ b/apps/sim/lib/folders/paths.ts @@ -0,0 +1,184 @@ +import type { folder } from '@sim/db/schema' + +export const ROOT_FOLDER_PATH = '/' +export const MAX_FOLDER_PATH_SEGMENTS = 64 +export const MAX_FOLDER_PATH_BYTES = 4096 + +type FolderPathRow = Pick + +export class FolderPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'FolderPathError' + } +} + +export interface FolderPathIndex { + rowById: ReadonlyMap + pathById: ReadonlyMap + idByPath: ReadonlyMap +} + +export interface FolderPathView { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +function encodedByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +/** Encodes one stored folder name without normalizing its case or Unicode form. */ +export function encodeFolderPathSegment(name: string): string { + if (name.length === 0) throw new FolderPathError('Folder names cannot be empty') + + if (name === '.') return '%2E' + if (name === '..') return '%2E%2E' + + try { + return encodeURIComponent(name).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) + } catch { + throw new FolderPathError('Folder name contains invalid Unicode') + } +} + +/** Builds the canonical public path for a decoded sequence of folder names. */ +export function buildFolderPath(segments: readonly string[]): string { + if (segments.length === 0) return ROOT_FOLDER_PATH + if (segments.length > MAX_FOLDER_PATH_SEGMENTS) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_SEGMENTS} segments`) + } + + const path = `/${segments.map(encodeFolderPathSegment).join('/')}` + if (encodedByteLength(path) > MAX_FOLDER_PATH_BYTES) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_BYTES} bytes`) + } + return path +} + +/** + * Parses a canonical public folder path. Accepted paths are byte-for-byte canonical: callers + * cannot use alternate escapes, raw reserved characters, or normalization aliases. + */ +export function parseFolderPath(path: string): string[] { + if (path === ROOT_FOLDER_PATH) return [] + if (!path.startsWith('/') || path.endsWith('/') || path.includes('//')) { + throw new FolderPathError('Path must be a canonical folder path') + } + if (encodedByteLength(path) > MAX_FOLDER_PATH_BYTES) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_BYTES} bytes`) + } + + const encodedSegments = path.slice(1).split('/') + if (encodedSegments.length > MAX_FOLDER_PATH_SEGMENTS) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_SEGMENTS} segments`) + } + + return encodedSegments.map((encodedSegment) => { + let decoded: string + try { + decoded = decodeURIComponent(encodedSegment) + } catch { + throw new FolderPathError('Path must be a canonical folder path') + } + + if (encodeFolderPathSegment(decoded) !== encodedSegment) { + throw new FolderPathError('Path must be a canonical folder path') + } + return decoded + }) +} + +export function requireNonRootFolderPath(path: string): string[] { + const segments = parseFolderPath(path) + if (segments.length === 0) throw new FolderPathError('The root path cannot be mutated') + return segments +} + +export function parentFolderPath(path: string): string { + const segments = requireNonRootFolderPath(path) + return buildFolderPath(segments.slice(0, -1)) +} + +export function folderNameFromPath(path: string): string { + const segments = requireNonRootFolderPath(path) + return segments[segments.length - 1] +} + +/** Builds a lossless, fail-fast bidirectional index over one active resource folder tree. */ +export function buildFolderPathIndex( + rows: readonly Row[] +): FolderPathIndex { + const rowById = new Map() + for (const row of rows) { + if (rowById.has(row.id)) throw new FolderPathError(`Duplicate folder id: ${row.id}`) + rowById.set(row.id, row) + } + + const pathById = new Map() + const idByPath = new Map() + const visiting = new Set() + + const resolvePath = (folderId: string): string => { + const resolved = pathById.get(folderId) + if (resolved) return resolved + if (visiting.has(folderId)) throw new FolderPathError('Folder hierarchy contains a cycle') + + const row = rowById.get(folderId) + if (!row) throw new FolderPathError(`Folder hierarchy references missing folder: ${folderId}`) + + visiting.add(folderId) + const parentPath = row.parentId ? resolvePath(row.parentId) : ROOT_FOLDER_PATH + const path = + parentPath === ROOT_FOLDER_PATH + ? `/${encodeFolderPathSegment(row.name)}` + : `${parentPath}/${encodeFolderPathSegment(row.name)}` + visiting.delete(folderId) + + parseFolderPath(path) + const duplicateId = idByPath.get(path) + if (duplicateId && duplicateId !== folderId) { + throw new FolderPathError(`Folder hierarchy contains duplicate path: ${path}`) + } + pathById.set(folderId, path) + idByPath.set(path, folderId) + return path + } + + for (const row of rows) resolvePath(row.id) + + return { rowById, pathById, idByPath } +} + +export function toFolderPathView( + row: Pick, + path: string +): FolderPathView { + return { + name: row.name, + path, + parentPath: parentFolderPath(path), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export function isFolderPathEffectivelyLocked( + index: FolderPathIndex, + folderId: string +): boolean { + let currentId: string | null = folderId + while (currentId) { + const row = index.rowById.get(currentId) + if (!row) throw new FolderPathError('Folder hierarchy references a missing ancestor') + if (row.locked) return true + currentId = row.parentId + } + return false +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 77cc4392756..7811476e82d 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,9 +1,15 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' -import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import type { DbOrTx } from '@/lib/db/types' +import { buildFolderPathIndex, type FolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' +export type FolderSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' + /** * Normalizes a `folder` row to the `FolderApi` wire shape (timestamps as ISO strings). * @@ -28,7 +34,8 @@ export function toFolderApi(row: typeof folder.$inferSelect): FolderApi { export async function wouldCreateFolderCycle( folderId: string, parentId: string, - resourceType: FolderResourceType + resourceType: FolderResourceType, + tx: DbOrTx = db ): Promise { let currentParentId: string | null = parentId const visited = new Set() @@ -37,7 +44,7 @@ export async function wouldCreateFolderCycle( if (visited.has(currentParentId) || currentParentId === folderId) return true visited.add(currentParentId) - const [parent] = await db + const [parent] = await tx .select({ parentId: folder.parentId }) .from(folder) .where(and(eq(folder.id, currentParentId), eq(folder.resourceType, resourceType))) @@ -83,6 +90,34 @@ export async function findActiveFolder( return row ?? null } +/** + * A folder in a workspace's tree regardless of archive state. + * + * {@link findActiveFolder} answers "is this a valid destination"; this answers "does this row + * exist here at all". Delete needs the second question — `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be retried, and + * filtering archived rows out would strand those stragglers. + */ +export async function findFolderInWorkspace( + folderId: string, + workspaceId: string, + resourceType: FolderResourceType +): Promise { + const [row] = await db + .select() + .from(folder) + .where( + and( + eq(folder.id, folderId), + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType) + ) + ) + .limit(1) + + return row ?? null +} + /** * Where a restored resource should land: its original folder when that folder is reachable, * otherwise the workspace root. @@ -107,21 +142,114 @@ export async function resolveRestoredFolderId( return (await findActiveFolder(folderId, workspaceId, resourceType)) ? folderId : null } -/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so folders sharing a name or a + * `sortOrder` still come back in a stable order. + */ +const FOLDER_SORTS = { + position: [folder.sortOrder, folder.createdAt], + name: [folder.name, folder.createdAt], + createdAt: [folder.createdAt], + updatedAt: [folder.updatedAt, folder.createdAt], +} satisfies Record + +interface ListFoldersOptions { + /** Case-insensitive substring match on the folder name. */ + search?: string + sortBy?: FolderSortBy + sortOrder?: V2SortOrder +} + +interface ListActiveFolderRowsOptions { + parentId?: string | null + search?: string + sortBy?: Exclude + sortOrder?: V2SortOrder +} + +export async function loadActiveFolderPathIndex( + workspaceId: string, + resourceType: FolderResourceType, + tx: DbOrTx = db +): Promise> { + const rows = await tx + .select() + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt) + ) + ) + + return buildFolderPathIndex(rows) +} + +/** Resolves a canonical folder path to its internal id; `/` resolves to the root sentinel. */ +export function resolveFolderPathFromIndex( + index: FolderPathIndex, + path: string +): string | null | undefined { + return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) +} + +export async function listActiveFolderRows( + workspaceId: string, + resourceType: FolderResourceType, + options: ListActiveFolderRowsOptions = {}, + tx: DbOrTx = db +): Promise> { + const parentFilter = + options.parentId === undefined + ? undefined + : options.parentId === null + ? isNull(folder.parentId) + : eq(folder.parentId, options.parentId) + + return tx + .select() + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt), + parentFilter, + searchFilter(folder.name, options.search) + ) + ) + .orderBy(...listOrderBy(FOLDER_SORTS[options.sortBy ?? 'name'], options.sortOrder ?? 'asc')) +} + +/** + * Shared by `GET /api/folders`, the public v2 list, and the sidebar prefetch so + * the query never drifts between them. Search and sort are applied in the + * query; the in-app callers omit them and keep the default `position` ordering. + */ export async function listFoldersForWorkspace( workspaceId: string, scope: FolderQueryScope, - resourceType: FolderResourceType + resourceType: FolderResourceType, + options?: ListFoldersOptions ): Promise { const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) + const sortBy = options?.sortBy ?? 'position' + const sortOrder = options?.sortOrder ?? 'asc' const rows = await db .select() .from(folder) .where( - and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), scopeFilter) + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + scopeFilter, + searchFilter(folder.name, options?.search) + ) ) - .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) + .orderBy(...listOrderBy(FOLDER_SORTS[sortBy], sortOrder)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/folders/status.ts b/apps/sim/lib/folders/status.ts index ddea423bd0b..2cf6e89037b 100644 --- a/apps/sim/lib/folders/status.ts +++ b/apps/sim/lib/folders/status.ts @@ -1,4 +1,4 @@ -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' /** * Folder mutations can fail for one reason the shared orchestration vocabulary has no word diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 84b9d8c9830..57ee50321be 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -1,6 +1,20 @@ /** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 +/** + * Chunking a knowledge base gets when its creator names no configuration. + * + * Applied in `lib/knowledge/orchestration` so the UI, the v1 and v2 APIs, and + * the copilot agent all index identical input identically. Previously each + * caller carried its own literal and the agent's `minSize` was 1, so the same + * document chunked differently depending on who uploaded it. + */ +export const DEFAULT_CHUNKING_CONFIG = { + maxSize: 1024, + minSize: 100, + overlap: 200, +} as const + export const TAG_SLOT_CONFIG = { text: { slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const, diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 1b8651a8424..70b6dc6c691 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -32,6 +32,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, type StorageBillingContext, + StorageLimitExceededError, } from '@/lib/billing/storage' import { checkAndBillOverageThreshold, @@ -41,6 +42,7 @@ import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { env, envNumber } from '@/lib/core/config/env' import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { processDocument } from '@/lib/knowledge/documents/document-processor' import { @@ -85,9 +87,9 @@ const logger = createLogger('DocumentService') * storage object that is not owned by the target knowledge base's workspace. * Routes map this to a 403. */ -export class KnowledgeBaseFileOwnershipError extends Error { +export class KnowledgeBaseFileOwnershipError extends OrchestrationError { constructor(public readonly storageKey: string) { - super('Document file is not owned by this knowledge base') + super('forbidden', 'Document file is not owned by this knowledge base') this.name = 'KnowledgeBaseFileOwnershipError' } } @@ -1052,7 +1054,7 @@ async function resolveDocumentStorageAdmission( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!kb) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if (bytes <= 0) { @@ -1064,7 +1066,7 @@ async function resolveDocumentStorageAdmission( const context = await resolveStorageBillingContext(kb.workspaceId) const quotaCheck = await checkStorageQuotaForBillingContext(context, bytes) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } return { workspaceId: kb.workspaceId, @@ -1078,7 +1080,7 @@ async function resolveDocumentStorageAdmission( getHighestPrioritySubscription(billedUserId), ]) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } return { workspaceId: null, @@ -1126,7 +1128,7 @@ export async function createDocumentRecords( .limit(1) if (kb.length === 0) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if ( @@ -1176,7 +1178,7 @@ export async function createDocumentRecords( preparedBilling.bytes ) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } } } @@ -1519,7 +1521,8 @@ export async function createSingleDocument( }, knowledgeBaseId: string, requestId: string, - uploadedBy: string | null = null + uploadedBy: string | null = null, + documentId = generateId() ): Promise<{ id: string knowledgeBaseId: string @@ -1540,7 +1543,6 @@ export async function createSingleDocument( tag6: string | null tag7: string | null }> { - const documentId = generateId() const now = new Date() const [resolvedDocumentData] = await resolveServerKnownDocumentSizes([documentData]) const admission = await resolveDocumentStorageAdmission( @@ -1618,7 +1620,7 @@ export async function createSingleDocument( .limit(1) if (kb.length === 0) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if ( @@ -1665,7 +1667,7 @@ export async function createSingleDocument( preparedBilling.bytes ) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } } } @@ -1711,6 +1713,60 @@ export async function createSingleDocument( } } +/** Returns one active document by its deterministic upload id. */ +export async function getDocumentByUploadId( + documentId: string, + knowledgeBaseId: string +): Promise< + | (Awaited> & { + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + }) + | null +> { + const [existing] = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + tag1: document.tag1, + tag2: document.tag2, + tag3: document.tag3, + tag4: document.tag4, + tag5: document.tag5, + tag6: document.tag6, + tag7: document.tag7, + processingStatus: document.processingStatus, + }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.deletedAt) + ) + ) + .limit(1) + if (!existing) return null + const processingStatus = existing.processingStatus + if ( + processingStatus !== 'pending' && + processingStatus !== 'processing' && + processingStatus !== 'completed' && + processingStatus !== 'failed' + ) { + throw new Error(`Document ${existing.id} has invalid processing status`) + } + return { ...existing, processingStatus } +} + export async function bulkDocumentOperation( knowledgeBaseId: string, operation: 'enable' | 'disable' | 'delete', diff --git a/apps/sim/lib/knowledge/folders.test.ts b/apps/sim/lib/knowledge/folders.test.ts index a81ebba83bf..009827d4a90 100644 --- a/apps/sim/lib/knowledge/folders.test.ts +++ b/apps/sim/lib/knowledge/folders.test.ts @@ -105,7 +105,7 @@ describe('createKnowledgeBase — folder assignment', () => { await expect( createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1') - ).rejects.toMatchObject({ code: 'KNOWLEDGE_BASE_FORBIDDEN' }) + ).rejects.toMatchObject({ code: 'forbidden' }) expect(mockFindActiveFolder).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts new file mode 100644 index 00000000000..68f7a10f242 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockDispatchSync, + mockHasWorkspaceLiveSyncAccess, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockDispatchSync: vi.fn(), + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + CONNECTOR_CREATED: 'connector.created', + CONNECTOR_UPDATED: 'connector.updated', + CONNECTOR_DELETED: 'connector.deleted', + CONNECTOR_SYNCED: 'connector.synced', + }, + AuditResourceType: { CONNECTOR: 'connector' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/api-key/crypto', () => ({ encryptApiKey: vi.fn() })) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess, +})) +vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/knowledge/tags/service', () => ({ + cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined), + createTagDefinition: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, +} from '@/lib/knowledge/orchestration/connectors' + +const KB = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' } +const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } +const BILLING = { actorUserId: 'user-1', workspaceId: 'ws-1' } as never +const resolveBillingAttribution = vi.fn().mockResolvedValue(BILLING) + +describe('performDeleteKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('reports the documents it kept, so the caller cannot claim otherwise', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [ + { id: 'doc-1', fileUrl: '/a.txt' }, + { id: 'doc-2', fileUrl: '/b.txt' }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + }) + + // The default keeps the documents. The copilot tool used to assert they had + // been removed while taking exactly this path. + expect(outcome).toMatchObject({ success: true, documentsKept: 2, documentsDeleted: 0 }) + expect(dbChainMockFns.delete).not.toHaveBeenCalledWith(document) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ deleteDocuments: false, documentsKept: 2 }), + }) + ) + }) + + it('reports the documents it deleted when asked to delete them', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [{ id: 'doc-1', fileUrl: '/a.txt' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + deleteDocuments: true, + }) + + expect(outcome).toMatchObject({ success: true, documentsDeleted: 1, documentsKept: 0 }) + }) + + it('reports a missing connector as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('rejects an update that names nothing before reading the connector', async () => { + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: {}, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('classifies a sub-hourly interval on an unentitled workspace as forbidden', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { syncIntervalMinutes: 5 }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-1') + }) + + it('leaves a caller-supplied validator to reject a bad source config', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'gone' } }, + validateSourceConfig: async () => ({ + message: 'Database not found', + errorCode: 'validation' as const, + }), + }) + + expect(outcome).toMatchObject({ + success: false, + errorCode: 'validation', + error: 'Database not found', + }) + }) + + it('preserves the failure class the validator chose', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + + // A stale stored credential kept the route's 401; collapsing every + // rejection to `validation` had flattened it (and the 409) into a 400. + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'x' } }, + validateSourceConfig: async () => ({ + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized' as const, + }), + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'unauthorized' }) + }) + + it('clears the failure counters when a paused connector is resumed', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { status: 'active' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ consecutiveFailures: 0, lastSyncError: null }) + ) + }) +}) + +describe('performSyncKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockDispatchSync.mockResolvedValue(undefined) + }) + + afterAll(resetDbChainMock) + + it('resolves the payer before writing the audit, not after', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + const rejects = vi.fn().mockRejectedValue(new Error('billing attribution header is malformed')) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution: rejects, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('refuses to stack a sync on one already running', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'syncing' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + // A rejected request never pays for the payer lookup. + expect(resolveBillingAttribution).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('dispatches and records who asked for it', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + rehydrate: true, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDispatchSync).toHaveBeenCalledWith('conn-1', { + billingAttribution: BILLING, + requestId: 'req-1', + rehydrate: true, + }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + metadata: expect.objectContaining({ syncType: 'manual-rehydrate' }), + }) + ) + }) + + it('rejects a knowledge base with no workspace to bill', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: { ...KB, workspaceId: null }, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts new file mode 100644 index 00000000000..01d18f5ccea --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -0,0 +1,735 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { + document, + embedding, + knowledgeBase, + knowledgeBaseTagDefinitions, + knowledgeConnector, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { encryptApiKey } from '@/lib/api-key/crypto' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { allocateTagSlots } from '@/lib/knowledge/constants' +import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { cleanupUnusedTagDefinitions, createTagDefinition } from '@/lib/knowledge/tags/service' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeConnectorOrchestration') + +/** + * The connector registry and the sync queue are loaded on demand rather than + * imported at module scope. Both pull in every connector's SDK and the whole + * sync engine, and this module is re-exported from the knowledge orchestration + * barrel — a static edge would drag that graph into the bundle of every route + * that merely creates a knowledge base or uploads a document. + */ +async function loadDispatchSync() { + return (await import('@/lib/knowledge/connectors/queue')).dispatchSync +} + +/** A connector row exactly as stored, including its encrypted API key. */ +export type KnowledgeConnectorRow = typeof knowledgeConnector.$inferSelect +type ConnectorRow = KnowledgeConnectorRow +/** The connector row as it reaches every caller: never carrying the stored API key. */ +export type ConnectorWithoutSecret = Omit + +/** A refused `sourceConfig`, with the failure class the caller wants surfaced. */ +export interface SourceConfigRejection { + message: string + errorCode: OrchestrationErrorCode +} + +/** The knowledge base a connector operation targets, already authorized by the caller. */ +export interface ConnectorKnowledgeBase { + id: string + name: string + workspaceId: string | null +} + +function withoutSecret(row: ConnectorRow): ConnectorWithoutSecret { + const { encryptedApiKey: _encryptedApiKey, ...rest } = row + return rest +} + +/** + * Rejects a sub-hourly sync interval on a workspace without the plan for it. + * `0` disables scheduled syncs and is always allowed. + */ +async function assertLiveSyncAllowed( + workspaceId: string, + syncIntervalMinutes: number | undefined +): Promise { + if (syncIntervalMinutes === undefined || syncIntervalMinutes <= 0 || syncIntervalMinutes >= 60) { + return + } + if (!(await hasWorkspaceLiveSyncAccess(workspaceId))) { + throw new OrchestrationError('forbidden', 'Live sync requires a Max or Enterprise plan') + } +} + +export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorType: string + credentialId?: string + apiKey?: string + sourceConfig: Record + syncIntervalMinutes: number + /** + * Resolves the payer the sync is billed to. A thunk so a request rejected by + * a guard never pays for the lookup, and so the payer is read at the moment + * the sync is dispatched. + */ + resolveBillingAttribution: () => Promise + /** + * Resolves an OAuth credential to its access token. Supplied by the caller + * because credential lookup is scoped to the requesting identity. + */ + resolveAccessToken: (credentialId: string) => Promise +} + +export type PerformConnectorResult = KnowledgeOrchestrationResult<{ + connector: ConnectorWithoutSecret +}> + +/** + * Creates a connector on a knowledge base and dispatches its first sync. + * + * The tag-slot allocation and the connector insert share one transaction under + * the knowledge base's row lock, so a knowledge base archived mid-request can + * never end up with a live connector, and a partial slot allocation cannot + * outlive a failed insert. + */ +export async function performCreateKnowledgeConnector( + params: PerformCreateKnowledgeConnectorParams +): Promise { + const { + knowledgeBase: kb, + connectorType, + credentialId, + apiKey, + sourceConfig, + syncIntervalMinutes, + resolveBillingAttribution, + resolveAccessToken, + request, + source, + } = params + const requestId = params.requestId ?? generateRequestId() + + if (!kb.workspaceId) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + const workspaceId = kb.workspaceId + + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + const connectorConfig = CONNECTOR_REGISTRY[connectorType] + if (!connectorConfig) { + return fail(`Unknown connector type: ${connectorType}`, 'validation') + } + + try { + await assertLiveSyncAllowed(workspaceId, syncIntervalMinutes) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + let resolvedCredentialId: string | null = null + let resolvedEncryptedApiKey: string | null = null + let accessToken: string + + if (connectorConfig.auth.mode === 'apiKey') { + if (!apiKey) { + return fail('API key is required', 'validation') + } + accessToken = apiKey + } else { + if (!credentialId) { + return fail('Credential is required', 'validation') + } + let token: string | null + try { + token = await resolveAccessToken(credentialId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + if (!token) { + return fail('Credential has no access token. Please reconnect your account.', 'validation') + } + accessToken = token + resolvedCredentialId = credentialId + } + + const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) + if (!configValidation.valid) { + return fail(configValidation.error || 'Invalid source configuration', 'validation') + } + + if (connectorConfig.auth.mode === 'apiKey' && apiKey) { + resolvedEncryptedApiKey = (await encryptApiKey(apiKey)).encrypted + } + + let finalSourceConfig: Record = { ...sourceConfig } + const tagSlotMapping: Record = {} + let newTagSlots: Record = {} + + if (connectorConfig.tagDefinitions?.length) { + const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? []) + const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id)) + + const existingDefs = await db + .select({ + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + fieldType: knowledgeBaseTagDefinitions.fieldType, + }) + .from(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, kb.id)) + + const usedSlots = new Set(existingDefs.map((d) => d.tagSlot)) + const existingByName = new Map( + existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }]) + ) + + const defsNeedingSlots: typeof enabledDefs = [] + for (const td of enabledDefs) { + const existing = existingByName.get(td.displayName) + if (existing && existing.fieldType === td.fieldType) { + tagSlotMapping[td.id] = existing.tagSlot + } else { + defsNeedingSlots.push(td) + } + } + + const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots) + Object.assign(tagSlotMapping, mapping) + newTagSlots = mapping + + for (const name of skippedTags) { + logger.warn(`[${requestId}] No available slots for "${name}"`) + } + + if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { + return fail( + `No available tag slots. Could not assign: ${skippedTags.join(', ')}`, + 'validation' + ) + } + + finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } + } + + // Resolved before the write, not after: every guard that can cheaply reject + // the request has already run, and `requireBillingAttributionHeader` throws on + // a malformed header. Resolving it post-commit would leave a live connector + // behind a 500 and let a retry create a duplicate. + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = await resolveBillingAttribution() + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + const now = new Date() + const connectorId = generateId() + const nextSyncAt = + syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null + + let created: ConnectorRow + try { + created = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${kb.id} FOR UPDATE`) + + const activeKb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, kb.id), isNull(knowledgeBase.deletedAt))) + .limit(1) + + if (activeKb.length === 0) { + throw new OrchestrationError('not_found', 'Knowledge base not found') + } + + for (const [semanticId, slot] of Object.entries(newTagSlots)) { + const td = connectorConfig.tagDefinitions?.find((d) => d.id === semanticId) + if (!td) continue + await createTagDefinition( + { + knowledgeBaseId: kb.id, + tagSlot: slot, + displayName: td.displayName, + fieldType: td.fieldType, + }, + requestId, + tx + ) + } + + const [row] = await tx + .insert(knowledgeConnector) + .values({ + id: connectorId, + knowledgeBaseId: kb.id, + connectorType, + credentialId: resolvedCredentialId, + encryptedApiKey: resolvedEncryptedApiKey, + sourceConfig: finalSourceConfig, + syncIntervalMinutes, + status: 'active', + nextSyncAt, + createdAt: now, + updatedAt: now, + }) + .returning() + + return row + }) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + logger.info(`[${requestId}] Created connector ${connectorId} for KB ${kb.id}`) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: kb.id, + workspace_id: workspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_CREATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connectorType, + description: `Created ${connectorType} connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType, + syncIntervalMinutes, + authMode: connectorConfig.auth.mode, + }, + ...(request ? { request } : {}), + }) + + const dispatchSync = await loadDispatchSync() + dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, + error + ) + }) + + return { success: true, connector: withoutSecret(created) } +} + +export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + updates: { + sourceConfig?: Record + syncIntervalMinutes?: number + status?: 'active' | 'paused' + } + /** + * Validates a replacement `sourceConfig` against the live source. Supplied by + * the caller because resolving the connector's token needs the requesting + * identity. Returning a rejection fails the update. + * + * The rejection carries its own `errorCode` so a stale credential and a bad + * config stay distinguishable — collapsing every rejection to `validation` + * flattened the route's 401 and 409 into a 400. + */ + validateSourceConfig?: ( + connector: KnowledgeConnectorRow, + sourceConfig: Record + ) => Promise +} + +/** Loads an active connector scoped to its knowledge base. */ +export async function getKnowledgeConnector( + knowledgeBaseId: string, + connectorId: string +): Promise { + const [row] = await db + .select() + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .limit(1) + + return row ?? null +} + +/** Applies a connector configuration change and records it against the actor. */ +export async function performUpdateKnowledgeConnector( + params: PerformUpdateKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, updates, validateSourceConfig, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail( + 'At least one of sourceConfig, syncIntervalMinutes, or status is required', + 'validation' + ) + } + + const existing = await getKnowledgeConnector(kb.id, connectorId) + if (!existing) { + return fail('Connector not found', 'not_found') + } + + if (updates.syncIntervalMinutes !== undefined) { + if (!kb.workspaceId && updates.syncIntervalMinutes > 0 && updates.syncIntervalMinutes < 60) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + if (kb.workspaceId) { + try { + await assertLiveSyncAllowed(kb.workspaceId, updates.syncIntervalMinutes) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) + } + } + } + + if (updates.sourceConfig !== undefined && validateSourceConfig) { + const rejection = await validateSourceConfig(existing, updates.sourceConfig) + if (rejection) { + return fail(rejection.message, rejection.errorCode) + } + } + + const values: Partial = { updatedAt: new Date() } + if (updates.sourceConfig !== undefined) { + values.sourceConfig = updates.sourceConfig + } + if (updates.syncIntervalMinutes !== undefined) { + values.syncIntervalMinutes = updates.syncIntervalMinutes + values.nextSyncAt = + updates.syncIntervalMinutes > 0 + ? new Date(Date.now() + updates.syncIntervalMinutes * 60 * 1000) + : null + } + if (updates.status !== undefined) { + values.status = updates.status + if (updates.status === 'active') { + values.consecutiveFailures = 0 + values.lastSyncError = null + // Resuming a paused connector syncs immediately unless this same request + // set a schedule, which then owns the next run. + if (values.nextSyncAt === undefined) { + values.nextSyncAt = new Date() + } + } + } + + let updated: ConnectorRow + try { + const [row] = await db + .update(knowledgeConnector) + .set(values) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning() + + if (!row) { + return fail('Connector not found', 'not_found') + } + updated = row + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) + } + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_UPDATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: updated.connectorType, + description: `Updated connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: updated.connectorType, + updatedFields, + ...(updates.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: updates.syncIntervalMinutes, + }), + ...(updates.status !== undefined && { newStatus: updates.status }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, connector: withoutSecret(updated) } +} + +export interface PerformDeleteKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + /** + * Also hard-delete the documents the connector produced. Defaults to keeping + * them, which turns them into ordinary standalone knowledge base entries. + */ + deleteDocuments?: boolean +} + +/** What actually happened to the connector's documents, for the caller to report. */ +export type PerformDeleteKnowledgeConnectorResult = KnowledgeOrchestrationResult<{ + documentsDeleted: number + documentsKept: number +}> + +/** + * Hard-deletes a connector, either removing the documents it produced or + * releasing them as standalone entries. + * + * Returns the counts so callers state what happened rather than assert it. The + * copilot tool used to reach this through an internal HTTP self-call that sent + * no query string, so it always took the keep-documents default while telling + * the user the documents had been removed. + */ +export async function performDeleteKnowledgeConnector( + params: PerformDeleteKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, request, source } = params + const deleteDocuments = params.deleteDocuments ?? false + const requestId = params.requestId ?? generateRequestId() + + const existing = await getKnowledgeConnector(kb.id, connectorId) + if (!existing) { + return fail('Connector not found', 'not_found') + } + + let deletedDocs: Array<{ id: string; fileUrl: string }> + let docCount: number + try { + ;({ deletedDocs, docCount } = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) + + // Includes pending-removal (tombstoned) docs — the connector is being + // deleted, so there's no future sync left to confirm or resurrect them. + const docs = await tx + .select({ id: document.id, fileUrl: document.fileUrl }) + .from(document) + .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) + + const documentIds = docs.map((doc) => doc.id) + if (deleteDocuments) { + if (documentIds.length > 0) { + await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) + await tx.delete(document).where(inArray(document.id, documentIds)) + } + } else if (documentIds.length > 0) { + // Kept documents become normal standalone KB entries once their connector + // is gone — resurrect any pending-removal ones rather than leaving them + // invisible tombstones with no future sync left to ever confirm or + // resurrect them. + await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds)) + } + + const deletedConnectors = await tx + .delete(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }) + + if (deletedConnectors.length === 0) { + throw new OrchestrationError('not_found', 'Connector not found') + } + + return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length } + })) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete connector ${connectorId}`) + } + + if (deleteDocuments) { + await Promise.all([ + deletedDocs.length > 0 + ? deleteDocumentStorageFiles( + deletedDocs.map((doc) => ({ ...doc, workspaceId: kb.workspaceId })), + requestId + ) + : Promise.resolve(), + cleanupUnusedTagDefinitions(kb.id, requestId).catch((error) => { + logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) + }), + ]) + } + + logger.info( + `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` + ) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_removed', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: existing.connectorType, + documents_deleted: deleteDocuments ? docCount : 0, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_DELETED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: existing.connectorType, + description: `Deleted connector from knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: existing.connectorType, + deleteDocuments, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + }, + ...(request ? { request } : {}), + }) + + return { + success: true, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + } +} + +export interface PerformSyncKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + /** + * Resolves the payer the sync is billed to. A thunk so a request rejected by + * a guard never pays for the lookup. + */ + resolveBillingAttribution: () => Promise + /** Re-fetch and re-index every already-synced document, not only changed ones. */ + rehydrate?: boolean +} + +export type PerformSyncKnowledgeConnectorResult = KnowledgeOrchestrationResult + +/** Triggers a manual sync for a connector and records who asked for it. */ +export async function performSyncKnowledgeConnector( + params: PerformSyncKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, resolveBillingAttribution, request, source } = params + const rehydrate = params.rehydrate ?? false + const requestId = params.requestId ?? generateRequestId() + + const connector = await getKnowledgeConnector(kb.id, connectorId) + if (!connector) { + return fail('Connector not found', 'not_found') + } + if (connector.status === 'syncing') { + return fail('Sync already in progress', 'conflict') + } + if (!kb.workspaceId) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + // Resolved before the audit is written, so a rejected payer lookup returns a + // classified failure rather than escaping as a 500 with a sync already recorded. + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = await resolveBillingAttribution() + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Sync connector ${connectorId}`) + } + + logger.info( + `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` + ) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: connector.connectorType, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_SYNCED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connector.connectorType, + description: `Triggered manual sync for connector on knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: connector.connectorType, + connectorStatus: connector.status, + syncType: rehydrate ? 'manual-rehydrate' : 'manual', + }, + ...(request ? { request } : {}), + }) + + const dispatchSync = await loadDispatchSync() + dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, + error + ) + }) + + return { success: true } +} diff --git a/apps/sim/lib/knowledge/orchestration/documents.test.ts b/apps/sim/lib/knowledge/orchestration/documents.test.ts new file mode 100644 index 00000000000..42a8b250e05 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/documents.test.ts @@ -0,0 +1,398 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockCreateDocumentRecords, + mockCreateSingleDocument, + mockDeleteDocument, + mockGetDocumentByUploadId, + mockMarkDocumentAsFailedTimeout, + mockProcessDocumentAsync, + mockProcessDocumentsWithQueue, + mockRecordAudit, + mockRetryDocumentProcessing, + mockUpdateDocument, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockCreateDocumentRecords: vi.fn(), + mockCreateSingleDocument: vi.fn(), + mockDeleteDocument: vi.fn(), + mockGetDocumentByUploadId: vi.fn(), + mockMarkDocumentAsFailedTimeout: vi.fn(), + mockProcessDocumentAsync: vi.fn(), + mockProcessDocumentsWithQueue: vi.fn(), + mockRecordAudit: vi.fn(), + mockRetryDocumentProcessing: vi.fn(), + mockUpdateDocument: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_UPDATED: 'document.updated', + DOCUMENT_DELETED: 'document.deleted', + }, + AuditResourceType: { DOCUMENT: 'document' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: vi.fn() }, +})) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createDocumentRecords: mockCreateDocumentRecords, + createSingleDocument: mockCreateSingleDocument, + deleteDocument: mockDeleteDocument, + getDocumentByUploadId: mockGetDocumentByUploadId, + markDocumentAsFailedTimeout: mockMarkDocumentAsFailedTimeout, + processDocumentAsync: mockProcessDocumentAsync, + processDocumentsWithQueue: mockProcessDocumentsWithQueue, + retryDocumentProcessing: mockRetryDocumentProcessing, + updateDocument: mockUpdateDocument, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from '@/lib/knowledge/orchestration/documents' + +const KB = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' } +const FILE = { + filename: 'report.pdf', + fileUrl: 'https://storage/report.pdf', + fileSize: 1024, + mimeType: 'application/pdf', +} +const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } + +describe('performUploadKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateSingleDocument.mockResolvedValue({ id: 'doc-1', filename: 'report.pdf' }) + mockGetDocumentByUploadId.mockResolvedValue(null) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + mockProcessDocumentAsync.mockResolvedValue(undefined) + }) + + it('audits an agent upload, which the copilot path never did', async () => { + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + resourceId: 'doc-1', + resourceName: 'report.pdf', + metadata: expect.objectContaining({ source: 'agent', knowledgeBaseId: 'kb-1' }), + }) + ) + }) + + it('records the document owner the caller names, not the acting user', async () => { + // A workspace API key bills and owns as the workspace account, while the + // acting user stays the audit actor. + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + uploadedBy: 'workspace-owner', + }) + + expect(mockCreateSingleDocument).toHaveBeenCalledWith(FILE, 'kb-1', 'req-1', 'workspace-owner') + }) + + it('starts no indexing unless the caller asks for it', async () => { + await performUploadKnowledgeDocument({ ...ACTOR, knowledgeBase: KB, document: FILE }) + + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockProcessDocumentAsync).not.toHaveBeenCalled() + }) + + it.each([ + { startProcessing: 'queue' as const, expected: mockProcessDocumentsWithQueue }, + { startProcessing: 'async' as const, expected: mockProcessDocumentAsync }, + ])('hands the record to the $startProcessing pipeline', async ({ startProcessing, expected }) => { + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + startProcessing, + }) + + expect(expected).toHaveBeenCalled() + }) + + it('classifies a storage-quota rejection as too large, by class not message', async () => { + mockCreateSingleDocument.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded. Used: 5.10GB') + ) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'payload_too_large' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('classifies a foreign file reference as forbidden', async () => { + mockCreateSingleDocument.mockRejectedValue( + new OrchestrationError('forbidden', 'Document file is not owned by this knowledge base') + ) + + expect( + (await performUploadKnowledgeDocument({ ...ACTOR, knowledgeBase: KB, document: FILE })) + .errorCode + ).toBe('forbidden') + }) + + it('returns the document already bound to a stateless upload id without duplicating work', async () => { + const existing = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: FILE.filename, + fileUrl: FILE.fileUrl, + fileSize: FILE.fileSize, + mimeType: FILE.mimeType, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date(), + } + mockGetDocumentByUploadId.mockResolvedValue(existing) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + documentId: 'upload-1', + startProcessing: 'queue', + }) + + expect(outcome).toMatchObject({ success: true, created: false, document: existing }) + expect(mockCreateSingleDocument).not.toHaveBeenCalled() + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('converges on the existing document when concurrent completions race to insert', async () => { + const existing = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + ...FILE, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date(), + processingStatus: 'pending', + } + mockGetDocumentByUploadId.mockResolvedValueOnce(null).mockResolvedValueOnce(existing) + mockCreateSingleDocument.mockRejectedValue(new Error('duplicate key')) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + documentId: 'upload-1', + startProcessing: 'queue', + }) + + expect(outcome).toMatchObject({ success: true, created: false, document: existing }) + expect(mockCreateSingleDocument).toHaveBeenCalledWith( + FILE, + 'kb-1', + 'req-1', + 'user-1', + 'upload-1' + ) + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) + +describe('performUploadKnowledgeDocuments', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateDocumentRecords.mockResolvedValue([ + { documentId: 'doc-1', filename: 'a.pdf' }, + { documentId: 'doc-2', filename: 'b.pdf' }, + ]) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + }) + + it('admits the whole batch in one call and queues it', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [FILE, { ...FILE, filename: 'b.pdf' }], + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockCreateDocumentRecords).toHaveBeenCalledTimes(1) + expect(mockProcessDocumentsWithQueue).toHaveBeenCalledTimes(1) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: '2 document(s)' }) + ) + }) + + it('rejects an empty batch before touching the service', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [], + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockCreateDocumentRecords).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateDocument.mockResolvedValue({ id: 'doc-1', filename: 'renamed.pdf' }) + }) + + it('rejects an update that names nothing before touching the service', async () => { + const outcome = await performUpdateKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + updates: { filename: undefined, enabled: undefined }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) + + it('names the changed fields in the audit metadata', async () => { + await performUpdateKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + updates: { filename: 'renamed.pdf', enabled: false }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceName: 'renamed.pdf', + metadata: expect.objectContaining({ + updatedFields: ['filename', 'enabled'], + enabled: false, + }), + }) + ) + }) +}) + +describe('performDeleteKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeleteDocument.mockResolvedValue({ success: true, message: 'ok' }) + }) + + it('audits the deletion against the acting user', async () => { + const outcome = await performDeleteKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf', fileSize: 10, mimeType: 'application/pdf' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDeleteDocument).toHaveBeenCalledWith('doc-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'doc-1' }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalled() + }) + + it('emits no telemetry when the delete fails', async () => { + mockDeleteDocument.mockRejectedValue(new Error('deadlock detected')) + + const outcome = await performDeleteKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('document processing state changes', () => { + beforeEach(() => vi.clearAllMocks()) + + it('refuses to time out a document that is not processing', async () => { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'completed', processingStartedAt: new Date() }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockMarkDocumentAsFailedTimeout).not.toHaveBeenCalled() + }) + + it('refuses to time out a document with no processing start time', async () => { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: null }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('surfaces a too-soon timeout as caller-fixable, not a fault', async () => { + mockMarkDocumentAsFailedTimeout.mockRejectedValue( + new Error('Document has not been processing long enough to be considered dead') + ) + + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: new Date() }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('refuses to retry a document that has not failed', async () => { + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'completed' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockRetryDocumentProcessing).not.toHaveBeenCalled() + }) + + it('re-queues a failed document and never audits it', async () => { + mockRetryDocumentProcessing.mockResolvedValue({ + success: true, + status: 'pending', + message: 'Document retry processing started', + }) + + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'failed' }, + requestId: 'req-1', + }) + + expect(outcome).toMatchObject({ success: true, status: 'pending' }) + // No document state the user chose changes, so there is nothing to record. + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts new file mode 100644 index 00000000000..de91b1a6ee0 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -0,0 +1,562 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { + createDocumentRecords, + createSingleDocument, + type DocumentData, + deleteDocument, + getDocumentByUploadId, + markDocumentAsFailedTimeout, + type ProcessingOptions, + processDocumentAsync, + processDocumentsWithQueue, + retryDocumentProcessing, + updateDocument, +} from '@/lib/knowledge/documents/service' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeDocumentOrchestration') + +/** The knowledge base a document operation targets, already authorized by the caller. */ +export interface KnowledgeBaseTarget { + id: string + name?: string | null + workspaceId: string | null +} + +export interface KnowledgeDocumentInput { + filename: string + fileUrl: string + fileSize: number + mimeType: string + documentTagsData?: string + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string +} + +/** + * How the created record is handed to the indexing pipeline. + * + * `queue` runs the shared bounded-concurrency queue; `async` starts one + * detached processing run. Omitted starts nothing — the internal single-document + * route deliberately only creates the row and leaves indexing to its caller. + */ +export type KnowledgeDocumentProcessing = 'queue' | 'async' + +export type CreatedKnowledgeDocument = Awaited> & { + /** Present when an idempotent completion returns an already-processing document. */ + processingStatus?: 'pending' | 'processing' | 'completed' | 'failed' +} + +export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: KnowledgeDocumentInput + startProcessing?: KnowledgeDocumentProcessing + processingOptions?: ProcessingOptions + billingAttribution?: BillingAttributionSnapshot + /** Row owner recorded on the document; defaults to the acting user. */ + uploadedBy?: string | null + /** Deterministic id carried by a stateless upload token for completion retries. */ + documentId?: string +} + +export type PerformUploadKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ + document: CreatedKnowledgeDocument + created: boolean +}> + +function isSameKnowledgeDocumentUpload( + existing: CreatedKnowledgeDocument, + document: KnowledgeDocumentInput +): boolean { + return ( + existing.filename === document.filename && + existing.fileUrl === document.fileUrl && + existing.fileSize === document.fileSize && + existing.mimeType === document.mimeType + ) +} + +export type BoundKnowledgeDocument = + | { status: 'absent' } + | { status: 'bound'; document: CreatedKnowledgeDocument } + | { status: 'conflict' } + +/** + * Resolves what a stateless upload id is already bound to. Callers use this to answer a + * completion retry from existing state before doing any work that can fail independently + * of the upload — resolving a billing payer, or deleting the uploaded object. + */ +export async function findBoundKnowledgeDocument(params: { + documentId: string + knowledgeBaseId: string + document: KnowledgeDocumentInput +}): Promise { + const existing = await getDocumentByUploadId(params.documentId, params.knowledgeBaseId) + if (!existing) return { status: 'absent' } + return isSameKnowledgeDocumentUpload(existing, params.document) + ? { status: 'bound', document: existing } + : { status: 'conflict' } +} + +function auditUpload( + params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, + entry: { resourceId: string; resourceName: string; description: string; metadata: object } +) { + recordAudit({ + workspaceId: params.knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + source: params.source, + knowledgeBaseId: params.knowledgeBase.id, + knowledgeBaseName: params.knowledgeBase.name, + ...entry.metadata, + }, + ...(params.request ? { request: params.request } : {}), + }) +} + +function captureUpload( + params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, + documentCount: number, + uploadType: 'single' | 'bulk' +) { + const workspaceId = params.knowledgeBase.workspaceId + captureServerEvent( + params.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: params.knowledgeBase.id, + workspace_id: workspaceId ?? '', + document_count: documentCount, + upload_type: uploadType, + }, + { + ...(workspaceId ? { groups: { workspace: workspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) +} + +/** + * Adds one already-resolved file to a knowledge base. + * + * Each surface acquires the file differently — a multipart body uploaded to + * workspace storage, a virtual-filesystem reference resolved to a presigned URL, + * a client-supplied URL — so acquisition stays with the caller and this takes + * the resolved `{filename, fileUrl, fileSize, mimeType}`. Everything downstream + * of that (the record, the indexing hand-off, telemetry, and the audit) is + * identical for all of them, which is why the copilot path used to index + * documents that appear nowhere in the audit log. + */ +export async function performUploadKnowledgeDocument( + params: PerformUploadKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, startProcessing, processingOptions, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + let created: CreatedKnowledgeDocument + if (params.documentId) { + const bound = await findBoundKnowledgeDocument({ + documentId: params.documentId, + knowledgeBaseId: knowledgeBase.id, + document, + }) + if (bound.status === 'conflict') { + return fail('Upload id is already bound to a different document', 'conflict') + } + if (bound.status === 'bound') { + return { success: true, document: bound.document, created: false } + } + } + + try { + created = params.documentId + ? await createSingleDocument( + document, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId, + params.documentId + ) + : await createSingleDocument( + document, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) + } catch (error) { + if (params.documentId) { + const bound = await findBoundKnowledgeDocument({ + documentId: params.documentId, + knowledgeBaseId: knowledgeBase.id, + document, + }) + if (bound.status === 'conflict') { + return fail('Upload id is already bound to a different document', 'conflict') + } + if (bound.status === 'bound') { + return { success: true, document: bound.document, created: false } + } + } + return classifyKnowledgeFailure( + error, + requestId, + `Upload document "${document.filename}" to knowledge base ${knowledgeBase.id}` + ) + } + + const documentData: DocumentData = { + documentId: created.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + } + + if (startProcessing === 'queue') { + processDocumentsWithQueue( + [documentData], + knowledgeBase.id, + processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Document processing pipeline failed`, { error }) + }) + } else if (startProcessing === 'async') { + processDocumentAsync( + knowledgeBase.id, + created.id, + document, + processingOptions ?? {}, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Background document processing failed`, { + documentId: created.id, + error: toError(error).message, + }) + }) + } + + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: 1, + uploadType: 'single', + mimeType: document.mimeType, + fileSize: document.fileSize, + }) + captureUpload(params, 1, 'single') + + auditUpload(params, { + resourceId: created.id, + resourceName: document.filename, + description: `Uploaded document "${document.filename}" to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + fileName: document.filename, + fileType: document.mimeType, + fileSize: document.fileSize, + }, + }) + + return { success: true, document: created, created: true } +} + +export interface PerformUploadKnowledgeDocumentsParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + documents: KnowledgeDocumentInput[] + processingOptions?: ProcessingOptions + billingAttribution?: BillingAttributionSnapshot + uploadedBy?: string | null +} + +export type PerformUploadKnowledgeDocumentsResult = KnowledgeOrchestrationResult<{ + documents: DocumentData[] +}> + +/** + * Adds many files to a knowledge base in one storage admission and hands the + * whole set to the bounded-concurrency processing queue. + * + * Kept separate from the single-document path because `createDocumentRecords` + * admits the batch's bytes as one unit; running it per document would let a set + * that exceeds the quota commit its first half. + */ +export async function performUploadKnowledgeDocuments( + params: PerformUploadKnowledgeDocumentsParams +): Promise { + const { knowledgeBase, documents, processingOptions, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + if (documents.length === 0) { + return fail('No documents specified', 'validation') + } + + let created: DocumentData[] + try { + created = await createDocumentRecords( + documents, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) + } catch (error) { + return classifyKnowledgeFailure( + error, + requestId, + `Upload ${documents.length} document(s) to knowledge base ${knowledgeBase.id}` + ) + } + + logger.info(`[${requestId}] Starting controlled async processing of ${created.length} documents`) + + processDocumentsWithQueue( + created, + knowledgeBase.id, + processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline`, { error }) + }) + + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: created.length, + uploadType: 'bulk', + recipe: processingOptions?.recipe, + }) + captureUpload(params, created.length, 'bulk') + + auditUpload(params, { + resourceId: knowledgeBase.id, + resourceName: `${created.length} document(s)`, + description: `Uploaded ${created.length} document(s) to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { fileCount: created.length }, + }) + + return { success: true, documents: created } +} + +export interface PerformUpdateKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: { id: string; filename: string } + updates: Parameters[1] +} + +export type PerformUpdateKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ + document: Awaited> +}> + +/** Renames a document, toggles it, or edits its tags, and records the change. */ +export async function performUpdateKnowledgeDocument( + params: PerformUpdateKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, updates, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail('No updates specified', 'validation') + } + + let updated: Awaited> + try { + updated = await updateDocument(document.id, updates, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update document ${document.id}`) + } + + const filename = updates.filename ?? document.filename + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: filename, + description: `Updated document "${filename}" in knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + source, + knowledgeBaseId: knowledgeBase.id, + knowledgeBaseName: knowledgeBase.name, + fileName: filename, + updatedFields, + ...(updates.enabled !== undefined && { enabled: updates.enabled }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, document: updated } +} + +export interface PerformDeleteKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: { id: string; filename: string; fileSize?: number; mimeType?: string } +} + +export type PerformDeleteKnowledgeDocumentResult = KnowledgeOrchestrationResult + +/** Deletes a document and its embeddings, and records the deletion. */ +export async function performDeleteKnowledgeDocument( + params: PerformDeleteKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteDocument(document.id, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete document ${document.id}`) + } + + logger.info( + `[${requestId}] Deleted document ${document.id} from knowledge base ${knowledgeBase.id}` + ) + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: document.filename, + description: `Deleted document "${document.filename}" from knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + source, + knowledgeBaseId: knowledgeBase.id, + knowledgeBaseName: knowledgeBase.name, + fileName: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + ...(request ? { request } : {}), + }) + + const workspaceId = knowledgeBase.workspaceId + captureServerEvent( + params.userId, + 'knowledge_base_document_deleted', + { knowledge_base_id: knowledgeBase.id, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + + return { success: true } +} + +export interface PerformMarkKnowledgeDocumentTimedOutParams { + document: { + id: string + processingStatus: string + processingStartedAt?: Date | null + } + requestId?: string +} + +export type PerformKnowledgeDocumentProcessingResult = KnowledgeOrchestrationResult<{ + status: string + message: string +}> + +/** + * Marks a document whose processing run died as failed. Not audited: the state + * change is the system conceding a run it lost, not a user editing a document. + */ +export async function performMarkKnowledgeDocumentTimedOut( + params: PerformMarkKnowledgeDocumentTimedOutParams +): Promise { + const { document } = params + const requestId = params.requestId ?? generateRequestId() + + if (document.processingStatus !== 'processing') { + return fail( + `Document is not in processing state (current: ${document.processingStatus})`, + 'validation' + ) + } + if (!document.processingStartedAt) { + return fail('Document has no processing start time', 'validation') + } + + try { + await markDocumentAsFailedTimeout(document.id, document.processingStartedAt, requestId) + } catch (error) { + // The service rejects a document that has not been processing long enough + // to be presumed dead; that is a caller-fixable "try again later", not a fault. + if (!(error instanceof OrchestrationError)) { + return fail(toError(error).message, 'validation') + } + return classifyKnowledgeFailure(error, requestId, `Time out document ${document.id}`) + } + + return { success: true, status: 'failed', message: 'Document marked as failed due to timeout' } +} + +export interface PerformRetryKnowledgeDocumentParams { + knowledgeBaseId: string + document: { + id: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + processingStatus: string + } + billingAttribution?: BillingAttributionSnapshot + requestId?: string +} + +/** Re-queues a failed document for indexing. Not audited: no document state a user chose changes. */ +export async function performRetryKnowledgeDocumentProcessing( + params: PerformRetryKnowledgeDocumentParams +): Promise { + const { knowledgeBaseId, document, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + if (document.processingStatus !== 'failed') { + return fail('Document is not in failed state', 'validation') + } + + try { + const result = await retryDocumentProcessing( + knowledgeBaseId, + document.id, + { + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + requestId, + billingAttribution + ) + return { success: true, status: result.status, message: result.message } + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Retry document ${document.id}`) + } +} diff --git a/apps/sim/lib/knowledge/orchestration/index.ts b/apps/sim/lib/knowledge/orchestration/index.ts index dc44ac10ee9..2f9d033a355 100644 --- a/apps/sim/lib/knowledge/orchestration/index.ts +++ b/apps/sim/lib/knowledge/orchestration/index.ts @@ -1,88 +1,34 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { generateRequestId } from '@/lib/core/utils/request' -import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' - -const logger = createLogger('KnowledgeBaseOrchestration') - -export type KnowledgeOrchestrationErrorCode = 'not_found' | 'conflict' | 'internal' - -export interface RestorableKnowledgeBase { - id: string - name: string - workspaceId: string | null - userId: string -} - -export interface PerformRestoreKnowledgeBaseParams { - knowledgeBaseId: string - userId: string - requestId?: string -} - -export interface PerformRestoreKnowledgeBaseResult { - success: boolean - error?: string - errorCode?: KnowledgeOrchestrationErrorCode - knowledgeBase?: RestorableKnowledgeBase -} - -export async function getRestorableKnowledgeBase( - knowledgeBaseId: string -): Promise { - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - .limit(1) - - return kb ?? null -} - -export async function performRestoreKnowledgeBase( - params: PerformRestoreKnowledgeBaseParams -): Promise { - const { knowledgeBaseId, userId } = params - const requestId = params.requestId ?? generateRequestId() - - const kb = await getRestorableKnowledgeBase(knowledgeBaseId) - if (!kb) { - return { success: false, error: 'Knowledge base not found', errorCode: 'not_found' } - } - - try { - await restoreKnowledgeBase(knowledgeBaseId, requestId) - - logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) - - recordAudit({ - workspaceId: kb.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: knowledgeBaseId, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { - knowledgeBaseName: kb.name, - }, - }) - - return { success: true, knowledgeBase: kb } - } catch (error) { - logger.error(`[${requestId}] Failed to restore knowledge base ${knowledgeBaseId}`, { error }) - if (error instanceof KnowledgeBaseConflictError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} +export { + type ConnectorKnowledgeBase, + type ConnectorWithoutSecret, + getKnowledgeConnector, + type KnowledgeConnectorRow, + performCreateKnowledgeConnector, + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, + type SourceConfigRejection, +} from './connectors' +export { + type CreatedKnowledgeDocument, + type KnowledgeBaseTarget, + type KnowledgeDocumentInput, + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from './documents' +export { + type PerformKnowledgeBaseResult, + performCreateKnowledgeBase, + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from './knowledge-bases' +export { + getRestorableKnowledgeBase, + performRestoreKnowledgeBase, + type RestorableKnowledgeBase, +} from './restore' +export type { KnowledgeActor, KnowledgeOperationSource } from './shared' diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts new file mode 100644 index 00000000000..6aed845a9be --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts @@ -0,0 +1,256 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockCreateKnowledgeBase, + mockDeleteKnowledgeBase, + mockRecordAudit, + mockUpdateKnowledgeBase, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockCreateKnowledgeBase: vi.fn(), + mockDeleteKnowledgeBase: vi.fn(), + mockRecordAudit: vi.fn(), + mockUpdateKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseCreated: vi.fn(), knowledgeBaseDeleted: vi.fn() }, +})) +vi.mock('@/lib/knowledge/embeddings', () => ({ + EMBEDDING_DIMENSIONS: 1536, + getConfiguredEmbeddingModel: () => 'text-embedding-3-small', +})) +vi.mock('@/lib/knowledge/service', () => ({ + createKnowledgeBase: mockCreateKnowledgeBase, + deleteKnowledgeBase: mockDeleteKnowledgeBase, + updateKnowledgeBase: mockUpdateKnowledgeBase, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { + performCreateKnowledgeBase, + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration/knowledge-bases' + +const CREATED = { id: 'kb-1', name: 'Docs', description: null, workspaceId: 'ws-1' } + +describe('performCreateKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateKnowledgeBase.mockResolvedValue(CREATED) + }) + + it('applies one chunking default for every caller', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'agent', + workspaceId: 'ws-1', + name: 'Docs', + }) + + // The agent used to default minSize to 1 against the API's 100, so the same + // document chunked differently depending on who created the knowledge base. + expect(mockCreateKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ chunkingConfig: { ...DEFAULT_CHUNKING_CONFIG } }), + expect.any(String) + ) + }) + + it('lets a caller override individual chunking fields', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'api', + workspaceId: 'ws-1', + name: 'Docs', + chunkingConfig: { maxSize: 512 }, + }) + + expect(mockCreateKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ + chunkingConfig: { ...DEFAULT_CHUNKING_CONFIG, maxSize: 512 }, + }), + expect.any(String) + ) + }) + + it('audits an agent-created knowledge base, which the copilot path never did', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'agent', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + resourceId: 'kb-1', + workspaceId: 'ws-1', + metadata: expect.objectContaining({ source: 'agent' }), + }) + ) + }) + + it('carries request provenance into the audit row', async () => { + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'curl/8' } }) + + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + request, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ request })) + }) + + it('classifies a duplicate name as a conflict, not bad input', async () => { + mockCreateKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'A knowledge base named "Docs" already exists') + ) + + const outcome = await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('keeps an unclassified failure internal and records nothing', async () => { + mockCreateKnowledgeBase.mockRejectedValue(new Error('connection terminated')) + + const outcome = await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateKnowledgeBase.mockResolvedValue({ ...CREATED, name: 'Renamed' }) + }) + + it('rejects an update that names nothing before touching the service', async () => { + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'api', + updates: { name: undefined, description: undefined }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateKnowledgeBase).not.toHaveBeenCalled() + }) + + it('always forwards the actor, so a workspace move is authorized not rejected', async () => { + await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'api', + updates: { workspaceId: 'ws-2' }, + }) + + // The v1 and v2 routes used to omit `actorUserId`, which the service rejects + // outright on a workspace change. + expect(mockUpdateKnowledgeBase).toHaveBeenCalledWith( + 'kb-1', + { workspaceId: 'ws-2' }, + expect.any(String), + { actorUserId: 'user-1' } + ) + }) + + it('files the audit against the destination workspace on a move', async () => { + await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'ui', + updates: { workspaceId: 'ws-2' }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-2' })) + }) + + it('classifies a rejected folder as bad input', async () => { + mockUpdateKnowledgeBase.mockRejectedValue( + new OrchestrationError('validation', 'Folder not found in this workspace') + ) + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'ui', + updates: { folderId: 'folder-elsewhere' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) +}) + +describe('performDeleteKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeleteKnowledgeBase.mockResolvedValue(undefined) + }) + + it('audits the archive against the acting user', async () => { + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' }, + userId: 'user-1', + source: 'agent', + requestId: 'req-1', + }) + + expect(outcome.success).toBe(true) + expect(mockDeleteKnowledgeBase).toHaveBeenCalledWith('kb-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1' }) + ) + }) + + it('records nothing when the archive fails', async () => { + mockDeleteKnowledgeBase.mockRejectedValue(new Error('deadlock detected')) + + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' }, + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts new file mode 100644 index 00000000000..b808f820e97 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { + createKnowledgeBase, + deleteKnowledgeBase, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeBaseOrchestration') + +export type PerformKnowledgeBaseResult = KnowledgeOrchestrationResult<{ + knowledgeBase: KnowledgeBaseWithCounts +}> + +export interface PerformCreateKnowledgeBaseParams extends KnowledgeOperationContext { + workspaceId: string + name: string + description?: string + /** Folder in the workspace's `knowledge_base` tree; `null`/omitted is the root. */ + folderId?: string | null + /** Omitted fields fall back to {@link DEFAULT_CHUNKING_CONFIG}. */ + chunkingConfig?: Partial +} + +/** + * Creates a knowledge base on behalf of an actor, as the single implementation + * behind the UI route, the v1 and v2 public APIs, and the copilot agent tool. + * + * The chunking default lives here rather than at each boundary: when every + * caller carried its own literal the agent's `minSize` was 1 against the API's + * 100, so identical input produced differently-chunked knowledge bases + * depending on who created it. The audit likewise lives here rather than in the + * three HTTP routes that each had their own copy — which is why an + * agent-created knowledge base used to leave no audit trail at all. + * + * The caller owns authentication; `createKnowledgeBase` still enforces the + * workspace write permission itself, and that failure comes back as `forbidden`. + */ +export async function performCreateKnowledgeBase( + params: PerformCreateKnowledgeBaseParams +): Promise { + const { workspaceId, name, description, folderId, request, source } = params + const requestId = params.requestId ?? generateRequestId() + const chunkingConfig: ChunkingConfig = { ...DEFAULT_CHUNKING_CONFIG, ...params.chunkingConfig } + const embeddingModel = getConfiguredEmbeddingModel() + + let created: KnowledgeBaseWithCounts + try { + created = await createKnowledgeBase( + { + name, + description, + workspaceId, + folderId, + userId: params.userId, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig, + }, + requestId + ) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create knowledge base "${name}"`) + } + + logger.info(`[${requestId}] Created knowledge base ${created.id} for user ${params.userId}`) + + PlatformEvents.knowledgeBaseCreated({ + knowledgeBaseId: created.id, + name: created.name, + workspaceId, + }) + + captureServerEvent( + params.userId, + 'knowledge_base_created', + { knowledge_base_id: created.id, workspace_id: workspaceId, name: created.name }, + { + groups: { workspace: workspaceId }, + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: created.id, + resourceName: created.name, + description: `Created knowledge base "${created.name}"`, + metadata: { + source, + name: created.name, + description: created.description, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingStrategy: chunkingConfig.strategy, + chunkMaxSize: chunkingConfig.maxSize, + chunkMinSize: chunkingConfig.minSize, + chunkOverlap: chunkingConfig.overlap, + }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: created } +} + +export interface PerformUpdateKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBaseId: string + /** Workspace the knowledge base currently belongs to, for the audit row. */ + workspaceId: string | null + updates: { + name?: string + description?: string + /** Moves the knowledge base between workspaces; omitted leaves it in place. */ + workspaceId?: string | null + folderId?: string | null + chunkingConfig?: ChunkingConfig + } +} + +/** + * Applies a knowledge base update and records it against the actor. + * + * `actorUserId` is always forwarded, so a workspace move is authorized against + * the caller rather than rejected for a missing actor — the v1 and v2 routes + * previously omitted it. + */ +export async function performUpdateKnowledgeBase( + params: PerformUpdateKnowledgeBaseParams +): Promise { + const { knowledgeBaseId, updates, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail('No updates specified', 'validation') + } + + let updated: KnowledgeBaseWithCounts + try { + updated = await updateKnowledgeBase(knowledgeBaseId, updates, requestId, { + actorUserId: params.userId, + }) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update knowledge base ${knowledgeBaseId}`) + } + + logger.info(`[${requestId}] Updated knowledge base ${knowledgeBaseId}`) + + recordAudit({ + // The destination workspace when this update moved it, so the audit row + // lands where the knowledge base now lives. + workspaceId: updates.workspaceId !== undefined ? updates.workspaceId : params.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: updated.name, + description: `Updated knowledge base "${updated.name}"`, + metadata: { + source, + updatedFields, + ...(updates.name && { newName: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.chunkingConfig && { + chunkMaxSize: updates.chunkingConfig.maxSize, + chunkMinSize: updates.chunkingConfig.minSize, + chunkOverlap: updates.chunkingConfig.overlap, + }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: updated } +} + +export interface PerformDeleteKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBase: { id: string; name: string; workspaceId: string | null } +} + +export type PerformDeleteKnowledgeBaseResult = KnowledgeOrchestrationResult + +/** + * Archives a knowledge base and its documents and connectors. + * + * The folder cascade and other internal callers keep calling + * `deleteKnowledgeBase` directly and stay silent by construction — auditing + * follows from a user performing this operation, not from the write running. + */ +export async function performDeleteKnowledgeBase( + params: PerformDeleteKnowledgeBaseParams +): Promise { + const { knowledgeBase, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteKnowledgeBase(knowledgeBase.id, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete knowledge base ${knowledgeBase.id}`) + } + + logger.info(`[${requestId}] Deleted knowledge base ${knowledgeBase.id}`) + + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: knowledgeBase.id }) + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBase.id, + resourceName: knowledgeBase.name, + description: `Deleted knowledge base "${knowledgeBase.name}"`, + metadata: { source, knowledgeBaseName: knowledgeBase.name }, + ...(request ? { request } : {}), + }) + + return { success: true } +} diff --git a/apps/sim/lib/knowledge/orchestration/restore.test.ts b/apps/sim/lib/knowledge/orchestration/restore.test.ts new file mode 100644 index 00000000000..558df668d1a --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/restore.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRecordAudit, mockRestoreKnowledgeBase } = vi.hoisted(() => ({ + mockRecordAudit: vi.fn(), + mockRestoreKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored' }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/knowledge/service', () => ({ restoreKnowledgeBase: mockRestoreKnowledgeBase })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performRestoreKnowledgeBase } from '@/lib/knowledge/orchestration/restore' + +const ARCHIVED = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1', userId: 'owner' } + +describe('performRestoreKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('audits the restore against the acting user', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockResolvedValue(undefined) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + requestId: 'req-1', + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1', workspaceId: 'ws-1' }) + ) + }) + + it('reports a knowledge base that is not archived as a conflict', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'Knowledge base is not archived') + ) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('reports bad input as validation, which the narrow local alias could not express', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockRejectedValue( + new OrchestrationError('validation', 'Folder not found in this workspace') + ) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('reports a knowledge base that does not exist as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(mockRestoreKnowledgeBase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/restore.ts b/apps/sim/lib/knowledge/orchestration/restore.ts new file mode 100644 index 00000000000..c162dc160b7 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/restore.ts @@ -0,0 +1,88 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { knowledgeBase } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { generateRequestId } from '@/lib/core/utils/request' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { restoreKnowledgeBase } from '@/lib/knowledge/service' + +const logger = createLogger('KnowledgeBaseRestoreOrchestration') + +export interface RestorableKnowledgeBase { + id: string + name: string + workspaceId: string | null + userId: string +} + +export interface PerformRestoreKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBaseId: string +} + +export type PerformRestoreKnowledgeBaseResult = KnowledgeOrchestrationResult<{ + knowledgeBase: RestorableKnowledgeBase +}> + +/** + * Loads an archived knowledge base's identity so the caller can authorize the + * restore. Reads regardless of `deletedAt` — an archived row is exactly what a + * restore targets. + */ +export async function getRestorableKnowledgeBase( + knowledgeBaseId: string +): Promise { + const [kb] = await db + .select({ + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + .limit(1) + + return kb ?? null +} + +/** Un-archives a knowledge base and its documents and connectors. */ +export async function performRestoreKnowledgeBase( + params: PerformRestoreKnowledgeBaseParams +): Promise { + const { knowledgeBaseId, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const kb = await getRestorableKnowledgeBase(knowledgeBaseId) + if (!kb) { + return fail('Knowledge base not found', 'not_found') + } + + try { + await restoreKnowledgeBase(knowledgeBaseId, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Restore knowledge base ${knowledgeBaseId}`) + } + + logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_RESTORED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: kb.name, + description: `Restored knowledge base "${kb.name}"`, + metadata: { source, knowledgeBaseName: kb.name }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: kb } +} diff --git a/apps/sim/lib/knowledge/orchestration/shared.ts b/apps/sim/lib/knowledge/orchestration/shared.ts new file mode 100644 index 00000000000..2c2581deee7 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/shared.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' + +const logger = createLogger('KnowledgeOrchestration') + +/** + * Which surface an operation came in through. Recorded in the audit metadata so + * the audit list still distinguishes a knowledge base the UI created from one an + * API key or the agent created, now that all three share one description. + */ +export type KnowledgeOperationSource = 'ui' | 'api' | 'agent' + +/** The acting user, plus the labels the audit row displays when it has them. */ +export interface KnowledgeActor { + userId: string + actorName?: string | null + actorEmail?: string | null + source: KnowledgeOperationSource +} + +/** Fields every knowledge orchestration function accepts. */ +export interface KnowledgeOperationContext extends KnowledgeActor { + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface KnowledgeOrchestrationFailure { + success: false + error: string + errorCode: OrchestrationErrorCode +} + +/** + * Every knowledge orchestration function returns this shape. A discriminated + * union rather than an all-optional record, so `if (!outcome.success) return …` + * narrows the success branch and callers reach the payload without asserting it + * is there. + */ +export type KnowledgeOrchestrationResult = + | ({ success: true } & TData) + | KnowledgeOrchestrationFailure + +export function fail( + error: string, + errorCode: OrchestrationErrorCode +): KnowledgeOrchestrationFailure { + return { success: false, error, errorCode } +} + +/** + * Maps a thrown failure to its transport-neutral class. + * + * Every caller-fixable knowledge failure is an {@link OrchestrationError} + * subclass, so the class decides the status rather than the message wording. + * Anything unclassified stays a generic 500, which is what an unexpected fault + * should be, and is logged here because no layer above will see the cause. + */ +export function classifyKnowledgeFailure( + error: unknown, + requestId: string, + operation: string +): KnowledgeOrchestrationFailure { + const classified = asOrchestrationError(error) + if (classified) { + return fail(classified.message, classified.code) + } + logger.error(`[${requestId}] ${operation} failed`, { error }) + return fail(toError(error).message, 'internal') +} + +/** The audit fields carried from the actor, omitting labels the caller lacks. */ +export function auditActorFields(actor: KnowledgeActor) { + return { + actorId: actor.userId, + ...(actor.actorName !== undefined ? { actorName: actor.actorName } : {}), + ...(actor.actorEmail !== undefined ? { actorEmail: actor.actorEmail } : {}), + } +} diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index 8b9ca673f2d..7399b83aebe 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -71,7 +71,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'attacker' }) ).rejects.toMatchObject({ - code: 'KNOWLEDGE_BASE_FORBIDDEN', + code: 'forbidden', message: 'Only the knowledge base owner can remove it from a workspace', }) expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() @@ -82,7 +82,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'owner' }) - ).rejects.not.toBeInstanceOf(KnowledgeBasePermissionError) + ).resolves.not.toThrow() expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() }) @@ -95,7 +95,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { actorUserId: 'attacker', }) ).rejects.toMatchObject({ - code: 'KNOWLEDGE_BASE_FORBIDDEN', + code: 'forbidden', message: 'User does not have permission on the target workspace', }) expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index cbac1283b63..0991e17a5f3 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -10,7 +10,22 @@ import { import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' +import { + and, + type Column, + count, + eq, + exists, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from 'drizzle-orm' +import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { ensureUserStatsExists } from '@/lib/billing/core/usage' @@ -20,6 +35,7 @@ import { resolveStorageBillingContext, type StorageBillingContext, } from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import type { @@ -31,22 +47,39 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('KnowledgeBaseService') -export class KnowledgeBaseConflictError extends Error { - readonly code = 'KNOWLEDGE_BASE_EXISTS' as const +/** + * Every caller-fixable knowledge-base failure is an {@link OrchestrationError}, + * so `lib/knowledge/orchestration` classifies it by class and each surface maps + * that one class to its own status. Message text is then free to change without + * silently moving a 409 to a 400. + */ +export class KnowledgeBaseConflictError extends OrchestrationError { constructor(name: string) { - super(`A knowledge base named "${name}" already exists in this workspace`) + super('conflict', `A knowledge base named "${name}" already exists in this workspace`) + this.name = 'KnowledgeBaseConflictError' } } -export class KnowledgeBasePermissionError extends Error { - readonly code = 'KNOWLEDGE_BASE_FORBIDDEN' as const +export class KnowledgeBasePermissionError extends OrchestrationError { + constructor(message: string) { + super('forbidden', message) + this.name = 'KnowledgeBasePermissionError' + } } /** Raised when a caller files a knowledge base under a folder it may not use. */ -export class KnowledgeBaseFolderError extends Error { - readonly code = 'KNOWLEDGE_BASE_FOLDER_INVALID' as const +export class KnowledgeBaseFolderError extends OrchestrationError { constructor() { - super('Folder not found in this workspace') + super('validation', 'Folder not found in this workspace') + this.name = 'KnowledgeBaseFolderError' + } +} + +/** Raised when a knowledge base the caller named does not exist (or is archived). */ +export class KnowledgeBaseNotFoundError extends OrchestrationError { + constructor(knowledgeBaseId: string) { + super('not_found', `Knowledge base ${knowledgeBaseId} not found`) + this.name = 'KnowledgeBaseNotFoundError' } } @@ -90,13 +123,39 @@ type KnowledgeBaseStorageMove = } /** - * Get knowledge bases that a user can access + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so knowledge bases sharing a + * name still come back in a stable order. + */ +const KNOWLEDGE_BASE_SORTS = { + name: [knowledgeBase.name, knowledgeBase.createdAt], + createdAt: [knowledgeBase.createdAt], + updatedAt: [knowledgeBase.updatedAt, knowledgeBase.createdAt], +} satisfies Record + +interface GetKnowledgeBasesOptions { + /** Restrict to one knowledge-base folder. */ + /** `undefined` lists every folder, `null` lists only workspace-root resources. */ + folderId?: string | null + /** Case-insensitive substring match on the knowledge base name. */ + search?: string + sortBy?: V2KnowledgeBaseSortBy + sortOrder?: V2SortOrder +} + +/** + * Get knowledge bases that a user can access. + * + * Filter and sort are applied in the query, so a search costs one narrowed scan + * rather than materializing every knowledge base the caller can reach. */ export async function getKnowledgeBases( userId: string, workspaceId?: string | null, - scope: KnowledgeBaseScope = 'active' + scope: KnowledgeBaseScope = 'active', + options?: GetKnowledgeBasesOptions ): Promise { + const { folderId, search, sortBy = 'createdAt', sortOrder = 'asc' } = options ?? {} const scopeCondition = scope === 'all' ? undefined @@ -143,6 +202,12 @@ export async function getKnowledgeBases( .where( and( scopeCondition, + folderId === undefined + ? undefined + : folderId === null + ? isNull(knowledgeBase.folderId) + : eq(knowledgeBase.folderId, folderId), + searchFilter(knowledgeBase.name, search), workspaceId ? // When filtering by workspace or( @@ -165,7 +230,7 @@ export async function getKnowledgeBases( ) ) .groupBy(knowledgeBase.id) - .orderBy(knowledgeBase.createdAt) + .orderBy(...listOrderBy(KNOWLEDGE_BASE_SORTS[sortBy], sortOrder)) const kbIds = knowledgeBasesWithCounts.map((kb) => kb.id) @@ -341,7 +406,7 @@ export async function updateKnowledgeBase( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!snapshot) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } effectiveWorkspaceId = snapshot.workspaceId } @@ -365,7 +430,7 @@ export async function updateKnowledgeBase( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!kbSnapshot) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } const sourceWorkspaceId = kbSnapshot.workspaceId ?? null const destinationWorkspaceId = updates.workspaceId ?? null @@ -450,7 +515,7 @@ export async function updateKnowledgeBase( .limit(1) if (!currentKb) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } if (storageMove && (currentKb.workspaceId ?? null) !== storageMove.sourceWorkspaceId) { @@ -666,7 +731,7 @@ export async function updateKnowledgeBase( .limit(1) if (updatedKb.length === 0) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } logger.info(`[${requestId}] Updated knowledge base: ${knowledgeBaseId}`) @@ -809,18 +874,21 @@ export async function restoreKnowledgeBase( .limit(1) if (!kb) { - throw new Error('Knowledge base not found') + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } if (!kb.deletedAt) { - throw new Error('Knowledge base is not archived') + throw new OrchestrationError('conflict', 'Knowledge base is not archived') } if (kb.workspaceId) { const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(kb.workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore knowledge base into an archived workspace') + throw new OrchestrationError( + 'conflict', + 'Cannot restore knowledge base into an archived workspace' + ) } } diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts new file mode 100644 index 00000000000..789a86a4454 --- /dev/null +++ b/apps/sim/lib/mcp/queries.ts @@ -0,0 +1,88 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +import { and, type Column, eq, isNull } from 'drizzle-orm' +import type { V2McpServerSortBy } from '@/lib/api/contracts/v2/mcp-servers' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' + +/** + * Workspace-scoped MCP server reads. The lifecycle functions in + * `lib/mcp/orchestration` cover the write paths; these cover the read paths the + * public API needs without duplicating the scoping predicate per route. + */ + +export type McpServerRow = typeof mcpServers.$inferSelect + +/** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so servers sharing a name or a + * timestamp still come back in a stable order. + */ +const MCP_SERVER_SORTS = { + name: [mcpServers.name, mcpServers.id], + createdAt: [mcpServers.createdAt, mcpServers.id], + updatedAt: [mcpServers.updatedAt, mcpServers.id], +} satisfies Record + +export async function listWorkspaceMcpServers(params: { + workspaceId: string + /** Case-insensitive substring match on the server name. */ + search?: string + sortBy?: V2McpServerSortBy + sortOrder?: V2SortOrder +}): Promise { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params + return db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt), + searchFilter(mcpServers.name, params.search) + ) + ) + .orderBy(...listOrderBy(MCP_SERVER_SORTS[sortBy], sortOrder)) +} + +/** A single live MCP server, or null when it does not exist in this workspace. */ +export async function getWorkspaceMcpServer(params: { + workspaceId: string + serverId: string +}): Promise { + const [row] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, params.serverId), + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + return row ?? null +} + +/** + * The state of the row occupying the deterministic id derived from a workspace + * and URL, or null when the id is free. + * + * The soft-deleted case has to be distinguished rather than merged into "taken": + * `performCreateMcpServer` revives such a row instead of inserting alongside it, + * so reporting it as a duplicate would make a soft-deleted URL permanently + * unusable — it cannot be fetched or patched either, since those resolve live + * rows only. + */ +export async function getMcpServerIdState(params: { + workspaceId: string + serverId: string +}): Promise<{ deleted: boolean } | null> { + const [row] = await db + .select({ deletedAt: mcpServers.deletedAt }) + .from(mcpServers) + .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + return row ? { deleted: row.deletedAt !== null } : null +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index aa520209e87..d6383133d8f 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -197,20 +197,20 @@ export interface PostHogEventMap { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_updated: { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_deleted: { skill_id: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_shared: { diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index 75baa13aaf4..4be469d5ffe 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import type { FolderResourceType } from '@/lib/api/contracts/folders' import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { restoreFolder } from '@/lib/folders/orchestration' import { getRestorableKnowledgeBase, performRestoreKnowledgeBase, @@ -11,7 +12,7 @@ import { import { performRestoreTable } from '@/lib/table/orchestration' import { getTableById } from '@/lib/table/service' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRestoreFolder, performRestoreWorkflow } from '@/lib/workflows/orchestration' +import { performRestoreWorkflow } from '@/lib/workflows/orchestration' import { getWorkflowById } from '@/lib/workflows/utils' import { performRestoreWorkspaceFile, @@ -148,10 +149,11 @@ export async function performRestoreResource( const result = await performRestoreKnowledgeBase({ knowledgeBaseId: id, userId, + source: 'agent', requestId, }) - if (!result.success || !result.knowledgeBase) { - return { success: false, error: result.error || 'Failed to restore knowledge base' } + if (!result.success) { + return { success: false, error: result.error } } logger.info('Knowledge base restored via restore_resource', { knowledgeBaseId: id }) @@ -167,7 +169,7 @@ export async function performRestoreResource( return { success: false, error: 'Folder not found' } } - const result = await performRestoreFolder({ + const result = await restoreFolder({ folderId: id, workspaceId, userId, diff --git a/apps/sim/lib/skills/orchestration/index.ts b/apps/sim/lib/skills/orchestration/index.ts new file mode 100644 index 00000000000..48bf621ec27 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/index.ts @@ -0,0 +1,12 @@ +export { + type PerformCreateSkillParams, + type PerformDeleteSkillParams, + type PerformSkillResult, + type PerformUpdateSkillParams, + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + type SkillOrchestrationErrorCode, + type SkillWriteSource, + statusForSkillOrchestrationError, +} from './skill-lifecycle' diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts new file mode 100644 index 00000000000..5cb13daf037 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -0,0 +1,359 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { skill } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import type { z } from 'zod' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext } from '@/lib/skills/access' +import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' + +const logger = createLogger('SkillOrchestration') + +/** + * Single authority for skill create/update/delete. + * + * Before this module the API route owned the create-vs-update split, the + * built-in guard, the per-skill editor check, the field limits (which lived + * only in the route's Zod contract), and the audit — so the copilot's + * `manage_skill`, which calls `upsertSkills` directly, bypassed all of them. + * Every caller now goes through these functions and gets the same rules. + * + * Workspace-level authorization stays with the caller: each surface has already + * established workspace access by the time it gets here (session middleware, + * the v2 `resolveWorkspaceAccess`, the copilot's permission context). What is + * owned here is everything *per skill*. + */ + +/** + * Skills need a `forbidden` outcome the shared code set does not carry: a + * caller can hold workspace write and still not be an editor of a given skill. + */ +export type SkillOrchestrationErrorCode = OrchestrationErrorCode | 'forbidden' + +/** HTTP status for a skill orchestration failure, shared by every route surface. */ +export function statusForSkillOrchestrationError( + code: SkillOrchestrationErrorCode | undefined +): number { + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} + +type SkillRow = typeof skill.$inferSelect + +/** Which surface performed the write. Recorded on the audit entry and the analytics event. */ +export type SkillWriteSource = 'settings' | 'tool_input' | 'api' + +interface ActorMetadata { + actorName?: string | null + actorEmail?: string | null + source?: SkillWriteSource + request?: NextRequest +} + +export interface PerformCreateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + name: string + description: string + content: string +} + +export interface PerformUpdateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string + name?: string + description?: string + content?: string +} + +export interface PerformDeleteSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string +} + +export interface PerformSkillResult { + success: boolean + error?: string + errorCode?: SkillOrchestrationErrorCode + skill?: SkillRow +} + +function validationFailure(error: string): PerformSkillResult { + return { success: false, error, errorCode: 'validation' } +} + +/** First message from a failed field parse, or null when the value is valid. */ +function fieldError(schema: z.ZodType, value: unknown): string | null { + const parsed = schema.safeParse(value) + return parsed.success ? null : (parsed.error.issues[0]?.message ?? 'Invalid value') +} + +/** + * A workspace skill sharing a built-in's name silently shadows it everywhere the + * two lists are merged. Reject the collision at the write instead of resolving + * it at every read. + */ +function builtinNameCollision(name: string): string | null { + return getBuiltinSkillByName(name) + ? `The skill name "${name}" is reserved by a built-in skill` + : null +} + +/** + * Resolves the acting user's edit rights over an existing workspace skill. + * Returns the loaded row, or the failure to surface. + */ +async function resolveEditableSkill(params: { + workspaceId: string + userId: string + skillId: string +}): Promise<{ ok: true; skill: SkillRow } | { ok: false; result: PerformSkillResult }> { + if (isBuiltinSkillId(params.skillId)) { + return { + ok: false, + result: validationFailure('Built-in skills are read-only and cannot be modified'), + } + } + + const actor = await getSkillActorContext(params.skillId, params.userId) + if (!actor.skill || actor.skill.workspaceId !== params.workspaceId || !actor.hasWorkspaceAccess) { + return { + ok: false, + result: { success: false, error: 'Skill not found', errorCode: 'not_found' }, + } + } + if (!actor.canEdit) { + return { + ok: false, + result: { + success: false, + error: `Skill editor access required to modify "${actor.skill.name}"`, + errorCode: 'forbidden', + }, + } + } + return { ok: true, skill: actor.skill } +} + +/** + * `upsertSkills` reports name collisions and vanished ids as thrown Errors. + * Classify them rather than letting every caller re-match the message. + * + * The `23505` arm covers the race its in-transaction name `SELECT` cannot: two + * concurrent creates (or renames) both pass that check, and the loser is rejected + * by `skill_workspace_name_unique` as a raw Postgres error whose message matches + * nothing here — which would otherwise surface as a 500 for what is a conflict. + */ +function classifyUpsertError(error: unknown): PerformSkillResult { + const message = getErrorMessage(error, 'Failed to save skill') + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'That skill name is unavailable in this workspace', + errorCode: 'conflict', + } + } + if (message.includes('is unavailable')) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message.startsWith('Skill not found')) { + return { success: false, error: 'Skill not found', errorCode: 'not_found' } + } + logger.error('Skill upsert failed', { error: message }) + return { success: false, error: 'Failed to save skill', errorCode: 'internal' } +} + +type SkillLifecycleAction = 'created' | 'updated' | 'deleted' + +const AUDIT_ACTION = { + created: AuditAction.SKILL_CREATED, + updated: AuditAction.SKILL_UPDATED, + deleted: AuditAction.SKILL_DELETED, +} as const satisfies Record + +const AUDIT_VERB = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', +} as const satisfies Record + +function recordSkillEvent(params: { + action: SkillLifecycleAction + workspaceId: string + userId: string + skillId: string + skillName: string + actor: ActorMetadata +}): void { + const { action, workspaceId, userId, skillId, skillName, actor } = params + + recordAudit({ + workspaceId, + actorId: userId, + actorName: actor.actorName ?? undefined, + actorEmail: actor.actorEmail ?? undefined, + action: AUDIT_ACTION[action], + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + resourceName: skillName, + description: `${AUDIT_VERB[action]} skill "${skillName}"`, + metadata: { source: actor.source }, + request: actor.request, + }) + + // The delete event carries no skill_name — the skill no longer exists to name. + if (action === 'deleted') { + captureServerEvent( + userId, + 'skill_deleted', + { skill_id: skillId, workspace_id: workspaceId, source: actor.source }, + { groups: { workspace: workspaceId } } + ) + return + } + + captureServerEvent( + userId, + action === 'created' ? 'skill_created' : 'skill_updated', + { + skill_id: skillId, + skill_name: skillName, + workspace_id: workspaceId, + source: actor.source, + }, + { groups: { workspace: workspaceId } } + ) +} + +export async function performCreateSkill( + params: PerformCreateSkillParams +): Promise { + const invalid = + fieldError(skillNameSchema, params.name) ?? + fieldError(skillDescriptionSchema, params.description) ?? + fieldError(skillContentSchema, params.content) ?? + builtinNameCollision(params.name) + if (invalid) return validationFailure(invalid) + + let created: { id: string; name: string } | undefined + try { + const { touched } = await upsertSkills({ + skills: [{ name: params.name, description: params.description, content: params.content }], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + created = touched[0] + } catch (error) { + return classifyUpsertError(error) + } + + if (!created) { + logger.error('Skill create returned no touched row', { workspaceId: params.workspaceId }) + return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + } + + recordSkillEvent({ + action: 'created', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: created.id, + skillName: created.name, + actor: params, + }) + + const row = await getSkillById({ skillId: created.id, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + return { success: true, skill: row } +} + +export async function performUpdateSkill( + params: PerformUpdateSkillParams +): Promise { + if ( + params.name === undefined && + params.description === undefined && + params.content === undefined + ) { + return validationFailure('At least one of name, description, or content is required') + } + + const invalid = + (params.name !== undefined + ? (fieldError(skillNameSchema, params.name) ?? builtinNameCollision(params.name)) + : null) ?? + (params.description !== undefined + ? fieldError(skillDescriptionSchema, params.description) + : null) ?? + (params.content !== undefined ? fieldError(skillContentSchema, params.content) : null) + if (invalid) return validationFailure(invalid) + + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + try { + await upsertSkills({ + skills: [ + { + id: params.skillId, + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.description !== undefined ? { description: params.description } : {}), + ...(params.content !== undefined ? { content: params.content } : {}), + }, + ], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + } catch (error) { + return classifyUpsertError(error) + } + + const row = await getSkillById({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'updated', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: row.id, + skillName: row.name, + actor: params, + }) + + return { success: true, skill: row } +} + +export async function performDeleteSkill( + params: PerformDeleteSkillParams +): Promise { + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!deleted) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'deleted', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: params.skillId, + skillName: resolved.skill.name, + actor: params, + }) + + return { success: true, skill: resolved.skill } +} diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index af4aa9ee168..824b102d17b 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -9,6 +9,7 @@ import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribu import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications' import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBillingDisabledTableLimits, getTablePlanLimits, @@ -183,12 +184,15 @@ function cacheLimits(workspaceId: string, limits: TablePlanLimits): void { /** * Thrown by {@link assertRowCapacity} when a write would exceed the workspace's - * current plan row limit. The message includes the lowercase `row limit` token so - * `rowWriteErrorResponse` maps it to a 400 toast carrying the real reason. + * current plan row limit. Typed as a `validation` failure so the routes answer + * 400 with the real reason — the message used to have to carry a lowercase + * `row limit` token for a substring match to find it, which made the wording + * load-bearing. */ -export class TableRowLimitError extends Error { +export class TableRowLimitError extends OrchestrationError { constructor(readonly limit: number) { super( + 'validation', `This table has reached its row limit (${limit.toLocaleString('en-US')} rows) on your current plan.` ) this.name = 'TableRowLimitError' diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 2ed1dd3cbda..327c87d264d 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -7,6 +7,12 @@ * * Use this for: workflow executor, background jobs, testing business logic. * Use API routes for: HTTP requests, frontend clients. + * + * Caller-fixable failures throw {@link OrchestrationError} carrying the class + * the layers above map to a status, so no caller has to search the message for + * a phrase. A duplicate column name is deliberately `validation` rather than + * `conflict` — both the v1 route and the orchestration have always answered 400 + * for it, and this refactor is not the place to change a published status. */ import { db } from '@sim/db' @@ -14,6 +20,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { and, count, eq, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' import { columnTypeById, @@ -81,30 +88,34 @@ export async function addTableColumn( return withLockedTable(tableId, async (table, trx) => { assertSchemaMutable(table) if (!NAME_PATTERN.test(column.name)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` ) } if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` ) } const schema = table.schema if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { - throw new Error(`Column "${column.name}" already exists`) + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) } if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( + throw new OrchestrationError( + 'validation', `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` ) } @@ -124,7 +135,10 @@ export async function addTableColumn( const columnValidation = validateColumnDefinition(newColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const newColumnId = getColumnId(newColumn) @@ -194,13 +208,15 @@ export async function renameColumn( return withLockedTable(data.tableId, async (table, trx) => { assertSchemaMutable(table) if (!NAME_PATTERN.test(data.newName)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` ) } if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } @@ -208,7 +224,7 @@ export async function renameColumn( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) if (columnIndex === -1) { - throw new Error(`Column "${data.oldName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) } if ( @@ -216,7 +232,7 @@ export async function renameColumn( (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() ) ) { - throw new Error(`Column "${data.newName}" already exists`) + throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) } const targetColumn = schema.columns[columnIndex] @@ -331,11 +347,11 @@ export async function deleteColumn( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } if (schema.columns.length <= 1) { - throw new Error('Cannot delete the last column in a table') + throw new OrchestrationError('validation', 'Cannot delete the last column in a table') } const targetColumn = schema.columns[columnIndex] @@ -423,12 +439,12 @@ export async function deleteColumns( } if (notFound.length > 0) { - throw new Error(`Columns not found: ${notFound.join(', ')}`) + throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) } const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) if (remaining.length === 0) { - throw new Error('Cannot delete all columns from a table') + throw new OrchestrationError('validation', 'Cannot delete all columns from a table') } // For each group, drop outputs whose column (by id) is being deleted. Groups @@ -506,26 +522,32 @@ async function applyConstraints( if (data.required === undefined && data.unique === undefined) return column if (column.workflowGroupId) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.` ) } if (data.required === true && !column.required) { const emptyCount = await countEmptyCells(trx, tableId, columnKey) if (emptyCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set column "${column.name}" as required: ${emptyCount} row(s) have null, missing, or empty values` ) } } if (data.unique === true && !column.unique) { if (!columnTypeOf(column).supportsUnique) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set column "${column.name}" as unique: ${column.type} columns compare stored values that would allow only one row per value.` ) } if (await hasDuplicateValues(trx, tableId, columnKey)) { - throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`) + throw new OrchestrationError( + 'validation', + `Cannot set column "${column.name}" as unique: duplicate values exist` + ) } } return { @@ -592,17 +614,19 @@ export function applyPendingRename( if (newName === undefined || newName === column.name) return column if (!NAME_PATTERN.test(newName)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` ) } if (newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (columns.some((c, i) => i !== columnIndex && c.name.toLowerCase() === newName.toLowerCase())) { - throw new Error(`Column "${newName}" already exists`) + throw new OrchestrationError('validation', `Column "${newName}" already exists`) } return { ...column, name: newName } } @@ -688,7 +712,8 @@ export async function updateColumnType( await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` ) } @@ -696,7 +721,7 @@ export async function updateColumnType( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] @@ -714,7 +739,8 @@ export async function updateColumnType( data.multiple !== undefined || data.currencyCode !== undefined if (carriesOtherWork) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` ) } @@ -760,7 +786,8 @@ export async function updateColumnType( if (targetRequired) { const emptyCount = await countEmptyCells(trx, data.tableId, columnKey) if (emptyCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` ) } @@ -828,13 +855,15 @@ export async function updateColumnType( } if (blankCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` ) } if (incompatibleCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` ) } @@ -846,7 +875,10 @@ export async function updateColumnType( const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } @@ -879,7 +911,8 @@ export async function updateColumnType( // irrecoverably rewritten. if (data.unique === true && !column.unique) { if (await hasDuplicateValues(trx, data.tableId, columnKey)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` ) } @@ -926,7 +959,7 @@ export async function updateColumnConstraints( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] @@ -966,12 +999,15 @@ export async function updateColumnOptions( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] if (column.type !== 'select') { - throw new Error(`Cannot set options on column "${column.name}" of type "${column.type}"`) + throw new OrchestrationError( + 'validation', + `Cannot set options on column "${column.name}" of type "${column.type}"` + ) } const columnKey = getColumnId(column) @@ -984,7 +1020,10 @@ export async function updateColumnOptions( } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const nextMultiple = !!(data.multiple ?? column.multiple) @@ -1033,7 +1072,8 @@ export async function updateColumnOptions( wasMultiple ) if (strandedCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` ) } @@ -1060,7 +1100,8 @@ export async function updateColumnOptions( } if (multiValuedCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` ) } @@ -1132,12 +1173,15 @@ export async function updateColumnCurrency( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] if (column.type !== 'currency') { - throw new Error(`Cannot set currency on column "${column.name}" of type "${column.type}"`) + throw new OrchestrationError( + 'validation', + `Cannot set currency on column "${column.name}" of type "${column.type}"` + ) } const updatedColumn: ColumnDefinition = { @@ -1146,7 +1190,10 @@ export async function updateColumnCurrency( } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const constrained = await applyConstraints( diff --git a/apps/sim/lib/table/export-stream.ts b/apps/sim/lib/table/export-stream.ts new file mode 100644 index 00000000000..abf2d027820 --- /dev/null +++ b/apps/sim/lib/table/export-stream.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { neutralizeCsvFormula } from '@/lib/core/utils/csv' +import { namedRowMapper } from '@/lib/table/cell-format' +import { getColumnId } from '@/lib/table/column-keys' +import { formatCsvCell } from '@/lib/table/export-format' +import { queryRows } from '@/lib/table/rows/service' +import type { TableDefinition, TableExportFormat } from '@/lib/table/types' + +const logger = createLogger('TableExportStream') + +const EXPORT_BATCH_SIZE = 1000 + +export function sanitizeExportFilename(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return cleaned || 'table' +} + +function escapeCsvField(field: string): string { + return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field +} + +function toCsvRow(values: string[]): string { + return values.map(escapeCsvField).join(',') +} + +export function exportContentType(format: TableExportFormat): string { + return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' +} + +/** + * Synchronous table export as a byte stream, shared by the first-party and + * public surfaces so both emit byte-identical files. + * + * Rows are paged out as they are read rather than buffered, so a table larger + * than memory still exports — at the cost of a mid-stream failure being + * unrecoverable (the response has already started). Large tables should use the + * background export instead. + */ +export function createTableExportStream( + table: TableDefinition, + format: TableExportFormat, + requestId: string +): ReadableStream { + const columns = table.schema.columns + // Stored row data is id-keyed; CSV headers and JSON keys are display names, so + // translate id → name on the way out (export is a name-friendly boundary). + const toNamedRow = namedRowMapper(columns) + + return new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + try { + if (format === 'csv') { + controller.enqueue( + encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) + ) + } else { + controller.enqueue(encoder.encode('[')) + } + + let offset = 0 + let firstJsonRow = true + while (true) { + const result = await queryRows( + table, + { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, + requestId + ) + + for (const row of result.rows) { + if (format === 'csv') { + const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) + controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) + } else { + const prefix = firstJsonRow ? '' : ',' + firstJsonRow = false + controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) + } + } + + // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, + // so a short page does NOT mean the export is done — only a null cursor does. + if (!result.nextCursor) break + offset += result.rows.length + } + + if (format === 'json') controller.enqueue(encoder.encode(']')) + controller.close() + + logger.info(`[${requestId}] Exported table ${table.id}`, { + format, + rowCount: table.rowCount, + }) + } catch (err) { + logger.error(`[${requestId}] Export failed for table ${table.id}`, err) + controller.error(err) + } + }, + }) +} diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 84664e077b2..f8e5fd8d0c6 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -9,6 +9,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' @@ -77,11 +78,17 @@ export async function bulkInsertImportBatch( for (let i = 0; i < data.rows.length; i++) { const sizeValidation = validateRowSize(data.rows[i]) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(data.rows[i], table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -94,7 +101,8 @@ export async function bulkInsertImportBatch( db ) if (!uniqueResult.valid) { - throw new Error( + throw new OrchestrationError( + 'validation', uniqueResult.errors.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`).join('; ') ) } diff --git a/apps/sim/lib/table/import-runner.test.ts b/apps/sim/lib/table/import-runner.test.ts index df66a5e0984..b0fae115c83 100644 --- a/apps/sim/lib/table/import-runner.test.ts +++ b/apps/sim/lib/table/import-runner.test.ts @@ -59,6 +59,7 @@ vi.mock('@/app/api/table/utils', () => ({ normalizeColumn: (col: unknown) => col, })) +import { CSV_MAX_BATCH_SIZE_BYTES } from '@/lib/table/import' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' const table = { @@ -136,4 +137,27 @@ describe('runTableImport source-file cleanup', () => { expect(mockMarkJobReady).toHaveBeenCalled() expect(mockDeleteFile).not.toHaveBeenCalled() }) + + it('flushes retained records before the serialized batch byte budget is exceeded', async () => { + const cell = 'x'.repeat(390 * 1024) + const csv = `name\n${Array.from({ length: 14 }, () => cell).join('\n')}\n` + mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(csv) }) + mockDownloadFileStream.mockResolvedValue(Readable.from(csv)) + mockBulkInsertImportBatch.mockImplementation(async ({ rows }) => ({ + inserted: rows.length, + lastOrderKey: 'a1', + })) + + await runTableImport(buildPayload()) + + expect(mockBulkInsertImportBatch).toHaveBeenCalledTimes(2) + for (const [input] of mockBulkInsertImportBatch.mock.calls) { + const retainedBytes = input.rows.reduce( + (total: number, row: Record) => + total + Buffer.byteLength(JSON.stringify(row), 'utf8'), + 0 + ) + expect(retainedBytes).toBeLessThanOrEqual(CSV_MAX_BATCH_SIZE_BYTES) + } + }) }) diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 93ae112d415..669fe7169ee 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -7,6 +7,7 @@ import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, CSV_MAX_BATCH_SIZE, + CSV_MAX_BATCH_SIZE_BYTES, CSV_SCHEMA_SAMPLE_SIZE, type CsvHeaderMapping, coerceRowsForTable, @@ -77,6 +78,8 @@ export interface TableImportPayload { * worker never needs a settings lookup. */ timezone?: string + /** Storage context for the source object. Legacy imports default to `workspace`. */ + storageContext?: 'workspace' | 'table-import' } /** @@ -89,12 +92,14 @@ export interface TableImportPayload { */ export async function runTableImport(payload: TableImportPayload): Promise { const { importId, tableId, workspaceId, userId, fileKey, fileName, delimiter, mode } = payload + const storageContext = payload.storageContext ?? 'workspace' const requestId = generateId().slice(0, 8) // Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks // open until it times out. let source: Readable | undefined try { + if (!(await updateJobProgress(tableId, 0, importId))) throw new ImportSupersededError() const loaded = await getTableById(tableId, { includeArchived: true }) if (!loaded) throw new Error(`Import target table ${tableId} not found`) const table = loaded @@ -131,10 +136,10 @@ export async function runTableImport(payload: TableImportPayload): Promise // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on // the local dev provider, in which case the bar stays indeterminate (rows still show). - const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0 + const totalBytes = (await headObject(fileKey, storageContext))?.size ?? 0 // Stream the file rather than buffering it — a ~1M-row import must never be held in memory. - source = await downloadFileStream({ key: fileKey, context: 'workspace' }) + source = await downloadFileStream({ key: fileKey, context: storageContext }) // The kickoff route's extension-derived delimiter is only the fallback — the separator is // sniffed from the file's head so semicolon/pipe exports don't collapse into one column. @@ -174,8 +179,10 @@ export async function runTableImport(payload: TableImportPayload): Promise let headerToColumn: Map | null = null let inserted = 0 let lastReported = 0 - const sample: Record[] = [] + let sample: Record[] = [] + let sampleBytes = 0 let batch: Record[] = [] + let batchBytes = 0 /** * Resolve the schema + header→column mapping from the buffered sample (runs once). @@ -183,6 +190,9 @@ export async function runTableImport(payload: TableImportPayload): Promise * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { + if (!(await updateJobProgress(tableId, inserted, importId))) { + throw new ImportSupersededError() + } const headers = csvHeaders if (mode === 'create') { @@ -304,19 +314,43 @@ export async function runTableImport(payload: TableImportPayload): Promise let ready = false for await (const record of parser as AsyncIterable>) { + const recordBytes = Buffer.byteLength(JSON.stringify(record), 'utf8') + if (recordBytes > CSV_MAX_BATCH_SIZE_BYTES) { + throw new Error(`CSV record exceeds ${CSV_MAX_BATCH_SIZE_BYTES} serialized bytes`) + } + if (!ready) { - sample.push(record) - if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + if (sample.length > 0 && sampleBytes + recordBytes > CSV_MAX_BATCH_SIZE_BYTES) { await resolveSetup() await flush(sample) + sample = [] + sampleBytes = 0 ready = true + } else { + sample.push(record) + sampleBytes += recordBytes + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE || sampleBytes >= CSV_MAX_BATCH_SIZE_BYTES) { + await resolveSetup() + await flush(sample) + sample = [] + sampleBytes = 0 + ready = true + } + continue } - continue + } + + if (batch.length > 0 && batchBytes + recordBytes > CSV_MAX_BATCH_SIZE_BYTES) { + await flush(batch) + batch = [] + batchBytes = 0 } batch.push(record) - if (batch.length >= CSV_MAX_BATCH_SIZE) { + batchBytes += recordBytes + if (batch.length >= CSV_MAX_BATCH_SIZE || batchBytes >= CSV_MAX_BATCH_SIZE_BYTES) { await flush(batch) batch = [] + batchBytes = 0 } } @@ -433,7 +467,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // import is terminal so the workspace bucket doesn't accumulate. Best-effort. Skipped for // persistent workspace files (deleteSourceFile: false). if (payload.deleteSourceFile !== false) { - await deleteFile({ key: fileKey, context: 'workspace' }).catch((err) => { + await deleteFile({ key: fileKey, context: storageContext }).catch((err) => { logger.warn(`[${requestId}] Failed to delete imported file`, { fileKey, err }) }) } diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 4ef9a6ef375..f58565ee118 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -8,6 +8,7 @@ import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { buildAutoMapping, CSV_DELIMITER_SNIFF_BYTES, + CSV_MAX_RECORD_SIZE_BYTES, CsvImportValidationError, coerceRowsForTable, coerceValue, @@ -336,12 +337,24 @@ describe('import', () => { const rows = await parseViaStream('name,age\nAlice,30\n') expect(Object.keys(rows[0])).toEqual(['name', 'age']) }) + + it('rejects a record larger than the parser byte budget', async () => { + const oversizedValue = 'x'.repeat(CSV_MAX_RECORD_SIZE_BYTES * 2) + + await expect(parseViaStream(`value\n${oversizedValue}\n`)).rejects.toThrow( + new RegExp(`maximum number of tolerated bytes of ${CSV_MAX_RECORD_SIZE_BYTES}`) + ) + }) }) describe('csvParseOptions', () => { it('sets bom and the delimiter, with a header-capturing columns callback', () => { const options = csvParseOptions('\t') - expect(options).toMatchObject({ bom: true, delimiter: '\t' }) + expect(options).toMatchObject({ + bom: true, + delimiter: '\t', + max_record_size: CSV_MAX_RECORD_SIZE_BYTES, + }) expect(typeof options.columns).toBe('function') }) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 02c8a6e431a..e6f597e67b7 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -12,6 +12,7 @@ */ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getColumnId } from '@/lib/table/column-keys' import type { ColumnType } from '@/lib/table/column-types' import { parseCurrencyInput } from '@/lib/table/currency' @@ -33,6 +34,9 @@ export type CsvDelimiter = (typeof CSV_DELIMITER_CANDIDATES)[number] */ export const CSV_DELIMITER_SNIFF_BYTES = 64 * 1024 +/** Maximum characters buffered for one CSV record before parsing fails. */ +export const CSV_MAX_RECORD_SIZE_BYTES = 1024 * 1024 + /** * Single source of truth for the `csv-parse` options used by both the buffered * sync parser and the streaming parser. @@ -61,9 +65,13 @@ export function csvParseOptions( relax_column_count: true, relax_quotes: true, skip_records_with_error: true, + on_skip(error) { + if (error?.code === 'CSV_MAX_RECORD_SIZE') throw error + }, cast: false, bom: true, delimiter, + max_record_size: CSV_MAX_RECORD_SIZE_BYTES, } } @@ -219,9 +227,14 @@ export const CSV_SCHEMA_SAMPLE_SIZE = 100 */ export const CSV_MAX_BATCH_SIZE = 5000 +/** Maximum serialized CSV row data retained before an import batch is flushed. */ +export const CSV_MAX_BATCH_SIZE_BYTES = 5 * 1024 * 1024 + /** Maximum CSV/TSV file size accepted by import routes (25 MB). */ export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 +export const CSV_MAX_FILE_SIZE_MESSAGE = `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB` + /** * Error thrown when the user-supplied mapping or CSV does not line up with the * target table. Callers should translate this into a 400 response. @@ -274,11 +287,11 @@ export async function parseCsvBuffer( const parsed = parse(text, options) as unknown as Record[] if (parsed.length === 0) { - throw new Error('CSV file has no data rows') + throw new OrchestrationError('validation', 'CSV file has no data rows') } if (headers.length === 0) { - throw new Error('CSV file has no headers') + throw new OrchestrationError('validation', 'CSV file has no headers') } return { headers, rows: parsed } @@ -648,15 +661,18 @@ export function parseJsonRows(buffer: Buffer | string): { const text = typeof buffer === 'string' ? buffer : buffer.toString('utf-8') const parsed = JSON.parse(text) if (!Array.isArray(parsed)) { - throw new Error('JSON file must contain an array of objects') + throw new OrchestrationError('validation', 'JSON file must contain an array of objects') } if (parsed.length === 0) { - throw new Error('JSON file contains an empty array') + throw new OrchestrationError('validation', 'JSON file contains an empty array') } const headerSet = new Set() for (const row of parsed) { if (typeof row !== 'object' || row === null || Array.isArray(row)) { - throw new Error('Each element in the JSON array must be a plain object') + throw new OrchestrationError( + 'validation', + 'Each element in the JSON array must be a plain object' + ) } for (const key of Object.keys(row)) headerSet.add(key) } @@ -685,5 +701,8 @@ export async function parseFileRows( ) return parseCsvBuffer(buffer, delimiter) } - throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`) + throw new OrchestrationError( + 'validation', + `Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json` + ) } diff --git a/apps/sim/lib/table/orchestration/columns.test.ts b/apps/sim/lib/table/orchestration/columns.test.ts new file mode 100644 index 00000000000..d99eff54342 --- /dev/null +++ b/apps/sim/lib/table/orchestration/columns.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + * + * The column-update guards. These used to live in four callers (UI route, v1, + * v2, copilot tool) and had drifted apart; they are asserted here once. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockRenameColumn, + mockUpdateColumnType, + mockUpdateColumnOptions, + mockUpdateColumnConstraints, + mockUpdateColumnCurrency, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockRenameColumn: vi.fn(), + mockUpdateColumnType: vi.fn(), + mockUpdateColumnOptions: vi.fn(), + mockUpdateColumnConstraints: vi.fn(), + mockUpdateColumnCurrency: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/table/columns/service', () => ({ + renameColumn: mockRenameColumn, + updateColumnConstraints: mockUpdateColumnConstraints, + updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnOptions: mockUpdateColumnOptions, + updateColumnType: mockUpdateColumnType, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { performUpdateTableColumn } from '@/lib/table/orchestration/columns' + +const SELECT_COLUMN = { + id: 'col-1', + name: 'Status', + type: 'select' as const, + options: [{ id: 'opt_open', name: 'Open' }], +} +const TEXT_COLUMN = { id: 'col-2', name: 'Priority', type: 'text' as const } + +const TABLE = { + id: 'table-1', + name: 'Tasks', + workspaceId: 'ws-1', + schema: { columns: [SELECT_COLUMN, TEXT_COLUMN] }, +} as unknown as TableDefinition + +const UPDATED = { schema: { columns: [SELECT_COLUMN] } } as unknown as TableDefinition + +function run(updates: Record, columnName = 'Status') { + return performUpdateTableColumn({ + table: TABLE, + columnName, + userId: 'user-1', + updates, + requestId: 'req-1', + }) +} + +describe('performUpdateTableColumn', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRenameColumn.mockResolvedValue(UPDATED) + mockUpdateColumnType.mockResolvedValue(UPDATED) + mockUpdateColumnOptions.mockResolvedValue(UPDATED) + mockUpdateColumnConstraints.mockResolvedValue(UPDATED) + mockUpdateColumnCurrency.mockResolvedValue(UPDATED) + }) + + it('refuses to make a select column unique before writing anything', async () => { + // Each write is its own locked transaction, so an un-gated constraint write + // commits the earlier writes and then throws, half-applying the change. + const result = await run({ unique: true }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + }) + + it('refuses a conversion to select that is also made unique', async () => { + const result = await run({ type: 'select', options: ['Done'], unique: true }, 'Priority') + + expect(result.errorCode).toBe('validation') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('routes an unchanged type with options to the options update', async () => { + // updateColumnType early-returns on an unchanged type and would drop them. + await run({ type: 'select', options: ['Open', 'Closed'] }) + + expect(mockUpdateColumnType).not.toHaveBeenCalled() + // Addressed by stable id so a rename folded into the write can't break it. + expect(mockUpdateColumnOptions).toHaveBeenCalledWith( + expect.objectContaining({ columnName: 'col-1' }), + 'req-1' + ) + }) + + it('reuses the id of an option resent by name so its cells survive', async () => { + await run({ options: ['Open', 'Blocked'] }) + + const [{ options }] = mockUpdateColumnOptions.mock.calls[0] + expect(options[0]).toEqual({ id: 'opt_open', name: 'Open' }) + expect(options[1].id).not.toBe('opt_open') + }) + + it('carries options and required through a real type change', async () => { + await run({ type: 'select', options: ['Done'], required: true }, 'Priority') + + expect(mockUpdateColumnOptions).not.toHaveBeenCalled() + expect(mockUpdateColumnType).toHaveBeenCalledWith( + expect.objectContaining({ newType: 'select', required: true }), + 'req-1' + ) + }) + + it('folds a rename into the write it rides on rather than running it separately', async () => { + // A rename is metadata-only, so folding it into the last write's transaction + // is what stops a combined request committing one half and failing the other. + await run({ name: 'State', required: true }) + + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).toHaveBeenCalledWith( + expect.objectContaining({ columnName: 'col-1', newName: 'State' }), + 'req-1' + ) + }) + + it('runs a rename standalone when there is no write to ride on', async () => { + mockRenameColumn.mockResolvedValue(UPDATED) + + await run({ name: 'State' }) + + expect(mockRenameColumn).toHaveBeenCalledWith( + { tableId: 'table-1', oldName: 'col-1', newName: 'State' }, + 'req-1' + ) + }) + + it('rejects setting a currency code on a non-currency column', async () => { + const result = await run({ currencyCode: 'USD' }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + }) + + it('rejects an unsupported currency code before any write', async () => { + const result = await run({ type: 'currency', currencyCode: 'XX' }, 'Priority') + + expect(result.errorCode).toBe('validation') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('reports an empty payload as a validation failure', async () => { + const result = await run({}) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toBe('No updates specified') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it("names the type when a payload only restates the column's current type", async () => { + const result = await run({ type: 'select' }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('is already type "select"') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('classifies a table lock as locked and does not audit', async () => { + mockUpdateColumnConstraints.mockRejectedValue(new TableLockedError('update')) + + const result = await run({ required: true }) + + expect(result.errorCode).toBe('locked') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('reports the code the service failure carries', async () => { + mockUpdateColumnConstraints.mockRejectedValue( + new OrchestrationError('validation', 'Column "State" already exists') + ) + + expect((await run({ required: true })).errorCode).toBe('validation') + }) + + it('classifies a missing column as not_found', async () => { + mockUpdateColumnConstraints.mockRejectedValue( + new OrchestrationError('not_found', 'Column "Nope" not found') + ) + + expect((await run({ required: true })).errorCode).toBe('not_found') + }) + + it('keeps an unclassified fault internal and hides its message', async () => { + // Wording alone must never buy a status: this reads exactly like the + // caller-fixable failure above but carries no classification. + mockUpdateColumnConstraints.mockRejectedValue(new Error('Column "State" already exists')) + + const result = await run({ required: true }) + + expect(result.errorCode).toBe('internal') + expect(result.error).toBe('Failed to update column') + }) + + it('audits a successful update on every caller', async () => { + // The UI route and the copilot tool previously emitted no audit at all. + await run({ required: true }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'ws-1', actorId: 'user-1', resourceId: 'table-1' }) + ) + }) +}) diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts new file mode 100644 index 00000000000..71048def646 --- /dev/null +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -0,0 +1,268 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { + OrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' +import { columnTypeById } from '@/lib/table/column-types' +import { + renameColumn, + updateColumnConstraints, + updateColumnCurrency, + updateColumnOptions, + updateColumnType, +} from '@/lib/table/columns/service' +import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' +import type { ColumnType, SelectOption, TableDefinition, TableLockKind } from '@/lib/table/types' + +const logger = createLogger('TableColumnOrchestration') + +export interface PerformUpdateTableColumnParams { + table: TableDefinition + columnName: string + userId: string + updates: { + name?: string + type?: ColumnType + required?: boolean + unique?: boolean + /** Accepts `{id,name}` pairs or bare names; ids are minted/reused as needed. */ + options?: unknown + multiple?: boolean + currencyCode?: string + } + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformUpdateTableColumnResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind + table?: TableDefinition +} + +function classify(error: unknown): PerformUpdateTableColumnResult { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + return { success: false, error: 'Failed to update column', errorCode: 'internal' } +} + +function fail(error: string, errorCode: OrchestrationErrorCode): PerformUpdateTableColumnResult { + return { success: false, error, errorCode } +} + +/** + * Applies a column update — rename, type conversion, currency re-denomination, + * option-set edit, and constraint change — as the single implementation behind + * the UI route, the v1 and v2 public APIs, and the copilot table tool. + * + * Each underlying write is its own locked transaction, so a request that spans + * several of them could commit one and then fail. Two things prevent that and + * both are load-bearing: the guards below reject every knowable failure before + * any write runs, and the rename and constraint changes ride *inside* the typed + * write's transaction rather than following it. The caller owns authentication + * and workspace scoping; by the time this runs, `table` is one the actor may write. + */ +export async function performUpdateTableColumn( + params: PerformUpdateTableColumnParams +): Promise { + const { table, columnName, userId, updates, request } = params + const requestId = params.requestId ?? generateRequestId() + const tableId = table.id + + const currentColumn = table.schema.columns.find((c) => columnMatchesRef(c, columnName)) + if (!currentColumn) { + return fail(`Column "${columnName}" not found`, 'not_found') + } + + // Address every write by the stable id, not the name: a rename folded into + // one of them must not break the next one's lookup. + const columnRef = getColumnId(currentColumn) + const existingOptions: SelectOption[] = currentColumn.options ?? [] + const options = normalizeSelectOptionsInput(updates.options, existingOptions) + + // A payload that repeats the current type must not go through + // `updateColumnType` — it early-returns on an unchanged type and would drop + // any options alongside it. Only a real type change routes there. + const typeChanging = updates.type !== undefined && updates.type !== currentColumn.type + + // A retype applies and validates the constraints itself, so the separate + // constraint write only runs when no typed write does. The rename rides + // whichever write actually runs last. + const typedWriteRuns = + typeChanging || + updates.currencyCode !== undefined || + options !== undefined || + updates.multiple !== undefined + const constraintsWriteRuns = + !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) + const renameWithTypedWrite = + updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} + + // Gate on the type the column ENDS UP with, not on whether the type is + // changing: an options-only update on an existing select column carries the + // same hazard as a conversion does. + const resultingType = updates.type ?? currentColumn.type + + if (updates.currencyCode !== undefined) { + if (resultingType !== 'currency') { + return fail( + `Cannot set currency on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } + if (!isSupportedCurrencyCode(updates.currencyCode)) { + return fail( + `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, + 'validation' + ) + } + } + // The rename runs last, so a name already taken would fail after the typed + // write committed. This is the only rename failure a caller can cause; + // catching it here leaves just the concurrent-collision race. + if ( + updates.name && + table.schema.columns.some( + (c) => + c.name.toLowerCase() === updates.name?.toLowerCase() && !columnMatchesRef(c, columnName) + ) + ) { + return fail(`Column "${updates.name}" already exists`, 'validation') + } + if ( + currentColumn.workflowGroupId && + (updates.required !== undefined || updates.unique !== undefined) + ) { + return fail( + `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, + 'validation' + ) + } + if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + return fail(`Cannot set a ${resultingType} column as unique`, 'validation') + } + + let updated: TableDefinition | undefined + + try { + if (typeChanging) { + updated = await updateColumnType( + { + tableId, + columnName: columnRef, + newType: updates.type as ColumnType, + ...(options !== undefined ? { options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + // Forwarded so the conversion validates against the constraints this + // same request is about to set, not the column's current ones. + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } else if (updates.currencyCode !== undefined) { + // Re-denominating an existing currency column: schema-only, no cell + // rewrite. Reached only when the type is unchanged — a conversion INTO + // currency carries the code through `updateColumnType` above. + updated = await updateColumnCurrency( + { + tableId, + columnName: columnRef, + currencyCode: updates.currencyCode, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } else if (options !== undefined || updates.multiple !== undefined) { + updated = await updateColumnOptions( + { + tableId, + columnName: columnRef, + options: options ?? existingOptions, + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } + + // Skipped whenever a typed write ran: that write already applied and + // validated these, in one transaction with the change they accompany. + if (constraintsWriteRuns) { + updated = await updateColumnConstraints( + { + tableId, + columnName: columnRef, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...(updates.name ? { newName: updates.name } : {}), + }, + requestId + ) + } + + // A rename rides along with the LAST write above, inside that write's + // transaction — a rename is metadata-only (rows key on the stable column + // id), so nothing forces it to be its own write, and folding it in is what + // stops a combined request from committing one half and then failing. Only + // a rename with nothing to ride on runs standalone. + if (updates.name && !updated) { + updated = await renameColumn( + { tableId, oldName: columnRef, newName: updates.name }, + requestId + ) + } + } catch (error) { + logger.error(`[${requestId}] Failed to update column "${columnName}" on table ${tableId}`, { + error, + }) + return classify(error) + } + + if (!updated) { + // A payload whose only content is the type the column already has names a + // change and asks for nothing. Say which, the way `updateColumnType` does + // when it loses the same race, rather than claiming the request was empty. + if (updates.type !== undefined) { + return fail( + `Column "${currentColumn.name}" is already type "${currentColumn.type}"; re-issue the request without a type change.`, + 'validation' + ) + } + return fail('No updates specified', 'validation') + } + + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${columnName}" in table "${table.name}"`, + metadata: { columnName, updates }, + ...(request ? { request } : {}), + }) + + return { success: true, table: updated } +} diff --git a/apps/sim/lib/table/orchestration/export-resource.ts b/apps/sim/lib/table/orchestration/export-resource.ts new file mode 100644 index 00000000000..381ae860051 --- /dev/null +++ b/apps/sim/lib/table/orchestration/export-resource.ts @@ -0,0 +1,129 @@ +import { db } from '@sim/db' +import { tableJobs } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { V2TableExport, V2TableExportStatus } from '@/lib/api/contracts/v2/tables' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import type { TableDefinition, TableExportJobPayload } from '@/lib/table/types' + +export type TableExportRecord = typeof tableJobs.$inferSelect + +export async function createTableExportResource(params: { + table: TableDefinition + format: 'csv' | 'json' +}): Promise { + const exportId = generateId() + const payload: TableExportJobPayload = { format: params.format } + if (!(await markTableJobRunning(params.table.id, exportId, 'export', payload))) { + throw new OrchestrationError('conflict', 'Failed to start export') + } + const runnerPayload: TableExportPayload = { + jobId: exportId, + tableId: params.table.id, + workspaceId: params.table.workspaceId, + format: params.format, + } + + if (params.table.rowCount <= TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { + await runTableExport(runnerPayload) + } else { + try { + if (isTriggerDevEnabled) { + const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-export', runnerPayload, { + tags: [`tableId:${params.table.id}`, `jobId:${exportId}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-export', () => runTableExport(runnerPayload)) + } + } catch (error) { + await markJobFailed( + params.table.id, + exportId, + getErrorMessage(error, 'Failed to dispatch table export') + ) + throw error + } + } + + return requireTableExport(exportId, params.table.workspaceId) +} + +export async function requireTableExport( + exportId: string, + workspaceId: string +): Promise { + const [record] = await db + .select() + .from(tableJobs) + .where( + and( + eq(tableJobs.id, exportId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.type, 'export') + ) + ) + .limit(1) + if (!record) throw new OrchestrationError('not_found', 'Table export not found') + return record +} + +export async function cancelTableExportResource( + record: TableExportRecord +): Promise { + if (record.status === 'canceled') return record + if (record.status !== 'running') { + throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) + } + await markJobCanceled(record.tableId, record.id) + return requireTableExport(record.id, record.workspaceId) +} + +export function toV2TableExport(record: TableExportRecord, queued = false): V2TableExport { + const payload = record.payload as TableExportJobPayload | null + if (!payload?.format) throw new Error(`Table export ${record.id} has no format`) + return { + id: record.id, + tableId: record.tableId, + workspaceId: record.workspaceId, + format: payload.format, + status: queued && record.status === 'running' ? 'queued' : publicExportStatus(record.status), + rowsProcessed: record.rowsProcessed, + error: record.error, + createdAt: record.startedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +export function tableExportResult(record: TableExportRecord): { + resultKey: string + format: 'csv' | 'json' +} { + if (record.status !== 'ready') { + throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) + } + const payload = record.payload as TableExportJobPayload | null + if (!payload?.resultKey || !payload.format) { + throw new OrchestrationError('not_found', 'Export file is no longer available') + } + return { resultKey: payload.resultKey, format: payload.format } +} + +function publicExportStatus(status: string): V2TableExportStatus { + if (status === 'running') return 'processing' + if (status === 'ready') return 'completed' + if (status === 'failed' || status === 'canceled') return status + throw new Error(`Invalid table export status: ${status}`) +} diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts new file mode 100644 index 00000000000..b281965477d --- /dev/null +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateTable, + mockCreateUploadSession, + mockDbLimit, + mockGetUserEntityPermissions, + mockGetUserSettings, + mockGetWorkspaceFile, + mockGetWorkspaceTableLimits, + mockRunDetached, +} = vi.hoisted(() => ({ + mockCreateTable: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockDbLimit: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetUserSettings: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), + mockRunDetached: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ limit: mockDbLimit }), + }), + }), + }, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached })) +vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits })) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) +vi.mock('@/lib/table/service', () => ({ + createTable: mockCreateTable, + getTableById: vi.fn(), +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ getWorkspaceFile: mockGetWorkspaceFile })) +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: vi.fn(), + createUploadSession: mockCreateUploadSession, + getOwnedUploadSession: vi.fn(), +})) +vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import { createTableImportResource } from '@/lib/table/orchestration/import-resource' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const SOURCE = { type: 'workspace_file' as const, fileId: 'file-1' } +const TARGET = { type: 'new' as const, name: 'imported_data' } + +function workspaceFile(size: number) { + return { + id: 'file-1', + workspaceId: WORKSPACE_ID, + name: 'data.csv', + key: 'workspace/data.csv', + path: '/api/files/serve/workspace/data.csv', + size, + type: 'text/csv', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + } +} + +describe('createTableImportResource workspace file size', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100, maxRowsPerTable: 10_000 }) + mockCreateTable.mockResolvedValue({ id: 'table-1' }) + mockGetUserSettings.mockResolvedValue({ timezone: 'UTC' }) + mockDbLimit.mockResolvedValue([ + { + id: 'import-1', + workspaceId: WORKSPACE_ID, + tableId: 'table-1', + type: 'import', + status: 'running', + rowsProcessed: 0, + error: null, + payload: { + kind: 'table_import', + userId: 'user-1', + source: SOURCE, + target: TARGET, + options: {}, + }, + startedAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + }, + ]) + }) + + it('accepts a workspace CSV at the exact byte limit', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES)) + + const result = await createTableImportResource( + { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, + 'user-1', + 'http://localhost:3000' + ) + + expect(result.upload).toBeNull() + expect(mockCreateTable).toHaveBeenCalledOnce() + expect(mockRunDetached).toHaveBeenCalledOnce() + }) + + it('rejects a workspace CSV one byte over the limit before creating a table', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES + 1)) + + await expect( + createTableImportResource( + { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, + 'user-1', + 'http://localhost:3000' + ) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockCreateTable).not.toHaveBeenCalled() + expect(mockRunDetached).not.toHaveBeenCalled() + }) +}) + +describe('createTableImportResource upload size', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserEntityPermissions.mockResolvedValue('write') + mockCreateUploadSession.mockResolvedValue({ + id: 'import-1', + userId: 'user-1', + status: 'uploading', + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + createdAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + }) + }) + + it('creates an upload session for a CSV at the exact byte limit', async () => { + await createTableImportResource( + { + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES, + }, + target: TARGET, + }, + 'user-1', + 'http://localhost:3000' + ) + + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ fileSize: CSV_MAX_FILE_SIZE_BYTES, purpose: 'table_import' }) + ) + }) + + it('rejects an upload one byte over the limit before creating a session', async () => { + await expect( + createTableImportResource( + { + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES + 1, + }, + target: TARGET, + }, + 'user-1', + 'http://localhost:3000' + ) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts new file mode 100644 index 00000000000..127058074f5 --- /dev/null +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -0,0 +1,483 @@ +import { db } from '@sim/db' +import { tableJobs } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { + type V2CreateTableImportBody, + type V2CreateTableImportData, + type V2TableImport, + type V2TableImportSource, + type V2TableImportTarget, + v2CreateTableImportBodySchema, + v2CreateTableImportDataSchema, + v2TableImportSourceSchema, + v2TableImportTargetSchema, +} from '@/lib/api/contracts/v2/tables' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { findActiveFolder } from '@/lib/folders/queries' +import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' +import { createTable, getTableById } from '@/lib/table/service' +import type { TableImportJobPayload } from '@/lib/table/types' +import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + abortUploadSession, + type CreatedUploadSession, + createUploadSession, + getOwnedUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { getUserSettings } from '@/lib/users/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' + +interface TableImportResource { + id: string + workspaceId: string + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] + tableId: string | null + status: TableImportStatus + rowsProcessed: number + error: string | null + createdAt: Date + updatedAt: Date + completedAt: Date | null +} + +interface CreateTableImportResult { + record: TableImportResource + upload: CreatedUploadSession | null +} + +export async function createTableImportResource( + body: V2CreateTableImportBody, + userId: string, + localOrigin: string, + resolvedFolderId?: string | null +): Promise { + await assertWorkspaceWrite(userId, body.workspaceId) + await validateTarget(body.workspaceId, body.target, resolvedFolderId) + const importId = generateId() + const options = importOptions(body) + + if (body.source.type === 'upload') { + assertCsvFileName(body.source.name) + if (body.source.size > CSV_MAX_FILE_SIZE_BYTES) { + throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) + } + const upload = await createUploadSession({ + id: importId, + workspaceId: body.workspaceId, + userId, + purpose: 'table_import', + fileName: body.source.name, + contentType: body.source.contentType, + fileSize: body.source.size, + metadata: { tableImport: body, tableImportFolderId: resolvedFolderId ?? null }, + localOrigin, + }) + return { record: resourceFromUpload(upload, body), upload } + } + + const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) + assertCsvFileName(file.name) + return { + record: await startTableImport({ + id: importId, + workspaceId: body.workspaceId, + userId, + source: body.source, + target: body.target, + folderId: resolvedFolderId, + options, + fileKey: file.key, + fileName: file.name, + storageContext: 'workspace', + deleteSourceFile: false, + }), + upload: null, + } +} + +export async function startUploadedTableImport( + upload: UploadSessionRecord +): Promise { + const body = tableImportBodyFromUpload(upload) + const workspaceId = body.workspaceId + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId, + userId: upload.userId, + }) + if (existing) return existing + const storedFolderId = upload.metadata.tableImportFolderId + let folderId: string | null | undefined + if (body.target.type === 'new') { + if (storedFolderId !== null && typeof storedFolderId !== 'string') { + throw new Error('Table import upload is missing its resolved folder target') + } + folderId = storedFolderId + } + await validateTarget(workspaceId, body.target, folderId) + return startTableImport({ + id: upload.id, + workspaceId, + userId: upload.userId, + source: body.source, + target: body.target, + folderId, + options: importOptions(body), + fileKey: upload.storageKey, + fileName: upload.fileName, + storageContext: 'table-import', + deleteSourceFile: true, + }) +} + +export function getOwnedTableImportUpload(params: { + importId: string + workspaceId: string + userId: string + uploadToken: string +}): UploadSessionRecord { + const upload = getOwnedUploadSession({ + uploadId: params.importId, + workspaceId: params.workspaceId, + userId: params.userId, + purpose: 'table_import', + uploadToken: params.uploadToken, + }) + tableImportBodyFromUpload(upload) + return upload +} + +export async function abortTableImportUpload(params: { + importId: string + workspaceId: string + userId: string + uploadToken: string +}): Promise { + const upload = getOwnedTableImportUpload(params) + const body = tableImportBodyFromUpload(upload) + return resourceFromUpload(await abortUploadSession(upload), body) +} + +export async function getOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const record = await findOwnedTableImport(params) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +export async function findOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const [job] = await db + .select() + .from(tableJobs) + .where( + and( + eq(tableJobs.id, params.importId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'import') + ) + ) + .limit(1) + if (!job) return null + const payload = parseImportJobPayload(job.payload) + if (payload.userId !== params.userId) return null + return { + id: job.id, + workspaceId: job.workspaceId, + userId: payload.userId, + source: v2TableImportSourceSchema.parse(payload.source), + target: v2TableImportTargetSchema.parse(payload.target), + options: payload.options, + tableId: job.tableId, + status: tableImportStatus(job.status), + rowsProcessed: job.rowsProcessed, + error: job.error, + createdAt: job.startedAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + } +} + +export async function cancelTableImportResource( + record: TableImportResource +): Promise { + if (record.status === 'canceled') return record + if (record.status !== 'running' || !record.tableId) { + throw new OrchestrationError('conflict', `Table import is ${publicImportStatus(record.status)}`) + } + await markJobCanceled(record.tableId, record.id) + return getOwnedTableImport({ + importId: record.id, + workspaceId: record.workspaceId, + userId: record.userId, + }) +} + +export function toV2TableImport(record: TableImportResource): V2TableImport { + return { + id: record.id, + workspaceId: record.workspaceId, + status: publicImportStatus(record.status), + source: record.source, + target: record.target, + tableId: record.tableId, + rowsProcessed: record.rowsProcessed, + error: record.error, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +export function toV2CreateTableImport(result: CreateTableImportResult): V2CreateTableImportData { + return v2CreateTableImportDataSchema.parse({ + session: toV2TableImport(result.record), + uploadToken: result.upload?.uploadToken ?? null, + transfer: result.upload?.transfer ?? null, + }) +} + +interface StartTableImportParams { + id: string + workspaceId: string + userId: string + source: V2TableImportSource + target: V2TableImportTarget + folderId?: string | null + options: TableImportJobPayload['options'] + fileKey: string + fileName: string + storageContext: 'workspace' | 'table-import' + deleteSourceFile: boolean +} + +async function startTableImport(params: StartTableImportParams): Promise { + const requestId = generateRequestId() + const jobPayload: TableImportJobPayload = { + kind: 'table_import', + userId: params.userId, + source: params.source, + target: params.target, + options: params.options, + } + let tableId: string | null = null + try { + if (params.target.type === 'new') { + const limits = await getWorkspaceTableLimits(params.workspaceId) + const table = await createTable( + { + name: params.target.name, + description: `Imported from ${params.fileName}`, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId: params.workspaceId, + folderId: params.folderId ?? null, + userId: params.userId, + maxTables: limits.maxTables, + jobStatus: 'running', + jobType: 'import', + jobId: params.id, + jobPayload, + }, + requestId + ) + tableId = table.id + } else { + const table = await requireExistingTarget(params.workspaceId, params.target) + tableId = table.id + if (!(await markTableJobRunning(tableId, params.id, 'import', jobPayload))) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + } + + const payload: TableImportPayload = { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + userId: params.userId, + fileKey: params.fileKey, + fileName: params.fileName, + delimiter: params.fileName.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: params.target.type === 'new' ? 'create' : params.target.mode, + mapping: params.options.mapping as TableImportPayload['mapping'], + createColumns: params.options.createColumns, + deleteSourceFile: params.deleteSourceFile, + storageContext: params.storageContext, + timezone: params.options.timezone ?? (await getUserSettings(params.userId)).timezone ?? 'UTC', + } + + if (isTriggerDevEnabled) { + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-import', payload, { + tags: [`tableId:${tableId}`, `jobId:${params.id}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-import', () => runTableImport(payload)) + } + return getOwnedTableImport({ + importId: params.id, + workspaceId: params.workspaceId, + userId: params.userId, + }) + } catch (error) { + const message = getErrorMessage(error, 'Failed to dispatch table import') + if (tableId) await markJobFailed(tableId, params.id, message).catch(() => {}) + if (params.deleteSourceFile) { + const { deleteFile } = await import('@/lib/uploads/core/storage-service') + await deleteFile({ key: params.fileKey, context: params.storageContext }).catch(() => {}) + } + throw error + } +} + +function resourceFromUpload( + upload: UploadSessionRecord, + body: V2CreateTableImportBody +): TableImportResource { + return { + id: upload.id, + workspaceId: body.workspaceId, + userId: upload.userId, + source: body.source, + target: body.target, + options: importOptions(body), + tableId: body.target.type === 'existing' ? body.target.tableId : null, + status: upload.status === 'aborted' ? 'canceled' : 'uploading', + rowsProcessed: 0, + error: null, + createdAt: upload.createdAt, + updatedAt: upload.updatedAt, + completedAt: upload.completedAt, + } +} + +function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { + if (upload.purpose !== 'table_import' || upload.storageContext !== 'table-import') { + throw new OrchestrationError('conflict', 'Upload is not a table import') + } + const body = v2CreateTableImportBodySchema.parse(upload.metadata.tableImport) + if (body.workspaceId !== upload.workspaceId || body.source.type !== 'upload') { + throw new OrchestrationError('conflict', 'Upload token table import metadata does not match') + } + return body +} + +function importOptions(body: V2CreateTableImportBody): TableImportJobPayload['options'] { + return { + ...(body.mapping ? { mapping: body.mapping } : {}), + ...(body.createColumns ? { createColumns: body.createColumns as string[] } : {}), + ...(body.timezone ? { timezone: body.timezone } : {}), + } +} + +function parseImportJobPayload(payload: unknown): TableImportJobPayload { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Table import job is missing its payload') + } + const candidate = payload as Partial + if ( + candidate.kind !== 'table_import' || + typeof candidate.userId !== 'string' || + !candidate.options || + typeof candidate.options !== 'object' + ) { + throw new Error('Table import job has an invalid payload') + } + v2TableImportSourceSchema.parse(candidate.source) + v2TableImportTargetSchema.parse(candidate.target) + return candidate as TableImportJobPayload +} + +async function validateTarget( + workspaceId: string, + target: V2TableImportTarget, + resolvedFolderId?: string | null +): Promise { + if (target.type === 'new') { + if (resolvedFolderId && !(await findActiveFolder(resolvedFolderId, workspaceId, 'table'))) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + return + } + await requireExistingTarget(workspaceId, target) +} + +async function requireExistingTarget( + workspaceId: string, + target: Extract +) { + const table = await getTableById(target.tableId) + if (!table || table.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + if (table.archivedAt) { + throw new OrchestrationError('validation', 'Cannot import into an archived table') + } + assertRowInsert(table) + if (target.mode === 'replace') assertRowDelete(table) + return table +} + +async function requireWorkspaceSource( + workspaceId: string, + fileId: string +): Promise { + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'Workspace file not found') + if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) + } + return file +} + +async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + throw new OrchestrationError('forbidden', 'Access denied') + } +} + +function assertCsvFileName(fileName: string): void { + const normalized = fileName.toLowerCase() + if (!normalized.endsWith('.csv') && !normalized.endsWith('.tsv')) { + throw new OrchestrationError('validation', 'Only CSV and TSV files are supported') + } +} + +function tableImportStatus(status: string): TableImportStatus { + if (status !== 'running' && status !== 'ready' && status !== 'failed' && status !== 'canceled') { + throw new Error(`Invalid table import job status: ${status}`) + } + return status +} + +function publicImportStatus(status: TableImportStatus): V2TableImport['status'] { + if (status === 'running') return 'processing' + if (status === 'ready') return 'completed' + return status +} diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts new file mode 100644 index 00000000000..d511266ad43 --- /dev/null +++ b/apps/sim/lib/table/orchestration/import.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + * + * CSV import orchestration — the logic both the first-party and public import + * routes delegate to, so neither can drift on what an import actually does. + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockImportAppendRows, + mockImportReplaceRows, + mockGetMaxRowsPerTable, + mockDispatchAfterBatchInsert, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockImportAppendRows: vi.fn(), + mockImportReplaceRows: vi.fn(), + mockGetMaxRowsPerTable: vi.fn(), + mockDispatchAfterBatchInsert: vi.fn(), + mockSignalSchemaChanged: vi.fn(), +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/import-data', () => ({ + importAppendRows: mockImportAppendRows, + importReplaceRows: mockImportReplaceRows, +})) +vi.mock('@/lib/table/billing', () => ({ + getMaxRowsPerTable: mockGetMaxRowsPerTable, + getWorkspaceTableLimits: vi.fn(), + wouldExceedRowLimit: (limit: number, current: number, added: number) => + limit >= 0 && current + added > limit, +})) +vi.mock('@/lib/table/rows/service', () => ({ + batchInsertRows: vi.fn(), + dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, +})) +vi.mock('@/lib/table/service', () => ({ createTable: vi.fn(), deleteTable: vi.fn() })) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) + +import { performTableCsvImport } from '@/lib/table/orchestration/import' + +const TABLE = { + id: 'table-1', + name: 'contacts', + workspaceId: 'ws-1', + rowCount: 10, + archivedAt: null, + jobStatus: null, + schema: { + columns: [ + { id: 'col_email', name: 'email', type: 'string', required: false, unique: false }, + { id: 'col_name', name: 'name', type: 'string', required: false, unique: false }, + ], + }, +} as never + +const CSV = 'email,name\na@b.c,Ann\nd@e.f,Dan\n' + +function csvStream(text = CSV) { + return Readable.from([Buffer.from(text)]) +} + +function importParams(overrides: Record = {}) { + return { + table: TABLE, + workspaceId: 'ws-1', + userId: 'user-1', + fileStream: csvStream(), + fileName: 'contacts.csv', + fallbackDelimiter: ',' as const, + mode: 'append' as const, + timezone: 'UTC', + requestId: 'req-1', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockMarkTableJobRunning.mockResolvedValue(true) + mockReleaseJobClaim.mockResolvedValue(undefined) + mockGetMaxRowsPerTable.mockResolvedValue(1000) + mockImportAppendRows.mockResolvedValue({ + inserted: [{ id: 'row-1' }, { id: 'row-2' }], + table: TABLE, + }) + mockImportReplaceRows.mockResolvedValue({ insertedCount: 2, deletedCount: 10 }) +}) + +describe('performTableCsvImport', () => { + it('auto-maps same-named headers and appends the parsed rows', async () => { + const result = await performTableCsvImport(importParams()) + + expect(result.success).toBe(true) + expect(result.data).toEqual({ + tableId: 'table-1', + mode: 'append', + insertedCount: 2, + mappedColumns: ['email', 'name'], + skippedHeaders: [], + unmappedColumns: [], + sourceFile: 'contacts.csv', + }) + // The trigger/scheduler fan-out must run AFTER the tx commits, so it is the + // orchestration's job rather than the writer's. + expect(mockDispatchAfterBatchInsert).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('reports the deleted count on a replace', async () => { + const result = await performTableCsvImport(importParams({ mode: 'replace' })) + + expect(result.data).toMatchObject({ mode: 'replace', insertedCount: 2, deletedCount: 10 }) + expect(mockImportReplaceRows).toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() + }) + + it('holds the table job slot for the write and releases it before returning', async () => { + await performTableCsvImport(importParams()) + + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', expect.any(String), 'import') + // Released before the response, so a client refetch never observes the claim. + expect(mockReleaseJobClaim).toHaveBeenCalledWith('table-1', expect.any(String)) + }) + + it('releases the claim even when the write throws', async () => { + mockImportAppendRows.mockRejectedValue(new Error('boom')) + + const result = await performTableCsvImport(importParams()) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('internal') + expect(mockReleaseJobClaim).toHaveBeenCalled() + }) + + it('refuses when another job already holds the slot', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const result = await performTableCsvImport(importParams()) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(mockImportAppendRows).not.toHaveBeenCalled() + // Nothing was claimed, so nothing may be released — releasing here would + // free the *other* job's slot. + expect(mockReleaseJobClaim).not.toHaveBeenCalled() + }) + + it('refuses an import that would exceed the plan row limit, before writing', async () => { + mockGetMaxRowsPerTable.mockResolvedValue(11) + + const result = await performTableCsvImport(importParams()) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('exceed table row limit') + expect(mockImportAppendRows).not.toHaveBeenCalled() + }) + + it('rejects an archived table and a table with a job already running', async () => { + const archived = await performTableCsvImport( + importParams({ table: { ...TABLE, archivedAt: new Date() } }) + ) + expect(archived).toMatchObject({ success: false, errorCode: 'validation' }) + + const busy = await performTableCsvImport( + importParams({ table: { ...TABLE, jobStatus: 'running' } }) + ) + expect(busy).toMatchObject({ success: false, errorCode: 'conflict' }) + + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('rejects a file with no data rows', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('email,name\n') }) + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toBe('CSV file has no data rows') + }) + + it('rejects a file whose headers map to nothing on the table', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('alpha,beta\n1,2\n') }) + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('No CSV headers map to columns') + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('reports which headers were skipped and which columns went unfilled', async () => { + const result = await performTableCsvImport( + importParams({ + fileStream: csvStream('email,notes\na@b.c,hi\n'), + mapping: { email: 'email', notes: null }, + }) + ) + + expect(result.data).toMatchObject({ + mappedColumns: ['email'], + skippedHeaders: ['notes'], + unmappedColumns: ['name'], + }) + }) + + it('rejects createColumns naming a header the file does not have', async () => { + const result = await performTableCsvImport(importParams({ createColumns: ['phone'] })) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('unknown CSV headers') + }) + + it('creates the requested columns with ids the coerced rows already key by', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('email,phone\na@b.c,555\n'), createColumns: ['phone'] }) + ) + + expect(result.success).toBe(true) + const [, additions, rows] = mockImportAppendRows.mock.calls[0] + expect(additions).toEqual([{ id: expect.any(String), name: 'phone', type: expect.any(String) }]) + // The id is pre-assigned so the prospective schema used to coerce and the + // column the write creates share one key — otherwise the values land under + // a key nothing reads. + expect(Object.keys(rows[0])).toContain(additions[0].id) + }) +}) diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts new file mode 100644 index 00000000000..7739d5e473b --- /dev/null +++ b/apps/sim/lib/table/orchestration/import.ts @@ -0,0 +1,514 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + getMaxRowsPerTable, + getWorkspaceTableLimits, + wouldExceedRowLimit, +} from '@/lib/table/billing' +import { generateColumnId } from '@/lib/table/column-keys' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + buildAutoMapping, + CSV_MAX_BATCH_SIZE, + CSV_SCHEMA_SAMPLE_SIZE, + type CsvDelimiter, + type CsvHeaderMapping, + CsvImportValidationError, + coerceRowsForTable, + createCsvParser, + inferColumnType, + inferSchemaFromCsv, + sanitizeName, + validateMapping, +} from '@/lib/table/import' +import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { batchInsertRows, dispatchAfterBatchInsert } from '@/lib/table/rows/service' +import { createTable, deleteTable } from '@/lib/table/service' +import type { RowData, TableDefinition, TableLockKind, TableSchema } from '@/lib/table/types' + +const logger = createLogger('TableImportOrchestration') + +/** + * CSV import orchestration. + * + * Both entry points own the whole import: they consume the caller's file + * stream, sniff the separator, parse, map, coerce, claim the table's job slot, + * and write. Routes are left holding only transport concerns — reading the + * multipart body, authorizing, and rendering the result — so the v1 and v2 + * surfaces cannot drift on what an import actually does. + */ + +interface ImportFailure { + success: false + error: string + errorCode: OrchestrationErrorCode + details?: unknown + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind +} + +function fail(error: string, errorCode: OrchestrationErrorCode, details?: unknown): ImportFailure { + return { success: false, error, errorCode, ...(details !== undefined ? { details } : {}) } +} + +/** + * Classifies a write failure raised inside an import. A lock rejection is a + * 423 and `TableLockedError` is not an `OrchestrationError`, so it needs its + * own branch; anything unclassified is a server fault whose message must not + * reach the caller. + * + * A lock rejection carries its `lock` kind through, because "locked" alone does + * not tell a caller which of the four flags to clear. + */ +function classifyImportFailure(error: unknown, requestId: string, tableId: string): ImportFailure { + if (error instanceof TableLockedError) { + return { ...fail(error.message, 'locked'), lock: error.lock } + } + if (error instanceof OrchestrationError) return fail(error.message, error.code) + logger.error(`[${requestId}] CSV import failed for table ${tableId}`, { error }) + return fail(toError(error).message, 'internal') +} + +/** + * Drains a CSV/TSV stream into memory. The extension only picks the fallback — + * the separator is sniffed from the file's head so semicolon/pipe exports + * (European-locale Excel) don't land in one column. + */ +async function readCsvRows( + fileStream: Readable, + fallbackDelimiter: CsvDelimiter +): Promise<{ headers: string[]; rows: Record[] }> { + const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) + + let headers: string[] = [] + const parser = createCsvParser(delimiter, (parsedHeaders) => { + headers = parsedHeaders + }) + // `.pipe` doesn't forward source errors; forward them so the iterator throws. + stream.on('error', (streamError) => parser.destroy(streamError)) + stream.pipe(parser) + + const rows: Record[] = [] + for await (const record of parser as AsyncIterable>) { + rows.push(record) + } + return { headers, rows } +} + +/** + * Resolves `createColumns` into pending column definitions plus the schema the + * rows should be coerced against. Ids are pre-assigned so the prospective + * schema and the columns the write actually creates share the same keys — the + * coerced rows are keyed by id before those columns exist. + */ +function planNewColumns( + table: TableDefinition, + headers: string[], + createColumns: string[], + mapping: CsvHeaderMapping, + rows: Record[] +): + | { + ok: true + additions: { id: string; name: string; type: string }[] + schema: TableSchema + mapping: CsvHeaderMapping + } + | { ok: false; failure: ImportFailure } { + const headerSet = new Set(headers) + const unknownHeaders = createColumns.filter((header) => !headerSet.has(header)) + if (unknownHeaders.length > 0) { + return { + ok: false, + failure: fail( + `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, + 'validation' + ), + } + } + + const usedNames = new Set(table.schema.columns.map((column) => column.name.toLowerCase())) + const updatedMapping: CsvHeaderMapping = { ...mapping } + const additions: { id: string; name: string; type: string }[] = [] + const newColumns: TableSchema['columns'] = [] + + for (const header of createColumns) { + const base = sanitizeName(header) + let columnName = base + let suffix = 2 + while (usedNames.has(columnName.toLowerCase())) { + columnName = `${base}_${suffix}` + suffix++ + } + usedNames.add(columnName.toLowerCase()) + const inferredType = inferColumnType(rows.map((row) => row[header])) + const id = generateColumnId() + additions.push({ id, name: columnName, type: inferredType }) + newColumns.push({ + id, + name: columnName, + type: inferredType as TableSchema['columns'][number]['type'], + required: false, + unique: false, + }) + updatedMapping[header] = columnName + } + + return { + ok: true, + additions, + schema: { columns: [...table.schema.columns, ...newColumns] }, + mapping: updatedMapping, + } +} + +export interface PerformTableCsvImportParams { + table: TableDefinition + workspaceId: string + userId: string + /** Multipart file stream. The caller still owns destroying it. */ + fileStream: Readable + fileName: string + /** Separator to fall back to when sniffing is inconclusive. */ + fallbackDelimiter: CsvDelimiter + mode: 'append' | 'replace' + /** Explicit CSV header → column name map. Auto-derived from the schema when omitted. */ + mapping?: CsvHeaderMapping + /** CSV headers to create as new columns on the table before importing. */ + createColumns?: string[] + /** IANA zone used to read naive datetimes (Excel/Sheets exports carry no offset). */ + timezone: string + requestId?: string +} + +export interface TableCsvImportData { + tableId: string + mode: 'append' | 'replace' + insertedCount: number + /** Replace mode only — rows removed before the insert. */ + deletedCount?: number + mappedColumns: string[] + skippedHeaders: string[] + unmappedColumns: string[] + sourceFile: string +} + +export interface PerformTableCsvImportResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Per-header mapping issues, when the failure is a mapping validation. */ + details?: unknown + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind + data?: TableCsvImportData +} + +/** + * Imports a CSV into an EXISTING table, appending or replacing its rows. + * + * The table's single write-job slot is claimed for the whole write and released + * before returning. The claim is the real concurrency gate — the `jobStatus` + * pre-check reads a snapshot taken before the parse, and a background import + * can start in that window; without the claim a synchronous and a background + * import would interleave and corrupt a replace. + */ +export async function performTableCsvImport( + params: PerformTableCsvImportParams +): Promise { + const { table, workspaceId, userId, fileStream, fileName, fallbackDelimiter, mode, timezone } = + params + const requestId = params.requestId ?? generateRequestId() + + if (table.archivedAt) return fail('Cannot import into an archived table', 'validation') + if (table.jobStatus === 'running') { + return fail('A job is already in progress for this table', 'conflict') + } + + const { headers, rows } = await readCsvRows(fileStream, fallbackDelimiter) + if (rows.length === 0) return fail('CSV file has no data rows', 'validation') + + let effectiveMapping = params.mapping ?? buildAutoMapping(headers, table.schema) + let prospectiveSchema = table.schema + let additions: { id: string; name: string; type: string }[] = [] + + if (params.createColumns && params.createColumns.length > 0) { + const planned = planNewColumns(table, headers, params.createColumns, effectiveMapping, rows) + if (!planned.ok) return planned.failure + additions = planned.additions + prospectiveSchema = planned.schema + effectiveMapping = planned.mapping + } + + let validation: ReturnType + try { + validation = validateMapping({ + csvHeaders: headers, + mapping: effectiveMapping, + tableSchema: prospectiveSchema, + }) + } catch (error) { + if (error instanceof CsvImportValidationError) { + return fail(error.message, 'validation', error.details) + } + throw error + } + + if (validation.mappedHeaders.length === 0) { + return fail( + `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveSchema.columns + .map((column) => column.name) + .join(', ')}`, + 'validation' + ) + } + + const coerced = coerceRowsForTable(rows, prospectiveSchema, validation.effectiveMap, { timezone }) + + const importId = generateId() + if (!(await markTableJobRunning(table.id, importId, 'import'))) { + return fail('A job is already in progress for this table', 'conflict') + } + + const summary = { + tableId: table.id, + mode, + mappedColumns: validation.mappedHeaders, + skippedHeaders: validation.skippedHeaders, + unmappedColumns: validation.unmappedColumns, + sourceFile: fileName, + } + + try { + if (mode === 'append') { + const maxRows = await getMaxRowsPerTable(workspaceId) + if (wouldExceedRowLimit(maxRows, table.rowCount, coerced.length)) { + const deficit = table.rowCount + coerced.length - maxRows + return fail( + `Append would exceed table row limit (${maxRows}). Currently ${table.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, + 'validation' + ) + } + + const { inserted, table: finalTable } = await importAppendRows(table, additions, coerced, { + workspaceId, + userId, + requestId, + }) + // Fire trigger + scheduler AFTER the tx commits — both read through the + // global db connection and would otherwise see no rows. + dispatchAfterBatchInsert(finalTable, inserted, requestId, userId) + + logger.info(`[${requestId}] Append CSV imported`, { + tableId: table.id, + fileName, + inserted: inserted.length, + createdColumns: additions.length, + }) + signalTableSchemaChanged(table.id) + + return { success: true, data: { ...summary, insertedCount: inserted.length } } + } + + const result = await importReplaceRows( + table, + additions, + { rows: coerced, workspaceId, userId }, + requestId + ) + + logger.info(`[${requestId}] Replace CSV imported`, { + tableId: table.id, + fileName, + deleted: result.deletedCount, + inserted: result.insertedCount, + createdColumns: additions.length, + }) + signalTableSchemaChanged(table.id) + + return { + success: true, + data: { + ...summary, + insertedCount: result.insertedCount, + deletedCount: result.deletedCount, + }, + } + } catch (error) { + return classifyImportFailure(error, requestId, table.id) + } finally { + // Release before returning, so a client refetch never observes the transient claim. + await releaseJobClaim(table.id, importId).catch(() => {}) + } +} + +export interface PerformCreateTableFromCsvParams { + workspaceId: string + userId: string + /** Multipart file stream. The caller still owns destroying it. */ + fileStream: Readable + fileName: string + fallbackDelimiter: CsvDelimiter + /** Folder to create the table in; `null` creates it at the workspace root. */ + folderId: string | null + timezone: string + requestId?: string +} + +export interface CreatedTableFromCsv { + id: string + name: string + description: string | null + schema: TableSchema + rowCount: number +} + +export interface PerformCreateTableFromCsvResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + data?: { table: CreatedTableFromCsv } +} + +/** + * Creates a NEW table from a CSV and streams its rows in. + * + * Unlike {@link performTableCsvImport} this never buffers the whole file: it + * infers the schema from the first {@link CSV_SCHEMA_SAMPLE_SIZE} records, + * creates the table, then inserts in batches as records arrive. A failure part + * way through drops the half-populated table rather than leaving it behind. + */ +export async function performCreateTableFromCsv( + params: PerformCreateTableFromCsvParams +): Promise { + const { workspaceId, userId, fileStream, fileName, fallbackDelimiter, folderId, timezone } = + params + const requestId = params.requestId ?? generateRequestId() + + const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) + + let csvHeaders: string[] = [] + const parser = createCsvParser(delimiter, (headers) => { + csvHeaders = headers + }) + stream.on('error', (streamError) => parser.destroy(streamError)) + stream.pipe(parser) + + interface ImportState { + table: TableDefinition + schema: TableSchema + headerToColumn: Map + } + + const insertRows = async ( + batch: Record[], + state: ImportState, + currentRowCount: number + ): Promise => { + if (batch.length === 0) return 0 + const coerced = coerceRowsForTable(batch, state.schema, state.headerToColumn, { timezone }) + const inserted = await batchInsertRows( + { tableId: state.table.id, rows: coerced as RowData[], workspaceId, userId }, + // The created table's rowCount is frozen at 0; pass the running total so the + // per-batch capacity check sees cumulative rows, not an always-empty table. + { ...state.table, rowCount: currentRowCount }, + generateId().slice(0, 8) + ) + return inserted.length + } + + /** Infer the schema from the buffered sample and create the (empty) table. */ + const buildTable = async (sampleRows: Record[]): Promise => { + const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) + // Inference emits only `{ name, type }`; the stored schema carries the + // constraint flags explicitly so a later read never has to guess a default. + const columns = inferred.columns.map((column) => ({ + ...column, + required: false, + unique: false, + })) + const planLimits = await getWorkspaceTableLimits(workspaceId) + const tableName = sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const table = await createTable( + { + name: tableName, + description: `Imported from ${fileName}`, + schema: { columns }, + workspaceId, + folderId, + userId, + maxTables: planLimits.maxTables, + }, + requestId + ) + // Coerce against the *created* schema so rows key by the ids `createTable` + // assigned (the inferred schema above is id-less). + return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } + } + + let state: ImportState | null = null + let inserted = 0 + const sample: Record[] = [] + let batch: Record[] = [] + + try { + for await (const record of parser as AsyncIterable>) { + if (!state) { + sample.push(record) + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + state = await buildTable(sample) + inserted += await insertRows(sample, state, inserted) + } + continue + } + batch.push(record) + if (batch.length >= CSV_MAX_BATCH_SIZE) { + inserted += await insertRows(batch, state, inserted) + batch = [] + } + } + + if (!state) { + if (sample.length === 0) return fail('CSV file has no data rows', 'validation') + state = await buildTable(sample) + inserted += await insertRows(sample, state, inserted) + } else { + inserted += await insertRows(batch, state, inserted) + } + } catch (error) { + // A half-populated table from a mid-stream failure is worse than none. + if (state) await deleteTable(state.table.id, requestId).catch(() => {}) + return classifyImportFailure(error, requestId, state?.table.id ?? 'unknown') + } + + logger.info(`[${requestId}] CSV imported`, { + tableId: state.table.id, + fileName, + columns: state.schema.columns.length, + rows: inserted, + }) + + return { + success: true, + data: { + table: { + id: state.table.id, + name: state.table.name, + description: state.table.description ?? null, + schema: state.schema, + rowCount: inserted, + }, + }, + } +} diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index af29da6aced..9fa267d7f33 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -1,64 +1,10 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateRequestId } from '@/lib/core/utils/request' -import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' -import type { TableDefinition } from '@/lib/table/types' - -const logger = createLogger('TableOrchestration') - -export type TableOrchestrationErrorCode = 'not_found' | 'validation' | 'conflict' | 'internal' - -export interface PerformRestoreTableParams { - tableId: string - userId: string - requestId?: string -} - -export interface PerformRestoreTableResult { - success: boolean - error?: string - errorCode?: TableOrchestrationErrorCode - table?: TableDefinition -} - -export async function performRestoreTable( - params: PerformRestoreTableParams -): Promise { - const { tableId, userId } = params - const requestId = params.requestId ?? generateRequestId() - - const archivedTable = await getTableById(tableId, { includeArchived: true }) - if (!archivedTable) { - return { success: false, error: 'Table not found', errorCode: 'not_found' } - } - - try { - await restoreTable(tableId, requestId) - const table = (await getTableById(tableId)) ?? archivedTable - - logger.info(`[${requestId}] Restored table ${tableId}`) - - recordAudit({ - workspaceId: archivedTable.workspaceId, - actorId: userId, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - metadata: { - tableName: table.name, - workspaceId: table.workspaceId, - }, - }) - - return { success: true, table } - } catch (error) { - logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) - if (error instanceof TableConflictError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} +export { performUpdateTableColumn } from './columns' +export { performCreateTableFromCsv, performTableCsvImport } from './import' +export { performRestoreTable } from './restore' +export { + performDeleteTable, + performDeleteTableRow, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from './tables' diff --git a/apps/sim/lib/table/orchestration/restore.ts b/apps/sim/lib/table/orchestration/restore.ts new file mode 100644 index 00000000000..1aff4de5da2 --- /dev/null +++ b/apps/sim/lib/table/orchestration/restore.ts @@ -0,0 +1,63 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' +import type { TableDefinition } from '@/lib/table/types' + +const logger = createLogger('TableOrchestration') + +export interface PerformRestoreTableParams { + tableId: string + userId: string + requestId?: string +} + +export interface PerformRestoreTableResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +export async function performRestoreTable( + params: PerformRestoreTableParams +): Promise { + const { tableId, userId } = params + const requestId = params.requestId ?? generateRequestId() + + const archivedTable = await getTableById(tableId, { includeArchived: true }) + if (!archivedTable) { + return { success: false, error: 'Table not found', errorCode: 'not_found' } + } + + try { + await restoreTable(tableId, requestId) + const table = (await getTableById(tableId)) ?? archivedTable + + logger.info(`[${requestId}] Restored table ${tableId}`) + + recordAudit({ + workspaceId: archivedTable.workspaceId, + actorId: userId, + action: AuditAction.TABLE_RESTORED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Restored table "${table.name}"`, + metadata: { + tableName: table.name, + workspaceId: table.workspaceId, + }, + }) + + return { success: true, table } + } catch (error) { + logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) + if (error instanceof TableConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/table/orchestration/tables.test.ts b/apps/sim/lib/table/orchestration/tables.test.ts new file mode 100644 index 00000000000..33cb68566ab --- /dev/null +++ b/apps/sim/lib/table/orchestration/tables.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockDeleteTable, mockDeleteRow, mockRenameTable, mockCaptureServerEvent, mockRecordAudit } = + vi.hoisted(() => ({ + mockDeleteTable: vi.fn(), + mockDeleteRow: vi.fn(), + mockRenameTable: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockRecordAudit: vi.fn(), + })) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/table/service', () => ({ + deleteTable: mockDeleteTable, + moveTableToFolder: vi.fn(), + renameTable: mockRenameTable, + updateTableLocks: vi.fn(), +})) +vi.mock('@/lib/table/rows/service', () => ({ deleteRow: mockDeleteRow })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { + performDeleteTable, + performDeleteTableRow, + performRenameTable, +} from '@/lib/table/orchestration/tables' + +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1' } as unknown as TableDefinition + +describe('performDeleteTable', () => { + beforeEach(() => vi.clearAllMocks()) + + it('audits a genuine archive against the acting user', async () => { + mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } }) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1', requestId: 'req-1' }) + + expect(result.success).toBe(true) + // The service no longer takes an actor — auditing follows from a user + // performing the operation, not from which function the caller reached for. + expect(mockDeleteTable).toHaveBeenCalledWith('table-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'table-1' }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'table_deleted', + expect.objectContaining({ table_id: 'table-1' }), + expect.anything() + ) + }) + + it('carries request provenance into the audit row', async () => { + mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } }) + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'curl/8' } }) + + await performDeleteTable({ table: TABLE, userId: 'user-1', request }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ request })) + }) + + it('neither audits nor reports a repeat delete of an already-archived table', async () => { + mockDeleteTable.mockResolvedValue({ archived: null }) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) + + expect(result.success).toBe(true) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('classifies a delete lock as locked and emits no telemetry', async () => { + mockDeleteTable.mockRejectedValue(new TableLockedError('delete')) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked', lock: 'delete' }) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('performRenameTable', () => { + beforeEach(() => vi.clearAllMocks()) + + it('classifies a name collision as a conflict, not bad input', async () => { + // `TableConflictError` is an `OrchestrationError('conflict')` — the class + // decides the status, so the 409 no longer rides on the message wording. + mockRenameTable.mockRejectedValue( + new OrchestrationError('conflict', 'A table named "Tasks" already exists in this workspace') + ) + + const result = await performRenameTable({ table: TABLE, newName: 'Tasks', userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('keeps an unclassified rename failure internal', async () => { + mockRenameTable.mockRejectedValue(new Error('A table named "Tasks" already exists')) + + expect( + (await performRenameTable({ table: TABLE, newName: 'Tasks', userId: 'user-1' })).errorCode + ).toBe('internal') + }) +}) + +describe('performDeleteTableRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('deletes through the row service so the lock and bookkeeping apply', async () => { + mockDeleteRow.mockResolvedValue(undefined) + + const result = await performDeleteTableRow({ table: TABLE, rowId: 'row-1', requestId: 'req-1' }) + + expect(result.success).toBe(true) + expect(mockDeleteRow).toHaveBeenCalledWith(TABLE, 'row-1', 'req-1') + }) + + it('classifies a delete lock as locked', async () => { + mockDeleteRow.mockRejectedValue(new TableLockedError('delete')) + + const rowResult = await performDeleteTableRow({ table: TABLE, rowId: 'row-1' }) + expect(rowResult.errorCode).toBe('locked') + // The kind rides along so the route can name which flag to clear. + expect(rowResult.lock).toBe('delete') + }) + + it('classifies a missing row as not_found', async () => { + mockDeleteRow.mockRejectedValue(new OrchestrationError('not_found', 'Row not found')) + + expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe( + 'not_found' + ) + }) +}) diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts new file mode 100644 index 00000000000..3c0abcf6ae0 --- /dev/null +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -0,0 +1,286 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + OrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { deleteRow } from '@/lib/table/rows/service' +import { deleteTable, moveTableToFolder, renameTable, updateTableLocks } from '@/lib/table/service' +import { + TABLE_LOCK_FLAGS, + TABLE_LOCK_KINDS, + type TableDefinition, + type TableLockKind, + type TableLocks, +} from '@/lib/table/types' + +const logger = createLogger('TableOrchestration') + +export interface PerformDeleteTableParams { + table: TableDefinition + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformDeleteTableResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind +} + +/** + * Archives a table on behalf of `userId`. + * + * The audit lives here rather than in `deleteTable` so that auditing follows + * from "a user performed this operation", not from which function a caller + * reached for. The rollback and cleanup paths call the service directly and + * are silent by construction, and a repeat delete of an already-archived table + * logs nothing because the service reports that it archived no row. + */ +export async function performDeleteTable( + params: PerformDeleteTableParams +): Promise { + const { table, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + let archived: { name: string; workspaceId: string | null } | null + try { + ;({ archived } = await deleteTable(table.id, requestId)) + } catch (error) { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Failed to delete table ${table.id}`, { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } + + // Both the audit and the analytics event describe an archive that happened, so + // both hang off the same evidence that one did. A repeat delete of an + // already-archived table succeeds and records nothing. + if (archived) { + recordAudit({ + workspaceId: archived.workspaceId, + actorId: userId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: archived.name, + description: `Archived table "${archived.name}"`, + ...(request ? { request } : {}), + }) + captureServerEvent( + userId, + 'table_deleted', + { table_id: table.id, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } + ) + } + + return { success: true } +} + +export interface PerformDeleteTableRowParams { + table: TableDefinition + rowId: string + requestId?: string +} + +export interface PerformDeleteTableRowResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind +} + +/** + * Deletes a single row through the row service, so the delete lock is enforced + * and the row-count and ordering bookkeeping runs. A raw `db.delete` skips both + * and returns success on a locked table. + */ +export async function performDeleteTableRow( + params: PerformDeleteTableRowParams +): Promise { + const { table, rowId } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteRow(table, rowId, requestId) + return { success: true } + } catch (error) { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Failed to delete row ${rowId} from table ${table.id}`, { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +export interface PerformRenameTableParams { + table: TableDefinition + newName: string + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformTableMutationResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind + table?: TableDefinition +} + +function classifyTableMutation(error: unknown, requestId: string, tableId: string) { + if (error instanceof TableLockedError) { + return { + success: false as const, + error: error.message, + errorCode: 'locked' as const, + lock: error.lock, + } + } + // `TableConflictError` is an `OrchestrationError('conflict')`, so a duplicate + // rename reaches 409 through this branch — by class, not by the message + // happening to contain "already exists". + if (error instanceof OrchestrationError) { + return { success: false as const, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Table mutation failed for ${tableId}`, { error }) + return { + success: false as const, + error: toError(error).message, + errorCode: 'internal' as const, + } +} + +/** Renames a table and records the rename against `userId`. */ +export async function performRenameTable( + params: PerformRenameTableParams +): Promise { + const { table, newName, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + try { + const renamed = await renameTable(table.id, newName, requestId) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: renamed.name, + description: `Renamed table to "${renamed.name}"`, + metadata: { op: 'rename', previousName: table.name }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + +export interface PerformMoveTableParams { + table: TableDefinition + folderId: string | null + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +/** Moves a table between folders (or to the workspace root). */ +export async function performMoveTableToFolder( + params: PerformMoveTableParams +): Promise { + const { table, folderId, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + if (!table.workspaceId) { + return { success: false, error: 'Table is not in a workspace', errorCode: 'validation' } + } + + try { + const { name } = await moveTableToFolder(table.id, table.workspaceId, folderId, requestId) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: name, + description: folderId + ? `Moved table "${name}" into a folder` + : `Moved table "${name}" to the workspace root`, + metadata: { op: 'move', folderId }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + +export interface PerformUpdateTableLocksParams { + tableId: string + partial: Partial + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +/** + * Applies a lock change and names the transitions in the audit description, so + * the audit list answers "who locked my production table" without expanding + * metadata. The before/after state comes back from the service because only the + * locked write can observe it. + */ +export async function performUpdateTableLocks( + params: PerformUpdateTableLocksParams +): Promise { + const { tableId, partial, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + try { + const { table, previousLocks } = await updateTableLocks(tableId, partial, requestId) + const flipped = TABLE_LOCK_KINDS.filter( + (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== table.locks[TABLE_LOCK_FLAGS[kind]] + ) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: flipped.length + ? `Table locks changed: ${flipped + .map((kind) => `${kind} ${table.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) + .join(', ')}` + : 'Updated table locks (no change)', + metadata: { op: 'update_locks', before: previousLocks, after: table.locks }, + ...(request ? { request } : {}), + }) + return { success: true, table } + } catch (error) { + return classifyTableMutation(error, requestId, tableId) + } +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 971c37b7957..f613090b373 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, inArray, lte, notInArray, type SQL, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, getMaxRowsPerTable, @@ -124,13 +125,16 @@ export async function insertRow( // Validate row size const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } // Validate against schema const schemaValidation = coerceRowToSchema(data.data, table.schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Check unique constraints using optimized database query @@ -138,7 +142,7 @@ export async function insertRow( if (uniqueColumns.length > 0) { const uniqueValidation = await checkUniqueConstraintsDb(data.tableId, data.data, table.schema) if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) } } @@ -261,12 +265,18 @@ export async function batchInsertRowsWithTx( const sizeValidation = validateRowSize(row) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -282,7 +292,7 @@ export async function batchInsertRowsWithTx( const errorMessages = uniqueResult.errors .map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`) .join('; ') - throw new Error(errorMessages) + throw new OrchestrationError('validation', errorMessages) } } @@ -422,12 +432,18 @@ export async function replaceTableRowsWithTx( const sizeValidation = validateRowSize(row) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -449,7 +465,8 @@ export async function replaceTableRowsWithTx( const normalized = typeof value === 'string' ? value : JSON.stringify(value) const map = seen.get(colId)! if (map.has(normalized)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Row ${i + 1}: Column "${col.name}" must be unique. Value "${String(value)}" duplicates row ${map.get(normalized)! + 1} in batch` ) } @@ -538,7 +555,8 @@ export async function upsertRow( const uniqueColumns = getUniqueColumns(schema) if (uniqueColumns.length === 0) { - throw new Error( + throw new OrchestrationError( + 'validation', 'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.' ) } @@ -552,7 +570,8 @@ export async function upsertRow( (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget ) if (!col) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` ) } @@ -560,7 +579,8 @@ export async function upsertRow( } else if (uniqueColumns.length === 1) { targetColumnKey = getColumnId(uniqueColumns[0]) } else { - throw new Error( + throw new OrchestrationError( + 'validation', `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` ) } @@ -568,12 +588,15 @@ export async function upsertRow( // Validate row data const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } const schemaValidation = coerceRowToSchema(data.data, schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Read the conflict-target value *after* coercion so `matchFilter` branches on @@ -583,7 +606,10 @@ export async function upsertRow( // Surface the display name, not the internal id — v1 callers pass a name. const targetColumnName = uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey - throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) + throw new OrchestrationError( + 'validation', + `Upsert requires a value for the conflict target column "${targetColumnName}"` + ) } // Build the conflict probe through the SAME leaf as the unique-constraint check @@ -636,7 +662,10 @@ export async function upsertRow( trx ) if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) } const now = new Date() @@ -1459,7 +1488,7 @@ export async function updateRow( // Get existing row const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) if (!existingRow) { - throw new Error('Row not found') + throw new OrchestrationError('not_found', 'Row not found') } // Merge partial update with existing row data so callers can pass only changed fields @@ -1483,13 +1512,16 @@ export async function updateRow( // Validate size const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } // Validate against schema const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Check unique constraints using optimized database query @@ -1502,7 +1534,7 @@ export async function updateRow( data.rowId // Exclude current row ) if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) } } @@ -1642,7 +1674,7 @@ export async function deleteRow( workspaceId: table.workspaceId, proof, }) - if (!deleted) throw new Error('Row not found') + if (!deleted) throw new OrchestrationError('not_found', 'Row not found') logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`) } @@ -1666,7 +1698,7 @@ export async function updateRowsByFilter( const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { - throw new Error('Filter is required for bulk update') + throw new OrchestrationError('validation', 'Filter is required for bulk update') } const baseConditions = and( @@ -1708,12 +1740,18 @@ export async function updateRowsByFilter( const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { - throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${row.id}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${row.id}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -1721,7 +1759,8 @@ export async function updateRowsByFilter( const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set unique column values when updating multiple rows. ` + `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` @@ -1738,7 +1777,10 @@ export async function updateRowsByFilter( row.id ) if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) } } @@ -1856,7 +1898,7 @@ export async function batchUpdateRows( const missing = rowIds.filter((id) => !existingMap.has(id)) if (missing.length > 0) { - throw new Error(`Rows not found: ${missing.join(', ')}`) + throw new OrchestrationError('validation', `Rows not found: ${missing.join(', ')}`) } const mergedUpdates: Array<{ @@ -1886,12 +1928,18 @@ export async function batchUpdateRows( const sizeValidation = validateRowSize(merged) if (!sizeValidation.valid) { - throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${update.rowId}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(merged, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${update.rowId}: ${schemaValidation.errors.join(', ')}` + ) } mergedUpdates.push({ @@ -1914,7 +1962,10 @@ export async function batchUpdateRows( rowId ) if (!uniqueValidation.valid) { - throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${rowId}: ${uniqueValidation.errors.join(', ')}` + ) } } } @@ -2036,7 +2087,7 @@ export async function deleteRowsByFilter( // Build filter clause const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { - throw new Error('Filter is required for bulk delete') + throw new OrchestrationError('validation', 'Filter is required for bulk delete') } // Find matching rows diff --git a/apps/sim/lib/table/select-options.test.ts b/apps/sim/lib/table/select-options.test.ts new file mode 100644 index 00000000000..36841dc9926 --- /dev/null +++ b/apps/sim/lib/table/select-options.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' + +describe('normalizeSelectOptionsInput', () => { + it('generates a stable id for a bare-name string option', () => { + const [opt] = normalizeSelectOptionsInput(['Open']) ?? [] + expect(opt.name).toBe('Open') + expect(typeof opt.id).toBe('string') + expect(opt.id.length).toBeGreaterThan(0) + }) + + it('generates an id for an object option without one', () => { + const [opt] = normalizeSelectOptionsInput([{ name: 'Closed' }]) ?? [] + expect(opt.name).toBe('Closed') + expect(opt.id.length).toBeGreaterThan(0) + }) + + it('preserves an explicitly supplied id', () => { + const result = normalizeSelectOptionsInput([{ id: 'opt_keep', name: 'Open' }]) + expect(result).toEqual([{ id: 'opt_keep', name: 'Open' }]) + }) + + it('reuses the id of an existing option with the same name', () => { + // The agent re-sends options as bare names on every edit. Minting fresh ids + // would orphan every cell holding them — silently clearing the column. + const existing = [ + { id: 'opt_low', name: 'Low' }, + { id: 'opt_high', name: 'High' }, + ] + const result = normalizeSelectOptionsInput(['Low', 'Medium', 'High'], existing) ?? [] + + expect(result[0]).toEqual({ id: 'opt_low', name: 'Low' }) + expect(result[2]).toEqual({ id: 'opt_high', name: 'High' }) + // Only the genuinely new option gets a fresh id. + expect(result[1].name).toBe('Medium') + expect(result[1].id).not.toBe('opt_low') + expect(result[1].id).not.toBe('opt_high') + }) + + it('matches an existing option name case-insensitively', () => { + const result = normalizeSelectOptionsInput(['open'], [{ id: 'opt_open', name: 'Open' }]) ?? [] + expect(result[0].id).toBe('opt_open') + expect(result[0].name).toBe('open') + }) + + it('mints a fresh id when there is no existing column to match against', () => { + const result = normalizeSelectOptionsInput(['Open']) ?? [] + expect(result[0].id.length).toBeGreaterThan(0) + expect(result[0].name).toBe('Open') + }) + + it('returns undefined for a non-array (validation rejects it downstream)', () => { + expect(normalizeSelectOptionsInput(undefined)).toBeUndefined() + expect(normalizeSelectOptionsInput('Open')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/table/select-options.ts b/apps/sim/lib/table/select-options.ts index aa62cfa0bfd..332b465cb7d 100644 --- a/apps/sim/lib/table/select-options.ts +++ b/apps/sim/lib/table/select-options.ts @@ -12,6 +12,7 @@ * Keeping the option primitives here lets both sides share one implementation. */ +import { generateShortId } from '@sim/utils/id' import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' /** Set of valid option ids for a `select`/`multiselect` column. */ @@ -85,3 +86,34 @@ export function resolveSelectCellValue(value: JsonValue, column: ColumnDefinitio const single = Array.isArray(value) ? value[0] : value return single === undefined ? null : resolveSelectOptionId(single, options) } + +/** + * Normalizes caller-supplied select options to `{ id, name }` pairs. + * + * Cells reference the option id, so an edit that re-sends an option by name + * must reuse the id it already has — minting a fresh one would orphan every + * cell holding it, silently clearing the column. A caller that already supplies + * an id keeps it, which makes this a no-op for the fully-formed options the + * HTTP contracts accept and a repair for the name-only options agents author. + */ +export function normalizeSelectOptionsInput( + raw: unknown, + existing: SelectOption[] = [] +): SelectOption[] | undefined { + if (!Array.isArray(raw)) return undefined + + const idByName = new Map() + for (const option of existing) { + const key = option.name.toLowerCase() + if (!idByName.has(key)) idByName.set(key, option.id) + } + const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() + + return raw.map((entry) => { + if (typeof entry === 'string') return { id: resolveId(entry), name: entry } + const e = (entry ?? {}) as { id?: unknown; name?: unknown } + const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') + const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) + return { id, name } + }) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 2f5baf0f46c..24925e41549 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,7 +13,22 @@ import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { and, type Column, count, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { resolveRestoredFolderId } from '@/lib/folders/queries' @@ -29,8 +44,6 @@ import type { DbTransaction } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' import { type CreateTableData, - TABLE_LOCK_FLAGS, - TABLE_LOCK_KINDS, type TableDefinition, type TableLocks, type TableMetadata, @@ -42,10 +55,15 @@ import { stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableService') -export class TableConflictError extends Error { - readonly code = 'TABLE_EXISTS' as const +/** + * A table name already taken in the workspace. Kept as its own class because + * several routes branch on it specifically, and typed as a `conflict` so the + * generic classifiers reach the same 409 without reading the message. + */ +export class TableConflictError extends OrchestrationError { constructor(name: string) { - super(`A table named "${name}" already exists in this workspace`) + super('conflict', `A table named "${name}" already exists in this workspace`) + this.name = 'TableConflictError' } } @@ -104,7 +122,7 @@ export async function withLockedTable( ) const table = await getTableById(tableId, { tx: trx, includeArchived: opts?.includeArchived }) if (!table) { - throw new Error('Table not found') + throw new OrchestrationError('not_found', 'Table not found') } return mutate(table, trx) }) @@ -202,53 +220,129 @@ export async function getTableById( } } +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so tables sharing a name still + * come back in a stable order. + */ +const TABLE_SORTS = { + name: [userTableDefinitions.name, userTableDefinitions.createdAt], + createdAt: [userTableDefinitions.createdAt], + updatedAt: [userTableDefinitions.updatedAt, userTableDefinitions.createdAt], +} satisfies Record + +/** + * The keysets behind {@link queryTables}' sortable fields. `satisfies` makes + * this total over the contract enum, so a new sortable field fails to compile + * until it has a keyset here rather than silently falling through to an + * unordered scan. + * + * Every key column is `NOT NULL`, and `id` closes each keyset so a page + * boundary inside a run of equal names or timestamps is still stable. + */ +const tableId = textKey(userTableDefinitions.id, (row) => row.id) + +/** `TableDefinition` widens its timestamps to `Date | string`; the keyset needs a `Date`. */ +const asDate = (value: Date | string): Date => (value instanceof Date ? value : new Date(value)) + +const TABLE_KEYSETS = { + name: [textKey(userTableDefinitions.name, (row) => row.name), tableId], + createdAt: [ + timestampKey(userTableDefinitions.createdAt, (row) => asDate(row.createdAt)), + tableId, + ], + updatedAt: [ + timestampKey(userTableDefinitions.updatedAt, (row) => asDate(row.updatedAt)), + tableId, + ], +} satisfies Record[]> + +/** The column projection every table listing reads, shared so one row type serves both. */ +const TABLE_ROW_SELECT = { + id: userTableDefinitions.id, + name: userTableDefinitions.name, + description: userTableDefinitions.description, + schema: userTableDefinitions.schema, + metadata: userTableDefinitions.metadata, + maxRows: userTableDefinitions.maxRows, + workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, + createdBy: userTableDefinitions.createdBy, + archivedAt: userTableDefinitions.archivedAt, + createdAt: userTableDefinitions.createdAt, + updatedAt: userTableDefinitions.updatedAt, + rowCount: userTableDefinitions.rowCount, + ...LOCK_SELECT, +} as const + +type TableRowSelection = Awaited< + ReturnType>['from']> +>[number] + +interface ListTablesOptions { + scope?: TableScope + /** Restrict to one table folder. */ + /** `undefined` lists every folder, `null` lists only workspace-root tables. */ + folderId?: string | null + /** Case-insensitive substring match on the table name. */ + search?: string + sortBy?: V2TableSortBy + sortOrder?: V2SortOrder +} + /** * Lists all tables in a workspace. * + * Filter and sort are applied in the query — a name search must not become + * "read every table in the workspace, then discard most of them". + * * @param workspaceId - Workspace ID to list tables for * @returns Array of table definitions */ export async function listTables( workspaceId: string, - options?: { scope?: TableScope } + options?: ListTablesOptions ): Promise { - const { scope = 'active' } = options ?? {} + const { + scope = 'active', + folderId, + search, + sortBy = 'createdAt', + sortOrder = 'asc', + } = options ?? {} const tables = await db - .select({ - id: userTableDefinitions.id, - name: userTableDefinitions.name, - description: userTableDefinitions.description, - schema: userTableDefinitions.schema, - metadata: userTableDefinitions.metadata, - maxRows: userTableDefinitions.maxRows, - workspaceId: userTableDefinitions.workspaceId, - folderId: userTableDefinitions.folderId, - createdBy: userTableDefinitions.createdBy, - archivedAt: userTableDefinitions.archivedAt, - createdAt: userTableDefinitions.createdAt, - updatedAt: userTableDefinitions.updatedAt, - rowCount: userTableDefinitions.rowCount, - ...LOCK_SELECT, - }) + .select(TABLE_ROW_SELECT) .from(userTableDefinitions) .where( - scope === 'all' - ? eq(userTableDefinitions.workspaceId, workspaceId) - : scope === 'archived' - ? and( - eq(userTableDefinitions.workspaceId, workspaceId), - sql`${userTableDefinitions.archivedAt} IS NOT NULL` - ) - : and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) + and( + eq(userTableDefinitions.workspaceId, workspaceId), + scope === 'all' + ? undefined + : scope === 'archived' + ? isNotNull(userTableDefinitions.archivedAt) + : isNull(userTableDefinitions.archivedAt), + folderId === undefined + ? undefined + : folderId === null + ? isNull(userTableDefinitions.folderId) + : eq(userTableDefinitions.folderId, folderId), + searchFilter(userTableDefinitions.name, search) + ) ) - .orderBy(userTableDefinitions.createdAt) + .orderBy(...listOrderBy(TABLE_SORTS[sortBy], sortOrder)) - const jobsByTable = await latestJobsForTables(tables.map((t) => t.id)) + return hydrateTableRows(tables) +} + +/** + * Attaches each table's latest job fields and its order-corrected schema. The + * `rowCount` subtracts rows a pending delete has already claimed, so a table + * mid-delete reports what a caller can still read rather than the raw column. + */ +async function hydrateTableRows(rows: TableRowSelection[]): Promise { + const jobsByTable = await latestJobsForTables(rows.map((t) => t.id)) - return tables.map((t) => { + return rows.map((t) => { const metadata = (t.metadata as TableMetadata) ?? null const { pendingDeleteRemaining, ...jobFields } = jobsByTable.get(t.id) ?? EMPTY_JOB_FIELDS return { @@ -271,6 +365,81 @@ export async function listTables( }) } +export interface QueryTablesOptions { + scope?: TableScope + /** Restrict to one table folder. */ + /** `undefined` lists every folder, `null` lists only workspace-root tables. */ + folderId?: string | null + /** Case-insensitive substring match on the table name. */ + search?: string + sortBy: V2TableSortBy + sortOrder: V2SortOrder + limit: number + /** Keyset values from a cursor, in the sort's key order. */ + after?: CursorKey[] +} + +export interface QueryTablesResult { + tables: TableDefinition[] + /** Keyset values to resume from, or `null` when this page is the last one. */ + nextKeys: CursorKey[] | null +} + +/** + * One filtered, sorted, bounded page of a workspace's tables. + * + * Distinct from {@link listTables}, which materializes the whole scope for + * callers that genuinely need it. Here the filter, the ordering, and the slice + * are all in the query, so a `search` never costs a full-workspace read — which + * matters now that tables can be created through the public API in bulk. + */ +export async function queryTables( + workspaceId: string, + options: QueryTablesOptions +): Promise { + const { scope = 'active', folderId, search, sortBy, sortOrder, limit, after } = options + const keys = TABLE_KEYSETS[sortBy] + + // A cursor whose values don't bind is a caller error, not an empty filter — + // coercing it away would silently serve page 1 under a resumed cursor. + let resumeAfter: SQL | undefined + if (after) { + const condition = keysetAfter(keys, after, sortOrder) + if (!condition) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + resumeAfter = condition + } + + const rows = await db + .select(TABLE_ROW_SELECT) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + scope === 'all' + ? undefined + : scope === 'archived' + ? isNotNull(userTableDefinitions.archivedAt) + : isNull(userTableDefinitions.archivedAt), + folderId === undefined + ? undefined + : folderId === null + ? isNull(userTableDefinitions.folderId) + : eq(userTableDefinitions.folderId, folderId), + searchFilter(userTableDefinitions.name, search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + // One extra row is the has-more probe; it never reaches the caller. + .limit(limit + 1) + + const hasMore = rows.length > limit + const tables = await hydrateTableRows(rows.slice(0, limit)) + const last = tables.at(-1) + + return { tables, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } +} + /** * Creates a new table. * @@ -286,13 +455,19 @@ export async function createTable( // Validate table name const nameValidation = validateTableName(data.name) if (!nameValidation.valid) { - throw new Error(`Invalid table name: ${nameValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Invalid table name: ${nameValidation.errors.join(', ')}` + ) } // Validate schema const schemaValidation = validateTableSchema(data.schema) if (!schemaValidation.valid) { - throw new Error(`Invalid schema: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Invalid schema: ${schemaValidation.errors.join(', ')}` + ) } const tableId = `tbl_${generateId().replace(/-/g, '')}` @@ -356,7 +531,12 @@ export async function createTable( ) if (Number(existingCount) >= maxTables) { - throw new Error(`Workspace has reached maximum table limit (${maxTables})`) + // A quota ceiling, not bad input — both create routes have always + // answered 403 for it. + throw new OrchestrationError( + 'forbidden', + `Workspace has reached maximum table limit (${maxTables})` + ) } const duplicateName = await trx @@ -384,6 +564,7 @@ export async function createTable( workspaceId: data.workspaceId, type: initialJob.type, status: 'running', + payload: data.jobPayload ?? null, startedAt: initialJob.startedAt, updatedAt: initialJob.startedAt, }) @@ -478,23 +659,26 @@ export async function addTableColumnsWithTx( for (const column of columns) { if (!NAME_PATTERN.test(column.name)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` ) } if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` ) } const lower = column.name.toLowerCase() if (usedNames.has(lower)) { - throw new Error(`Column "${column.name}" already exists`) + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) } usedNames.add(lower) // Honor a caller-assigned id (the CSV append path pre-assigns so coercion @@ -510,7 +694,8 @@ export async function addTableColumnsWithTx( } if (table.schema.columns.length + additions.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( + throw new OrchestrationError( + 'validation', `Adding ${additions.length} column(s) would exceed maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` ) } @@ -576,12 +761,11 @@ export function auditTableColumnsAdded( export async function renameTable( tableId: string, newName: string, - requestId: string, - actingUserId?: string + requestId: string ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { - throw new Error(nameValidation.errors.join(', ')) + throw new OrchestrationError('validation', nameValidation.errors.join(', ')) } const now = new Date() @@ -590,30 +774,15 @@ export async function renameTable( .update(userTableDefinitions) .set({ name: newName, updatedAt: now }) .where(eq(userTableDefinitions.id, tableId)) - .returning({ - id: userTableDefinitions.id, - createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, - }) + // `workspaceId` is selected for the live-list notify below, not for an audit — + // the audit moved up to `performRenameTable`. + .returning({ id: userTableDefinitions.id, workspaceId: userTableDefinitions.workspaceId }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) + throw new OrchestrationError('not_found', `Table ${tableId} not found`) } - const { createdBy, workspaceId } = result[0] - const renameActorId = actingUserId ?? createdBy - if (renameActorId) { - recordAudit({ - workspaceId: workspaceId ?? null, - actorId: renameActorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: newName, - description: `Renamed table to "${newName}"`, - metadata: { op: 'rename' }, - }) - } + const { workspaceId } = result[0] logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) @@ -644,9 +813,8 @@ export async function moveTableToFolder( tableId: string, workspaceId: string, folderId: string | null, - requestId: string, - actingUserId?: string -): Promise { + requestId: string +): Promise<{ name: string }> { const updates: Partial = { folderId, updatedAt: new Date(), @@ -674,30 +842,16 @@ export async function moveTableToFolder( }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) + throw new OrchestrationError('not_found', `Table ${tableId} not found`) } - const { name, createdBy } = result[0] - const actorId = actingUserId ?? createdBy - if (actorId) { - recordAudit({ - workspaceId, - actorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: name, - description: folderId - ? `Moved table "${name}" into a folder` - : `Moved table "${name}" to the workspace root`, - metadata: { op: 'move', folderId }, - }) - } + const { name } = result[0] logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) - // Live tables list: a move changes each table's folder placement in the list result. await notifyWorkspaceTablesChanged(workspaceId) + + return { name } } /** @@ -714,11 +868,8 @@ export async function moveTableToFolder( export async function updateTableLocks( tableId: string, partial: Partial, - actingUserId: string, - requestId: string, - /** Forwarded to the audit record for IP / user-agent capture. */ - request?: { headers: { get(name: string): string | null } } -): Promise { + requestId: string +): Promise<{ table: TableDefinition; previousLocks: TableLocks }> { let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS const updated = await withLockedTable(tableId, async (table, trx) => { previousLocks = table.locks @@ -731,36 +882,12 @@ export async function updateTableLocks( return { ...table, locks: nextLocks, updatedAt: now } }) - // Name the transitions in the description so the audit list is readable - // without expanding metadata — "who locked my production table" is the - // question this feature exists to answer. - const flipped = TABLE_LOCK_KINDS.filter( - (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]] - ) - const description = flipped.length - ? `Table locks changed: ${flipped - .map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) - .join(', ')}` - : 'Updated table locks (no change)' - - recordAudit({ - workspaceId: updated.workspaceId, - actorId: actingUserId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: updated.name, - description, - metadata: { op: 'update_locks', before: previousLocks, after: updated.locks }, - ...(request ? { request } : {}), - }) - await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => { logger.warn(`[${requestId}] Failed to emit lock-change event for table ${tableId}`, { error }) }) logger.info(`[${requestId}] Updated locks for table ${tableId}`) - return updated + return { table: updated, previousLocks } } /** @@ -841,9 +968,8 @@ export async function updateTableMetadata( export async function deleteTable( tableId: string, requestId: string, - actingUserId?: string, options?: { archivedAt?: Date; skipNotify?: boolean } -): Promise { +): Promise<{ archived: { name: string; workspaceId: string | null } | null }> { const now = options?.archivedAt ?? new Date() // Archiving destroys access to every row, so it is gated on the delete lock. // The guard is inline in the WHERE (atomic — no separate read, no TOCTOU); @@ -885,27 +1011,16 @@ export async function deleteTable( } // Otherwise the table is missing or already archived — a silent no-op, as before. } - // Audit only genuine user deletes — rollback callers omit `actingUserId`. The - // caller emits the `table_deleted` PostHog event, so it is not duplicated here. - if (deleted && actingUserId) { - recordAudit({ - workspaceId: deleted.workspaceId ?? null, - actorId: actingUserId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: deleted.name, - description: `Archived table "${deleted.name}"`, - }) - } - logger.info(`[${requestId}] Archived table ${tableId}`) - // Live tables list: only on a genuine archive (a no-op/already-archived delete changes nothing). // Skipped under a folder cascade — deleteFolder fires one folder-level notify for the whole subtree, // so we don't spam one relay call per archived table. if (deleted?.workspaceId && !options?.skipNotify) await notifyWorkspaceTablesChanged(deleted.workspaceId) + + // Null when the table was missing or already archived — a silent no-op. The + // caller audits only a genuine archive, so a repeat delete logs nothing. + return { archived: deleted ? { name: deleted.name, workspaceId: deleted.workspaceId } : null } } /** @@ -924,18 +1039,18 @@ export async function restoreTable( ): Promise { const table = await getTableById(tableId, { includeArchived: true }) if (!table) { - throw new Error('Table not found') + throw new OrchestrationError('not_found', 'Table not found') } if (!table.archivedAt) { - throw new Error('Table is not archived') + throw new OrchestrationError('validation', 'Table is not archived') } if (table.workspaceId) { const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(table.workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore table into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore table into an archived workspace') } } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 40231c77900..fbe997dd29f 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -348,16 +348,31 @@ export interface TableUpdateJobPayload { maxRows?: number } +export type TableExportFormat = 'csv' | 'json' + /** * Persisted scope of an export job (`table_jobs.payload`). `resultKey` is merged in by the worker * on completion — the storage key of the generated file, served to the client via a presigned URL * and deleted by the janitor when the terminal job is pruned. */ export interface TableExportJobPayload { - format: 'csv' | 'json' + format: TableExportFormat resultKey?: string } +/** Durable import descriptor stored on the existing `table_jobs` row. */ +export interface TableImportJobPayload { + kind: 'table_import' + userId: string + source: unknown + target: unknown + options: { + mapping?: unknown + createColumns?: string[] + timezone?: string + } +} + /** * Keyset cursor for paginating a table's default row order, `(order_key, id)`. The grid's * infinite scroll threads this instead of an OFFSET — offset paging re-scans every prior row per @@ -644,6 +659,8 @@ export interface CreateTableData { jobType?: TableJobType /** Async job id stamped on the table when `jobStatus` is set. */ jobId?: string + /** Type-specific payload stored on the initial async job. */ + jobPayload?: unknown } export interface InsertRowData { diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 0033d7b2ce2..24dde87c3fa 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/table/events', () => ({ import { createTableView, deleteTableView, + getTableView, normalizeStoredViewConfig, pruneViewConfig, updateTableView, @@ -184,3 +185,37 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) }) + +describe('getTableView', () => { + const columns: ColumnDefinition[] = [{ id: 'col_a', name: 'Name', type: 'text' }] + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('prunes stale column references the same way the list read does', async () => { + queueTableRows(tableViews, [ + { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: { columnOrder: ['col_a', 'col_gone'], hiddenColumns: ['col_gone'] }, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + const view = await getTableView('view-1', 'table-1', columns) + + expect(view?.config.columnOrder).toEqual(['col_a']) + expect(view?.config.hiddenColumns).toEqual([]) + }) + + it('returns null for a view id that is not on this table', async () => { + expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 1487be7dd35..bd3fb64963e 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -151,6 +151,21 @@ export async function listTableViews( return rows.map((row) => toTableView(row, columns)) } +/** One view by id, scoped to its table, or `null` when it doesn't exist there. */ +export async function getTableView( + viewId: string, + tableId: string, + columns: ColumnDefinition[] +): Promise { + const [row] = await db + .select() + .from(tableViews) + .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .limit(1) + + return row ? toTableView(row, columns) : null +} + function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 4054113d4e2..a2ccc24d4d6 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -18,6 +18,7 @@ import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { buildCancelledExecution } from '@/lib/table/cell-write' import type { Filter, @@ -731,8 +732,9 @@ export async function runWorkflowColumn(opts: { // this module; `@trigger.dev/sdk` is heavy and only needed on this op. const { getTableById } = await import('@/lib/table/service') const table = await getTableById(tableId) - if (!table) throw new Error('Table not found') - if (table.workspaceId !== workspaceId) throw new Error('Invalid workspace ID') + if (!table) throw new OrchestrationError('not_found', 'Table not found') + if (table.workspaceId !== workspaceId) + throw new OrchestrationError('validation', 'Invalid workspace ID') const allGroups = table.schema.workflowGroups ?? [] const targetGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups diff --git a/apps/sim/lib/uploads/client/admission.test.ts b/apps/sim/lib/uploads/client/admission.test.ts new file mode 100644 index 00000000000..889678594e6 --- /dev/null +++ b/apps/sim/lib/uploads/client/admission.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + assertMultiFileUploadAdmission, + MULTI_FILE_UPLOAD_MAX_FILE_BYTES, + MULTI_FILE_UPLOAD_MAX_FILES, + MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES, + type MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' + +function files(count: number, size: number) { + return Array.from({ length: count }, (_, index) => ({ name: `file-${index}.bin`, size })) +} + +describe('multi-file upload admission', () => { + it('accepts the exact aggregate-byte boundary', () => { + expect(() => + assertMultiFileUploadAdmission(files(5, MULTI_FILE_UPLOAD_MAX_FILE_BYTES)) + ).not.toThrow() + expect(MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES).toBe(5 * MULTI_FILE_UPLOAD_MAX_FILE_BYTES) + }) + + it('rejects a selection whose combined count exceeds the action cap', () => { + expect(() => + assertMultiFileUploadAdmission([{ name: 'new.bin', size: 1 }], { + existingFiles: files(MULTI_FILE_UPLOAD_MAX_FILES, 1), + }) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_FILE_COUNT_EXCEEDED', + }) + ) + }) + + it('rejects one file above the shared per-file ceiling', () => { + expect(() => + assertMultiFileUploadAdmission([ + { name: 'oversized.bin', size: MULTI_FILE_UPLOAD_MAX_FILE_BYTES + 1 }, + ]) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_FILE_SIZE_EXCEEDED', + }) + ) + }) + + it('rejects aggregate bytes across existing and newly selected files', () => { + expect(() => + assertMultiFileUploadAdmission(files(1, MULTI_FILE_UPLOAD_MAX_FILE_BYTES), { + existingFiles: files(5, MULTI_FILE_UPLOAD_MAX_FILE_BYTES), + }) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + }) + ) + }) + + it('supports a larger direct-to-storage limit without weakening aggregate admission', () => { + expect(() => + assertMultiFileUploadAdmission([{ name: 'archive.zip', size: 1024 }], { + maxFileBytes: 1024, + maxTotalBytes: 2048, + }) + ).not.toThrow() + + expect(() => + assertMultiFileUploadAdmission(files(3, 1024), { + maxFileBytes: 1024, + maxTotalBytes: 2048, + }) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + }) + ) + }) +}) diff --git a/apps/sim/lib/uploads/client/admission.ts b/apps/sim/lib/uploads/client/admission.ts new file mode 100644 index 00000000000..670a570a2fd --- /dev/null +++ b/apps/sim/lib/uploads/client/admission.ts @@ -0,0 +1,102 @@ +import { + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, +} from '@/lib/uploads/shared/types' + +export const MULTI_FILE_UPLOAD_MAX_FILES = 20 +const MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS = 5 +export const MULTI_FILE_UPLOAD_MAX_FILE_BYTES = Math.min( + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE +) +export const MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES = + MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS * MULTI_FILE_UPLOAD_MAX_FILE_BYTES + +export type MultiFileUploadAdmissionErrorCode = + | 'UPLOAD_FILE_COUNT_EXCEEDED' + | 'UPLOAD_FILE_SIZE_EXCEEDED' + | 'UPLOAD_TOTAL_SIZE_EXCEEDED' + +export class MultiFileUploadAdmissionError extends Error { + constructor( + message: string, + readonly code: MultiFileUploadAdmissionErrorCode + ) { + super(message) + this.name = 'MultiFileUploadAdmissionError' + } +} + +interface UploadAdmissionFile { + readonly name?: string + readonly size: number +} + +interface MultiFileUploadAdmissionOptions { + existingFiles?: ArrayLike + maxFileBytes?: number + maxTotalBytes?: number +} + +/** + * Bounds one user upload action before previews, UI rows, or upload sessions are allocated. + * Knowledge uploads use the shared 100 MiB / 500 MiB defaults. Direct-to-storage consumers may + * provide their larger server-side limit while retaining the same count and aggregate bounds. + */ +export function assertMultiFileUploadAdmission( + files: ArrayLike, + options: MultiFileUploadAdmissionOptions = {} +): void { + const existingFiles = options.existingFiles + const maxFileBytes = options.maxFileBytes ?? MULTI_FILE_UPLOAD_MAX_FILE_BYTES + const maxTotalBytes = + options.maxTotalBytes ?? MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS * maxFileBytes + if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) { + throw new Error('Invalid per-file upload limit') + } + if (!Number.isSafeInteger(maxTotalBytes) || maxTotalBytes < maxFileBytes) { + throw new Error('Invalid aggregate upload limit') + } + const existingCount = existingFiles?.length ?? 0 + const totalCount = existingCount + files.length + if (totalCount > MULTI_FILE_UPLOAD_MAX_FILES) { + throw new MultiFileUploadAdmissionError( + `Select up to ${MULTI_FILE_UPLOAD_MAX_FILES} files at a time.`, + 'UPLOAD_FILE_COUNT_EXCEEDED' + ) + } + + let totalBytes = 0 + const groups = existingFiles ? [existingFiles, files] : [files] + for (const group of groups) { + for (let index = 0; index < group.length; index++) { + const file = group[index] + if (!file || !Number.isSafeInteger(file.size) || file.size < 0) { + throw new Error('Invalid file size in upload selection') + } + if (file.size > maxFileBytes) { + const label = file.name ? `"${file.name}"` : 'A selected file' + throw new MultiFileUploadAdmissionError( + `${label} is too large. Each file must be ${formatBinaryBytes(maxFileBytes)} or smaller.`, + 'UPLOAD_FILE_SIZE_EXCEEDED' + ) + } + totalBytes += file.size + } + } + + if (totalBytes > maxTotalBytes) { + throw new MultiFileUploadAdmissionError( + `Select files totaling ${formatBinaryBytes(maxTotalBytes)} or less.`, + 'UPLOAD_TOTAL_SIZE_EXCEEDED' + ) + } +} + +function formatBinaryBytes(bytes: number): string { + const gibibyte = 1024 ** 3 + if (bytes % gibibyte === 0) return `${bytes / gibibyte} GiB` + const mebibyte = 1024 ** 2 + if (bytes % mebibyte === 0) return `${bytes / mebibyte} MiB` + return `${bytes} bytes` +} diff --git a/apps/sim/lib/uploads/client/api-fallback.test.ts b/apps/sim/lib/uploads/client/api-fallback.test.ts deleted file mode 100644 index 0c0d4196144..00000000000 --- a/apps/sim/lib/uploads/client/api-fallback.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { uploadViaApiFallbackWithMetadata } from '@/lib/uploads/client/api-fallback' - -const mockFetch = vi.fn() - -describe('uploadViaApiFallbackWithMetadata', () => { - beforeEach(() => { - mockFetch.mockReset() - vi.stubGlobal('fetch', mockFetch) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('builds execution multipart fields and normalizes an array response', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - files: [ - { - id: 'file-1', - name: 'diagram.png', - url: '/api/files/serve/execution%2Fdiagram.png', - size: 7, - type: 'image/png', - key: 'execution/diagram.png', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - ) - const file = new File(['diagram'], 'diagram.png', { type: 'image/png' }) - - const result = await uploadViaApiFallbackWithMetadata(file, 'execution', { - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }) - - expect(result).toEqual({ - id: 'file-1', - name: 'diagram.png', - path: '/api/files/serve/execution%2Fdiagram.png', - size: 7, - type: 'image/png', - key: 'execution/diagram.png', - }) - - const request = mockFetch.mock.calls[0]?.[1] as RequestInit - const formData = request.body as FormData - expect(formData.get('file')).toBe(file) - expect(formData.get('context')).toBe('execution') - expect(formData.get('workspaceId')).toBe('workspace-1') - expect(formData.get('workflowId')).toBe('workflow-1') - expect(formData.get('executionId')).toBe('execution-1') - }) - - it('throws the exact server upload error', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ error: 'Workspace file storage limit exceeded' }), { - status: 413, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - await expect( - uploadViaApiFallbackWithMetadata( - new File(['report'], 'report.pdf', { type: 'application/pdf' }), - 'execution', - { - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - } - ) - ).rejects.toThrow('Workspace file storage limit exceeded') - }) -}) diff --git a/apps/sim/lib/uploads/client/api-fallback.ts b/apps/sim/lib/uploads/client/api-fallback.ts deleted file mode 100644 index 2281fd05e61..00000000000 --- a/apps/sim/lib/uploads/client/api-fallback.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { isRecordLike } from '@sim/utils/object' -import type { StorageContext } from '@/lib/uploads/shared/types' - -export interface ApiFallbackUploadOptions { - workspaceId?: string - workflowId?: string - executionId?: string -} - -export interface ApiFallbackUploadMetadata { - path: string - key?: string - id?: string - name?: string - size?: number - type?: string - uploadedAt?: string - expiresAt?: string -} - -function getOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed || undefined -} - -function getOptionalNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined -} - -function getUploadFailureReason(value: unknown, response: Response): string { - if (isRecordLike(value)) { - const message = getOptionalString(value.message) ?? getOptionalString(value.error) - if (message) return message - } - - return `Failed to upload file: ${response.status}` -} - -function normalizeUploadMetadata(value: unknown): ApiFallbackUploadMetadata { - if (!isRecordLike(value)) { - throw new Error('Invalid upload response: expected file metadata') - } - - const fileInfo = isRecordLike(value.fileInfo) ? value.fileInfo : undefined - const path = - getOptionalString(fileInfo?.path) ?? - getOptionalString(value.path) ?? - getOptionalString(value.url) - if (!path) { - throw new Error('Invalid upload response: missing path') - } - - return { - path, - key: getOptionalString(fileInfo?.key) ?? getOptionalString(value.key), - id: getOptionalString(fileInfo?.id) ?? getOptionalString(value.id), - name: - getOptionalString(fileInfo?.name) ?? - getOptionalString(value.name) ?? - getOptionalString(value.fileName), - size: getOptionalNumber(fileInfo?.size) ?? getOptionalNumber(value.size), - type: getOptionalString(fileInfo?.type) ?? getOptionalString(value.type), - uploadedAt: getOptionalString(fileInfo?.uploadedAt) ?? getOptionalString(value.uploadedAt), - expiresAt: getOptionalString(fileInfo?.expiresAt) ?? getOptionalString(value.expiresAt), - } -} - -async function parseUploadResponse(response: Response): Promise { - if (!response.ok) { - const errorData: unknown = await response.json().catch(() => null) - throw new Error(getUploadFailureReason(errorData, response)) - } - - let data: unknown - try { - data = await response.json() - } catch { - throw new Error('Invalid upload response: response was not JSON') - } - - const results = isRecordLike(data) && Array.isArray(data.files) ? data.files : [data] - if (results.length === 0) { - throw new Error('Invalid upload response: no files returned') - } - if (results.length > 1) { - throw new Error('Invalid upload response: multiple files returned for a single-file upload') - } - - return normalizeUploadMetadata(results[0]) -} - -/** - * Uploads one file through the server-proxied multipart fallback and returns - * normalized metadata for either the singular or `{ files: [...] }` response. - */ -export async function uploadViaApiFallbackWithMetadata( - file: File, - context: StorageContext, - options: ApiFallbackUploadOptions = {} -): Promise { - const formData = new FormData() - formData.append('file', file) - formData.append('context', context) - if (options.workspaceId) { - formData.append('workspaceId', options.workspaceId) - } - if (options.workflowId) { - formData.append('workflowId', options.workflowId) - } - if (options.executionId) { - formData.append('executionId', options.executionId) - } - - // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson - const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) - return parseUploadResponse(response) -} - -/** - * Server-proxied fallback used only when cloud storage isn't configured (local dev). - * Production always takes the presigned PUT path. - */ -export async function uploadViaApiFallback( - file: File, - context: StorageContext, - workspaceId?: string -): Promise<{ path: string; key?: string }> { - const { path, key } = await uploadViaApiFallbackWithMetadata(file, context, { workspaceId }) - return { path, key } -} diff --git a/apps/sim/lib/uploads/client/concurrency.test.ts b/apps/sim/lib/uploads/client/concurrency.test.ts new file mode 100644 index 00000000000..b0880ef091d --- /dev/null +++ b/apps/sim/lib/uploads/client/concurrency.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { runWithConcurrency } from '@/lib/uploads/client/concurrency' + +describe('runWithConcurrency', () => { + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid concurrency limit %s', + async (limit) => { + await expect(runWithConcurrency([1], limit, async (item) => item)).rejects.toThrow( + 'Concurrency limit must be a positive safe integer' + ) + } + ) + + it('preserves input order while bounding concurrency', async () => { + let active = 0 + let peak = 0 + const release: Array<() => void> = [] + const worker = vi.fn(async (item: number) => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => release.push(resolve)) + active -= 1 + return item * 2 + }) + + const resultPromise = runWithConcurrency([1, 2, 3], 2, worker) + await Promise.resolve() + expect(worker).toHaveBeenCalledTimes(2) + + release.shift()?.() + release.shift()?.() + await Promise.resolve() + await Promise.resolve() + expect(worker).toHaveBeenCalledTimes(3) + release.shift()?.() + + await expect(resultPromise).resolves.toEqual([ + { status: 'fulfilled', value: 2 }, + { status: 'fulfilled', value: 4 }, + { status: 'fulfilled', value: 6 }, + ]) + expect(peak).toBe(2) + }) +}) diff --git a/apps/sim/lib/uploads/client/concurrency.ts b/apps/sim/lib/uploads/client/concurrency.ts new file mode 100644 index 00000000000..307e145776f --- /dev/null +++ b/apps/sim/lib/uploads/client/concurrency.ts @@ -0,0 +1,38 @@ +export const WHOLE_FILE_PARALLEL_UPLOADS = 3 + +/** + * Runs a worker with bounded concurrency and preserves input ordering in the settled results. + */ +export async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise>> { + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error('Concurrency limit must be a positive safe integer') + } + + const results: Array> = Array(items.length) + if (items.length === 0) return results + + const concurrency = Math.min(limit, items.length) + let nextIndex = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const currentIndex = nextIndex++ + if (currentIndex >= items.length) return + try { + results[currentIndex] = { + status: 'fulfilled', + value: await worker(items[currentIndex], currentIndex), + } + } catch (error) { + results[currentIndex] = { status: 'rejected', reason: error } + } + } + }) + + await Promise.all(runners) + return results +} diff --git a/apps/sim/lib/uploads/client/direct-upload.test.ts b/apps/sim/lib/uploads/client/direct-upload.test.ts deleted file mode 100644 index da2b2aca65f..00000000000 --- a/apps/sim/lib/uploads/client/direct-upload.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - DirectUploadError, - type PresignedUploadInfo, - runUploadStrategy, -} from '@/lib/uploads/client/direct-upload' - -const ONE_MB = 1024 * 1024 -const LARGE_THRESHOLD = 50 * ONE_MB - -const makeFile = (size: number, name = 'test.bin', type = 'application/octet-stream'): File => { - const file = new File([new Uint8Array(0)], name, { type }) - Object.defineProperty(file, 'size', { value: size }) - return file -} - -const presigned = (overrides?: Partial): PresignedUploadInfo => ({ - fileName: 'test.bin', - presignedUrl: 'https://s3/presigned', - fileInfo: { - path: '/api/files/serve/test', - key: 'workspace/ws-1/test.bin', - name: 'test.bin', - size: ONE_MB, - type: 'application/octet-stream', - }, - uploadHeaders: undefined, - directUploadSupported: true, - ...overrides, -}) - -class MockXHR { - static instances: MockXHR[] = [] - upload = { addEventListener: vi.fn() } - status = 200 - statusText = 'OK' - private listeners: Record void>> = {} - open = vi.fn() - setRequestHeader = vi.fn() - abort = vi.fn() - send = vi.fn(() => { - queueMicrotask(() => this.listeners.load?.forEach((cb) => cb())) - }) - addEventListener = (event: string, cb: () => void) => { - ;(this.listeners[event] ??= []).push(cb) - } - removeEventListener = vi.fn() - constructor() { - MockXHR.instances.push(this) - } -} - -describe('runUploadStrategy', () => { - let originalXHR: typeof XMLHttpRequest - - beforeEach(() => { - MockXHR.instances = [] - originalXHR = globalThis.XMLHttpRequest - globalThis.XMLHttpRequest = MockXHR as unknown as typeof XMLHttpRequest - }) - - afterEach(() => { - globalThis.XMLHttpRequest = originalXHR - vi.restoreAllMocks() - }) - - it('uses presigned PUT for files at or below the multipart threshold', async () => { - const file = makeFile(LARGE_THRESHOLD) - - const result = await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned(), - }) - - expect(result.key).toBe('workspace/ws-1/test.bin') - expect(MockXHR.instances).toHaveLength(1) - expect(MockXHR.instances[0].open).toHaveBeenCalledWith('PUT', 'https://s3/presigned') - }) - - it('sets Content-Type exactly once when uploadHeaders already carry it (GCS signed uploads)', async () => { - const file = makeFile(1024) - - await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned({ - uploadHeaders: { - 'Content-Type': 'application/octet-stream', - 'x-goog-meta-workspaceid': 'ws-1', - }, - }), - }) - - const calls = MockXHR.instances[0].setRequestHeader.mock.calls - const contentTypeCalls = calls.filter( - ([k]: [string, string]) => k.toLowerCase() === 'content-type' - ) - expect(contentTypeCalls).toHaveLength(1) - expect(contentTypeCalls[0][1]).toBe('application/octet-stream') - expect(calls.some(([k]: [string, string]) => k === 'x-goog-meta-workspaceid')).toBe(true) - }) - - it('falls back to the file content type when uploadHeaders omit Content-Type', async () => { - const file = makeFile(1024) - - await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned({ uploadHeaders: { 'x-ms-blob-type': 'BlockBlob' } }), - }) - - const calls = MockXHR.instances[0].setRequestHeader.mock.calls - const contentTypeCalls = calls.filter( - ([k]: [string, string]) => k.toLowerCase() === 'content-type' - ) - expect(contentTypeCalls).toHaveLength(1) - }) - - it('throws FALLBACK_REQUIRED when server signals no cloud storage', async () => { - const file = makeFile(ONE_MB) - - await expect( - runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned({ presignedUrl: '', directUploadSupported: false }), - }) - ).rejects.toMatchObject({ - name: 'DirectUploadError', - code: 'FALLBACK_REQUIRED', - }) - }) - - it('takes the multipart path for files larger than the threshold and posts unified parts', async () => { - const file = makeFile(LARGE_THRESHOLD + ONE_MB) - const calls: Array<{ url: string; body: unknown }> = [] - - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const rawBody = init?.body - const body = typeof rawBody === 'string' ? JSON.parse(rawBody) : undefined - calls.push({ url, body }) - - if (url.includes('action=initiate')) { - return new Response( - JSON.stringify({ uploadId: 'u1', key: 'workspace/ws-1/big.bin', uploadToken: 't' }), - { status: 200 } - ) - } - if (url.includes('action=get-part-urls')) { - return new Response( - JSON.stringify({ - presignedUrls: [ - { partNumber: 1, url: 'https://s3/part1' }, - { partNumber: 2, url: 'https://s3/part2' }, - ], - }), - { status: 200 } - ) - } - if (url.startsWith('https://s3/part')) { - return new Response(null, { status: 200, headers: { ETag: '"etag-x"' } }) - } - if (url.includes('action=complete')) { - return new Response(JSON.stringify({ path: '/api/files/serve/big' }), { status: 200 }) - } - throw new Error(`unexpected url ${url}`) - }) - - vi.stubGlobal('fetch', fetchMock) - - const result = await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - }) - - expect(result.path).toBe('/api/files/serve/big') - - const completeCall = calls.find((c) => c.url.includes('action=complete'))! - expect(completeCall.body).toMatchObject({ - uploadToken: 't', - parts: [ - { partNumber: 1, etag: 'etag-x' }, - { partNumber: 2, etag: 'etag-x' }, - ], - }) - }) - - it('rejects with ABORTED when signal is already aborted before PUT begins', async () => { - const file = makeFile(ONE_MB) - const controller = new AbortController() - controller.abort() - - await expect( - runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned(), - signal: controller.signal, - }) - ).rejects.toMatchObject({ name: 'DirectUploadError', code: 'ABORTED' }) - }) - - it('fires action=abort when the multipart complete call fails', async () => { - const file = makeFile(LARGE_THRESHOLD + ONE_MB) - const calls: Array<{ url: string }> = [] - - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input.toString() - calls.push({ url }) - - if (url.includes('action=initiate')) { - return new Response( - JSON.stringify({ uploadId: 'u1', key: 'workspace/ws-1/big.bin', uploadToken: 't' }), - { status: 200 } - ) - } - if (url.includes('action=get-part-urls')) { - return new Response( - JSON.stringify({ - presignedUrls: [ - { partNumber: 1, url: 'https://s3/part1' }, - { partNumber: 2, url: 'https://s3/part2' }, - ], - }), - { status: 200 } - ) - } - if (url.startsWith('https://s3/part')) { - return new Response(null, { status: 200, headers: { ETag: '"etag-x"' } }) - } - if (url.includes('action=complete')) { - return new Response(JSON.stringify({ error: 'kaboom' }), { status: 500 }) - } - if (url.includes('action=abort')) { - return new Response(null, { status: 200 }) - } - throw new Error(`unexpected url ${url}`) - }) - - vi.stubGlobal('fetch', fetchMock) - - await expect( - runUploadStrategy({ file, workspaceId: 'ws-1', context: 'workspace' }) - ).rejects.toBeInstanceOf(DirectUploadError) - - expect(calls.some((c) => c.url.includes('action=abort'))).toBe(true) - }) - - it('throws when neither presignedEndpoint nor presignedOverride is supplied', async () => { - const file = makeFile(ONE_MB) - await expect( - runUploadStrategy({ file, workspaceId: 'ws-1', context: 'workspace' }) - ).rejects.toBeInstanceOf(DirectUploadError) - }) -}) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts deleted file mode 100644 index 41e9911c26d..00000000000 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ /dev/null @@ -1,637 +0,0 @@ -import { createLogger } from '@sim/logger' -import { sleep } from '@sim/utils/helpers' -import { getFileContentType, isAbortError } from '@/lib/uploads/utils/file-utils' - -const logger = createLogger('DirectUpload') - -const CHUNK_SIZE = 8 * 1024 * 1024 -export const LARGE_FILE_THRESHOLD = 50 * 1024 * 1024 -const BASE_TIMEOUT_MS = 2 * 60 * 1000 -const TIMEOUT_PER_MB_MS = 1500 -const MAX_TIMEOUT_MS = 10 * 60 * 1000 -export const MULTIPART_PART_CONCURRENCY = 3 -export const MULTIPART_MAX_RETRIES = 3 -export const MULTIPART_RETRY_DELAY_MS = 2000 -export const MULTIPART_RETRY_BACKOFF = 2 -export const WHOLE_FILE_PARALLEL_UPLOADS = 3 - -interface PresignedFileInfo { - path: string - key: string - name: string - size: number - type: string -} - -export interface PresignedUploadInfo { - fileName: string - presignedUrl: string - fileInfo: PresignedFileInfo - uploadHeaders?: Record - directUploadSupported: boolean -} - -export interface UploadStrategyResult { - key: string - path: string - name: string - size: number - contentType: string -} - -export interface UploadProgressEvent { - loaded: number - total: number - percent: number -} - -export type DirectUploadErrorCode = - | 'PRESIGNED_URL_ERROR' - | 'DIRECT_UPLOAD_ERROR' - | 'MULTIPART_ERROR' - | 'ABORTED' - | 'FALLBACK_REQUIRED' - -export class DirectUploadError extends Error { - constructor( - message: string, - public code: DirectUploadErrorCode, - public details?: unknown, - public status?: number - ) { - super(message) - this.name = 'DirectUploadError' - } -} - -/** - * Transport-level upload errors worth retrying at the outer level: timeouts, - * network failures, and 5xx from the storage backend. Excludes deterministic - * client failures (4xx, `PRESIGNED_URL_ERROR`, `FALLBACK_REQUIRED`) and aborts. - */ -export const isTransientUploadError = (error: unknown): boolean => { - if (!(error instanceof DirectUploadError)) return false - if (error.code !== 'DIRECT_UPLOAD_ERROR' && error.code !== 'MULTIPART_ERROR') return false - if (error.status === undefined) return true - return error.status >= 500 && error.status < 600 -} - -export const calculateUploadTimeoutMs = (fileSize: number): number => { - const sizeInMb = fileSize / (1024 * 1024) - const dynamicBudget = BASE_TIMEOUT_MS + sizeInMb * TIMEOUT_PER_MB_MS - return Math.min(dynamicBudget, MAX_TIMEOUT_MS) -} - -/** - * Run `worker` over `items` with at most `limit` concurrent invocations. - * Returns a settled result per item (never rejects), so callers can handle - * partial failures explicitly. - */ -export const runWithConcurrency = async ( - items: T[], - limit: number, - worker: (item: T, index: number) => Promise -): Promise>> => { - const results: Array> = Array(items.length) - if (items.length === 0) return results - - const concurrency = Math.max(1, Math.min(limit, items.length)) - let nextIndex = 0 - - const runners = Array.from({ length: concurrency }, async () => { - while (true) { - const currentIndex = nextIndex++ - if (currentIndex >= items.length) break - try { - const value = await worker(items[currentIndex], currentIndex) - results[currentIndex] = { status: 'fulfilled', value } - } catch (error) { - results[currentIndex] = { status: 'rejected', reason: error } - } - } - }) - - await Promise.all(runners) - return results -} - -/** - * Normalize a presigned-upload server response into a {@link PresignedUploadInfo}. - * Accepts both single (`/api/files/presigned`) and batch entry shapes, tolerates - * `presignedUrl` vs `uploadUrl` aliases, and short-circuits when the server - * signals no cloud storage (`directUploadSupported: false`) so callers can fall - * back to a server-proxied upload path. - * - * @throws {@link DirectUploadError} with code `PRESIGNED_URL_ERROR` if the - * response is missing a presigned URL or `fileInfo.path`. - */ -export const normalizePresignedData = (data: unknown, context: string): PresignedUploadInfo => { - const d = (data ?? {}) as Record - const presignedUrl = (d.presignedUrl as string) || (d.uploadUrl as string) || '' - const fileInfo = d.fileInfo as Record | undefined - const directUploadSupported = d.directUploadSupported !== false - - if (!directUploadSupported) { - return { - fileName: (d.fileName as string) || context, - presignedUrl: '', - fileInfo: { path: '', key: '', name: context, size: 0, type: '' }, - directUploadSupported: false, - } - } - - if (!presignedUrl || !fileInfo?.path) { - throw new DirectUploadError( - `Invalid presigned response for ${context}`, - 'PRESIGNED_URL_ERROR', - data - ) - } - - return { - fileName: (d.fileName as string) || (fileInfo.name as string) || context, - presignedUrl, - fileInfo: { - path: fileInfo.path as string, - key: (fileInfo.key as string) || '', - name: (fileInfo.name as string) || context, - size: (fileInfo.size as number) || (d.fileSize as number) || 0, - type: (fileInfo.type as string) || (d.contentType as string) || '', - }, - uploadHeaders: (d.uploadHeaders as Record) || undefined, - directUploadSupported: true, - } -} - -interface GetPresignedOptions { - endpoint: string - file: File - body?: Record - signal?: AbortSignal -} - -/** - * Fetch a single presigned upload URL from a server endpoint that follows the - * `{ presignedUrl, fileInfo, uploadHeaders?, directUploadSupported }` contract. - */ -export const getPresignedUploadInfo = async ( - opts: GetPresignedOptions -): Promise => { - const { endpoint, file, body, signal } = opts - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - fileName: file.name, - contentType: getFileContentType(file), - fileSize: file.size, - ...body, - }), - signal, - }) - - if (!response.ok) { - let errorDetails: unknown = null - try { - errorDetails = await response.json() - } catch {} - const serverMessage = - errorDetails != null && - typeof errorDetails === 'object' && - typeof (errorDetails as Record).error === 'string' - ? ((errorDetails as Record).error as string) - : null - throw new DirectUploadError( - serverMessage || - `Failed to get presigned URL for ${file.name}: ${response.status} ${response.statusText}`, - 'PRESIGNED_URL_ERROR', - errorDetails - ) - } - - return normalizePresignedData(await response.json(), file.name) -} - -interface UploadViaPutOptions { - file: File - presignedUrl: string - uploadHeaders?: Record - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void -} - -const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { - const { file, presignedUrl, uploadHeaders, signal, onProgress } = opts - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - let isCompleted = false - const timeoutMs = calculateUploadTimeoutMs(file.size) - - const timeoutId = setTimeout(() => { - if (isCompleted) return - isCompleted = true - signal?.removeEventListener('abort', abortHandler) - xhr.abort() - reject(new DirectUploadError(`Upload timeout for ${file.name}`, 'DIRECT_UPLOAD_ERROR')) - }, timeoutMs) - - const abortHandler = () => { - if (isCompleted) return - isCompleted = true - clearTimeout(timeoutId) - xhr.abort() - reject(new DirectUploadError(`Upload aborted for ${file.name}`, 'ABORTED')) - } - - if (signal) { - if (signal.aborted) { - abortHandler() - return - } - signal.addEventListener('abort', abortHandler) - } - - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable && !isCompleted) { - onProgress?.({ - loaded: event.loaded, - total: event.total, - percent: Math.round((event.loaded / event.total) * 100), - }) - } - }) - - xhr.addEventListener('load', () => { - if (isCompleted) return - isCompleted = true - clearTimeout(timeoutId) - signal?.removeEventListener('abort', abortHandler) - - if (xhr.status >= 200 && xhr.status < 300) { - onProgress?.({ loaded: file.size, total: file.size, percent: 100 }) - resolve() - } else { - reject( - new DirectUploadError( - `Direct upload failed for ${file.name}: ${xhr.status} ${xhr.statusText}`, - 'DIRECT_UPLOAD_ERROR', - undefined, - xhr.status - ) - ) - } - }) - - xhr.addEventListener('error', () => { - if (isCompleted) return - isCompleted = true - clearTimeout(timeoutId) - signal?.removeEventListener('abort', abortHandler) - reject(new DirectUploadError(`Network error uploading ${file.name}`, 'DIRECT_UPLOAD_ERROR')) - }) - - xhr.open('PUT', presignedUrl) - const providesContentType = - uploadHeaders && - Object.keys(uploadHeaders).some((key) => key.toLowerCase() === 'content-type') - if (!providesContentType) { - xhr.setRequestHeader('Content-Type', getFileContentType(file)) - } - if (uploadHeaders) { - for (const [key, value] of Object.entries(uploadHeaders)) { - xhr.setRequestHeader(key, value) - } - } - xhr.send(file) - }) -} - -interface MultipartUploadOptions { - file: File - workspaceId: string - context: - | 'workspace' - | 'knowledge-base' - | 'mothership' - | 'profile-pictures' - | 'workspace-logos' - | 'execution' - workflowId?: string - executionId?: string - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void -} - -interface CompletedPart { - partNumber: number - etag?: string -} - -interface PartUrl { - partNumber: number - url: string -} - -const uploadViaMultipart = async ( - opts: MultipartUploadOptions -): Promise<{ key: string; path: string }> => { - const { file, workspaceId, context, workflowId, executionId, signal, onProgress } = opts - - // boundary-raw-fetch: multipart upload control plane uses action query strings; client lifecycle (initiate/get-part-urls/complete/abort) is sequenced manually and not modeled by a single contract - const initiateResponse = await fetch('/api/files/multipart?action=initiate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - fileName: file.name, - contentType: getFileContentType(file), - fileSize: file.size, - workspaceId, - context, - ...(workflowId ? { workflowId } : {}), - ...(executionId ? { executionId } : {}), - }), - signal, - }) - - if (!initiateResponse.ok) { - let errorBody: { error?: string } | null = null - try { - errorBody = (await initiateResponse.clone().json()) as { error?: string } - } catch {} - if ( - initiateResponse.status === 400 && - typeof errorBody?.error === 'string' && - errorBody.error.toLowerCase().includes('cloud storage') - ) { - throw new DirectUploadError( - 'Server signaled fallback to API upload', - 'FALLBACK_REQUIRED', - errorBody - ) - } - throw new DirectUploadError( - `Failed to initiate multipart upload: ${initiateResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - initiateResponse.status - ) - } - - const { key, uploadToken } = (await initiateResponse.json()) as { - uploadId: string - key: string - uploadToken: string - } - - const numParts = Math.ceil(file.size / CHUNK_SIZE) - const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1) - - const abortMultipart = async () => { - try { - // boundary-raw-fetch: fire-and-forget abort during multipart cleanup; intentionally avoids contract response parsing so cleanup cannot mask the original error - await fetch('/api/files/multipart?action=abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadToken }), - }) - } catch (err) { - logger.warn('Failed to abort multipart upload:', err) - } - } - - let presignedUrls: PartUrl[] - try { - // boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/complete/abort outside the contract layer - const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadToken, partNumbers }), - signal, - }) - - if (!partUrlsResponse.ok) { - throw new DirectUploadError( - `Failed to get part URLs: ${partUrlsResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - partUrlsResponse.status - ) - } - - ;({ presignedUrls } = (await partUrlsResponse.json()) as { presignedUrls: PartUrl[] }) - } catch (err) { - await abortMultipart() - throw err - } - - const completedBytes = new Array(numParts).fill(0) - const reportProgress = () => { - const loaded = completedBytes.reduce((a, b) => a + b, 0) - onProgress?.({ - loaded, - total: file.size, - percent: Math.min(100, Math.round((loaded / file.size) * 100)), - }) - } - - const uploadedParts: CompletedPart[] = [] - - try { - const uploadPart = async ({ partNumber, url }: PartUrl): Promise => { - const start = (partNumber - 1) * CHUNK_SIZE - const end = Math.min(start + CHUNK_SIZE, file.size) - const chunk = file.slice(start, end) - - for (let attempt = 0; attempt <= MULTIPART_MAX_RETRIES; attempt++) { - try { - const partResponse = await fetch(url, { - method: 'PUT', - body: chunk, - signal, - headers: { 'Content-Type': getFileContentType(file) }, - }) - - if (!partResponse.ok) { - throw new DirectUploadError( - `Failed to upload part ${partNumber}: ${partResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - partResponse.status - ) - } - - const etag = partResponse.headers.get('ETag') || undefined - completedBytes[partNumber - 1] = end - start - reportProgress() - - return { partNumber, etag: etag?.replace(/"/g, '') } - } catch (partError) { - const isClientError = - partError instanceof DirectUploadError && - partError.status !== undefined && - partError.status >= 400 && - partError.status < 500 - if (isAbortError(partError) || isClientError || attempt >= MULTIPART_MAX_RETRIES) { - throw partError - } - const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt - logger.warn( - `Part ${partNumber} failed (attempt ${attempt + 1}), retrying in ${Math.round(delay / 1000)}s` - ) - await sleep(delay) - } - } - - throw new DirectUploadError(`Retries exhausted for part ${partNumber}`, 'MULTIPART_ERROR') - } - - const partResults = await runWithConcurrency( - presignedUrls, - MULTIPART_PART_CONCURRENCY, - uploadPart - ) - - for (const result of partResults) { - if (result?.status === 'fulfilled') { - uploadedParts.push(result.value) - } else if (result?.status === 'rejected') { - throw result.reason - } - } - } catch (error) { - await abortMultipart() - throw error - } - - let path: string - try { - // boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/get-part-urls/abort outside the contract layer - const completeResponse = await fetch('/api/files/multipart?action=complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadToken, parts: uploadedParts }), - signal, - }) - - if (!completeResponse.ok) { - throw new DirectUploadError( - `Failed to complete multipart upload: ${completeResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - completeResponse.status - ) - } - - ;({ path } = (await completeResponse.json()) as { path: string }) - } catch (err) { - await abortMultipart() - throw err - } - return { key, path } -} - -export interface RunUploadStrategyOptions { - file: File - workspaceId: string - context: - | 'workspace' - | 'knowledge-base' - | 'mothership' - | 'profile-pictures' - | 'workspace-logos' - | 'execution' - /** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */ - presignedEndpoint?: string - /** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */ - presignedOverride?: PresignedUploadInfo - /** Extra JSON body fields for the presigned endpoint. */ - presignedBody?: Record - /** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */ - workflowId?: string - /** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */ - executionId?: string - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void -} - -/** - * Strategy ladder for client-side uploads: - * - Files larger than {@link LARGE_FILE_THRESHOLD} use multipart S3/Blob with chunked PUTs. - * - Smaller files use a presigned PUT URL (fetched per-file, or supplied via - * `presignedOverride` for batched flows like KB). - * - If the server signals no cloud storage is configured, a {@link DirectUploadError} - * with code `FALLBACK_REQUIRED` is thrown so callers can fall back to a server-proxied path. - */ -export const runUploadStrategy = async ( - opts: RunUploadStrategyOptions -): Promise => { - const { - file, - presignedEndpoint, - presignedOverride, - presignedBody, - workspaceId, - context, - workflowId, - executionId, - signal, - onProgress, - } = opts - const contentType = getFileContentType(file) - - if (presignedOverride && !presignedOverride.directUploadSupported) { - throw new DirectUploadError('Server signaled fallback to API upload', 'FALLBACK_REQUIRED') - } - - if (file.size > LARGE_FILE_THRESHOLD) { - const { key, path } = await uploadViaMultipart({ - file, - workspaceId, - context, - workflowId, - executionId, - signal, - onProgress, - }) - return { key, path, name: file.name, size: file.size, contentType } - } - - let presigned: PresignedUploadInfo - if (presignedOverride) { - presigned = presignedOverride - } else { - if (!presignedEndpoint) { - throw new DirectUploadError( - 'runUploadStrategy requires either presignedEndpoint or presignedOverride', - 'PRESIGNED_URL_ERROR' - ) - } - presigned = await getPresignedUploadInfo({ - endpoint: presignedEndpoint, - file, - body: presignedBody, - signal, - }) - } - - if (!presigned.directUploadSupported) { - throw new DirectUploadError('Server signaled fallback to API upload', 'FALLBACK_REQUIRED') - } - - await uploadViaPresignedPut({ - file, - presignedUrl: presigned.presignedUrl, - uploadHeaders: presigned.uploadHeaders, - signal, - onProgress, - }) - - return { - key: presigned.fileInfo.key, - path: presigned.fileInfo.path, - name: file.name, - size: file.size, - contentType, - } -} diff --git a/apps/sim/lib/uploads/client/session-upload.test.ts b/apps/sim/lib/uploads/client/session-upload.test.ts new file mode 100644 index 00000000000..026c3f55a0e --- /dev/null +++ b/apps/sim/lib/uploads/client/session-upload.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' + +interface UploadClientMockParams { + complete: (body: V2CompleteUploadBody) => Promise +} + +const { mockRequestJson, mockUploadFileSession } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + mockUploadFileSession: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) +vi.mock('@/lib/uploads/client/upload-session', () => ({ + uploadFileSession: mockUploadFileSession, +})) + +import { + uploadInternalFileSession, + uploadKnowledgeDocumentSession, +} from '@/lib/uploads/client/session-upload' + +const DOCUMENT = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: 'guide.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + createdAt: '2026-08-04T21:00:00.000Z', +} as const + +describe('session upload domain clients', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the PUT knowledge session without requesting part URLs', async () => { + mockRequestJson + .mockResolvedValueOnce({ + data: { + session: { id: 'upload-1' }, + uploadToken: 'token', + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/pdf' }, + }, + }, + }) + .mockResolvedValueOnce({ data: { document: DOCUMENT } }) + mockUploadFileSession.mockImplementation( + async (params: UploadClientMockParams) => params.complete({}) + ) + const file = { name: 'guide.pdf', type: 'application/pdf', size: 1024 } as File + + await expect( + uploadKnowledgeDocumentSession({ + workspaceId: 'workspace-1', + knowledgeBaseId: 'kb-1', + file, + tag1: 'product', + }) + ).resolves.toEqual(DOCUMENT) + + expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/knowledge/[id]/documents/uploads') + expect(mockRequestJson.mock.calls[1][0].path).toBe( + '/api/knowledge/[id]/documents/uploads/[uploadId]/complete' + ) + expect(mockRequestJson.mock.calls[1][1].body).toEqual({}) + expect(mockRequestJson.mock.calls.some(([contract]) => contract.path.endsWith('/parts'))).toBe( + false + ) + }) + + it('returns the purpose-specific result from the generic internal session', async () => { + const result = { + path: '/api/files/serve/logo.png', + key: 'workspace-logos/logo.png', + name: 'logo.png', + size: 100, + type: 'image/png', + } + mockRequestJson + .mockResolvedValueOnce({ + data: { + session: { id: 'upload-2', purpose: 'workspace_logo' }, + uploadToken: 'token', + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'image/png' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { id: 'upload-2', purpose: 'workspace_logo', result }, + }) + mockUploadFileSession.mockImplementation( + async (params: UploadClientMockParams) => params.complete({}) + ) + + await expect( + uploadInternalFileSession({ + purpose: 'workspace_logo', + workspaceId: 'workspace-1', + file: { name: 'logo.png', type: 'image/png', size: 100 } as File, + }) + ).resolves.toEqual(result) + + expect(mockRequestJson.mock.calls[0][1].body).toEqual({ + purpose: 'workspace_logo', + workspaceId: 'workspace-1', + name: 'logo.png', + contentType: 'image/png', + size: 100, + }) + }) +}) diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts new file mode 100644 index 00000000000..dabb59b893e --- /dev/null +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -0,0 +1,240 @@ +import { requestJson } from '@/lib/api/client/request' +import { + abortKnowledgeDocumentUploadContract, + completeKnowledgeDocumentUploadContract, + createKnowledgeDocumentUploadContract, + createKnowledgeDocumentUploadPartUrlsContract, +} from '@/lib/api/contracts/knowledge/upload-sessions' +import { + abortInternalFileUploadContract, + type CreateInternalFileUploadBody, + completeInternalFileUploadContract, + createInternalFileUploadContract, + createInternalFileUploadPartUrlsContract, + type InternalFileUploadSession, +} from '@/lib/api/contracts/upload-sessions' +import type { + V2KnowledgeDocumentSummary, + V2KnowledgeDocumentUploadMetadata, +} from '@/lib/api/contracts/v2/knowledge' +import type { + V2CompleteUploadBody, + V2UploadPartUrl, + V2UploadTransfer, +} from '@/lib/api/contracts/v2/uploads' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' +import { uploadFileSession } from '@/lib/uploads/client/upload-session' +import { getFileContentType } from '@/lib/uploads/utils/file-utils' + +interface UploadWorkspaceFileSessionParams { + workspaceId: string + folderId?: string | null + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + +interface InternalUploadCommonParams { + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + +type InternalUploadContext = + | { purpose: 'workspace_file'; workspaceId: string; folderId?: string | null } + | { purpose: 'profile_picture' } + | { purpose: 'workspace_logo'; workspaceId: string } + | { purpose: 'mothership_attachment'; workspaceId: string } + | { + purpose: 'execution_attachment' + workspaceId: string + workflowId: string + executionId: string + } + +type InternalUploadPurpose = InternalUploadContext['purpose'] +type InternalUploadResult = NonNullable< + Extract['result'] +> + +export type UploadInternalFileSessionParams = InternalUploadCommonParams & InternalUploadContext + +interface UploadKnowledgeDocumentSessionParams extends V2KnowledgeDocumentUploadMetadata { + workspaceId: string + knowledgeBaseId: string + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + +interface RunCreatedUploadParams { + file: File + transfer: V2UploadTransfer + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void + getPartUrls: (partNumbers: number[]) => Promise + complete: (body: V2CompleteUploadBody) => Promise + abort: () => Promise +} + +function runCreatedUpload(params: RunCreatedUploadParams): Promise { + const common = { + file: params.file, + signal: params.signal, + onProgress: params.onProgress, + complete: params.complete, + abort: params.abort, + } + return params.transfer.method === 'put' + ? uploadFileSession({ ...common, transfer: params.transfer }) + : uploadFileSession({ + ...common, + transfer: params.transfer, + getPartUrls: params.getPartUrls, + }) +} + +export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSessionParams) { + return uploadInternalFileSession({ + purpose: 'workspace_file', + ...params, + }) +} + +export async function uploadInternalFileSession( + params: InternalUploadCommonParams & Extract +): Promise> { + const { file, signal, onProgress } = params + const created = await requestJson(createInternalFileUploadContract, { + body: internalUploadBody(params), + signal, + }) + const { session, uploadToken, transfer } = created.data + if (session.purpose !== params.purpose) { + throw new Error(`Expected ${params.purpose} upload session; received ${session.purpose}`) + } + return runCreatedUpload({ + file, + transfer, + signal, + onProgress, + getPartUrls: async (partNumbers) => { + const batch = await requestJson(createInternalFileUploadPartUrlsContract, { + params: { uploadId: session.id }, + headers: { 'upload-token': uploadToken }, + body: { partNumbers }, + signal, + }) + return batch.data.parts + }, + complete: async (body) => { + const completed = await requestJson(completeInternalFileUploadContract, { + params: { uploadId: session.id }, + headers: { 'upload-token': uploadToken }, + body, + signal, + }) + if (completed.data.purpose !== params.purpose) { + throw new Error(`Expected ${params.purpose} completion; received ${completed.data.purpose}`) + } + if (!completed.data.result) { + throw new Error(`Completed ${params.purpose} upload returned no result`) + } + return completed.data.result as InternalUploadResult + }, + abort: async () => { + await requestJson(abortInternalFileUploadContract, { + params: { uploadId: session.id }, + headers: { 'upload-token': uploadToken }, + }) + }, + }) +} + +function internalUploadBody(params: UploadInternalFileSessionParams): CreateInternalFileUploadBody { + const fileFields = { + name: params.file.name, + contentType: getFileContentType(params.file), + size: params.file.size, + } + switch (params.purpose) { + case 'workspace_file': + return { + purpose: params.purpose, + workspaceId: params.workspaceId, + ...fileFields, + ...(params.folderId ? { folderId: params.folderId } : {}), + } + case 'profile_picture': + return { purpose: params.purpose, ...fileFields } + case 'workspace_logo': + case 'mothership_attachment': + return { + purpose: params.purpose, + workspaceId: params.workspaceId, + ...fileFields, + } + case 'execution_attachment': + return { + purpose: params.purpose, + workspaceId: params.workspaceId, + workflowId: params.workflowId, + executionId: params.executionId, + ...fileFields, + } + } +} + +export async function uploadKnowledgeDocumentSession( + params: UploadKnowledgeDocumentSessionParams +): Promise { + const { workspaceId, knowledgeBaseId, file, signal, onProgress, ...metadata } = params + const created = await requestJson(createKnowledgeDocumentUploadContract, { + params: { id: knowledgeBaseId }, + body: { + workspaceId, + name: file.name, + contentType: getFileContentType(file), + size: file.size, + ...metadata, + }, + signal, + }) + const { session, uploadToken, transfer } = created.data + return runCreatedUpload({ + file, + transfer, + signal, + onProgress, + getPartUrls: async (partNumbers) => { + const batch = await requestJson(createKnowledgeDocumentUploadPartUrlsContract, { + params: { id: knowledgeBaseId, uploadId: session.id }, + query: { workspaceId }, + headers: { 'upload-token': uploadToken }, + body: { partNumbers }, + signal, + }) + return batch.data.parts + }, + complete: async (body) => { + const completed = await requestJson(completeKnowledgeDocumentUploadContract, { + params: { id: knowledgeBaseId, uploadId: session.id }, + query: { workspaceId }, + headers: { 'upload-token': uploadToken }, + body, + signal, + }) + if (!completed.data.document) { + throw new Error('Completed upload returned no knowledge document') + } + return completed.data.document + }, + abort: async () => { + await requestJson(abortKnowledgeDocumentUploadContract, { + params: { id: knowledgeBaseId, uploadId: session.id }, + query: { workspaceId }, + headers: { 'upload-token': uploadToken }, + }) + }, + }) +} diff --git a/apps/sim/lib/uploads/client/types.ts b/apps/sim/lib/uploads/client/types.ts new file mode 100644 index 00000000000..28d4c02ad98 --- /dev/null +++ b/apps/sim/lib/uploads/client/types.ts @@ -0,0 +1,5 @@ +export interface UploadProgressEvent { + loaded: number + total: number + percent: number +} diff --git a/apps/sim/lib/uploads/client/upload-session.test.ts b/apps/sim/lib/uploads/client/upload-session.test.ts new file mode 100644 index 00000000000..8df72f6b24f --- /dev/null +++ b/apps/sim/lib/uploads/client/upload-session.test.ts @@ -0,0 +1,325 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' +import { calculateUploadTimeoutMs, uploadFileSession } from '@/lib/uploads/client/upload-session' + +const MIB = 1024 * 1024 +const PUT_THRESHOLD = 50 * MIB + +function sizedFile(size: number): File { + const file = new File([], 'data.bin', { type: 'application/octet-stream' }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +class MockXhr extends EventTarget { + static instances: MockXhr[] = [] + static onSend: (xhr: MockXhr) => void = (xhr) => { + queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + } + + readonly upload = new EventTarget() + readonly requestHeaders = new Map() + status = 200 + statusText = 'OK' + timeout = 0 + responseHeaders = new Map() + open = vi.fn() + abort = vi.fn() + send = vi.fn(() => MockXhr.onSend(this)) + + constructor() { + super() + MockXhr.instances.push(this) + } + + setRequestHeader(name: string, value: string) { + this.requestHeaders.set(name, value) + } + + getResponseHeader(name: string) { + return this.responseHeaders.get(name) ?? null + } +} + +describe('uploadFileSession', () => { + const originalXhr = globalThis.XMLHttpRequest + + beforeEach(() => { + MockXhr.instances = [] + MockXhr.onSend = (xhr) => queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + globalThis.XMLHttpRequest = MockXhr as unknown as typeof XMLHttpRequest + }) + + afterEach(() => { + globalThis.XMLHttpRequest = originalXhr + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('uploads an exact-threshold file with PUT and completes with an empty body', async () => { + const file = sizedFile(PUT_THRESHOLD) + const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const abort = vi.fn(async () => undefined) + const onProgress = vi.fn() + + await expect( + uploadFileSession({ + file, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/octet-stream', 'x-upload': 'signed' }, + }, + complete, + abort, + onProgress, + }) + ).resolves.toBe('done') + + expect(MockXhr.instances).toHaveLength(1) + expect(MockXhr.instances[0].open).toHaveBeenCalledWith('PUT', 'https://storage.example/upload') + expect(MockXhr.instances[0].requestHeaders).toEqual( + new Map([ + ['Content-Type', 'application/octet-stream'], + ['x-upload', 'signed'], + ]) + ) + expect(MockXhr.instances[0].timeout).toBe(calculateUploadTimeoutMs(file.size)) + expect(complete).toHaveBeenCalledWith({}) + expect(onProgress).toHaveBeenLastCalledWith({ + loaded: PUT_THRESHOLD, + total: PUT_THRESHOLD, + percent: 100, + }) + expect(abort).not.toHaveBeenCalled() + }) + + it('uploads an empty file with PUT and reports finite completion progress', async () => { + const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const onProgress = vi.fn() + + await expect( + uploadFileSession({ + file: sizedFile(0), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete, + abort: vi.fn(async () => undefined), + onProgress, + }) + ).resolves.toBe('done') + + expect(complete).toHaveBeenCalledWith({}) + expect(onProgress).toHaveBeenLastCalledWith({ loaded: 0, total: 0, percent: 100 }) + }) + + it('uploads a file above the threshold through bounded multipart batches', async () => { + const file = sizedFile(PUT_THRESHOLD + 1) + const partSize = 8 * MIB + const partCount = Math.ceil(file.size / partSize) + const getPartUrls = vi.fn(async (partNumbers: number[]) => + [...partNumbers].reverse().map((partNumber) => ({ + partNumber, + url: `https://storage.example/parts/${partNumber}`, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt: '2026-08-05T00:00:00.000Z', + })) + ) + const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const abort = vi.fn(async () => undefined) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200, headers: { etag: '"part-etag"' } })) + ) + + await expect( + uploadFileSession({ + file, + transfer: { method: 'multipart', partSize, partCount }, + getPartUrls, + complete, + abort, + }) + ).resolves.toBe('done') + + expect(getPartUrls).toHaveBeenCalledWith(Array.from({ length: partCount }, (_, i) => i + 1)) + expect(complete).toHaveBeenCalledWith({ + parts: Array.from({ length: partCount }, (_, index) => ({ + partNumber: index + 1, + etag: 'part-etag', + })), + }) + expect(abort).not.toHaveBeenCalled() + }) + + it('does not retry a deterministic PUT 4xx', async () => { + MockXhr.onSend = (xhr) => { + xhr.status = 403 + xhr.statusText = 'Forbidden' + queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + } + const abort = vi.fn(async () => undefined) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: vi.fn(), + abort, + }) + ).rejects.toMatchObject({ status: 403 }) + + expect(MockXhr.instances).toHaveLength(1) + expect(abort).toHaveBeenCalledTimes(1) + }) + + it('retains a completed PUT transfer when completion fails', async () => { + const abort = vi.fn(async () => undefined) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: async () => { + throw new Error('finalizer unavailable') + }, + abort, + }) + ).rejects.toThrow('finalizer unavailable') + + expect(abort).not.toHaveBeenCalled() + }) + + it('retries transient PUT failures and keeps progress monotonic', async () => { + vi.useFakeTimers() + const onProgress = vi.fn() + MockXhr.onSend = (xhr) => { + const attempt = MockXhr.instances.length + if (attempt === 1) { + xhr.upload.dispatchEvent( + new ProgressEvent('progress', { lengthComputable: true, loaded: 8, total: 10 }) + ) + queueMicrotask(() => xhr.dispatchEvent(new Event('error'))) + return + } + xhr.upload.dispatchEvent( + new ProgressEvent('progress', { lengthComputable: true, loaded: 2, total: 10 }) + ) + queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + } + + const promise = uploadFileSession({ + file: sizedFile(10), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: async () => 'done', + abort: async () => undefined, + onProgress, + }) + await vi.runAllTimersAsync() + + await expect(promise).resolves.toBe('done') + expect(MockXhr.instances).toHaveLength(2) + expect(onProgress.mock.calls.map(([event]) => event.loaded)).toEqual([8, 8, 10]) + }) + + it('aborts XHR and the control session when the caller cancels', async () => { + const controller = new AbortController() + MockXhr.onSend = () => undefined + const abort = vi.fn(async () => undefined) + const promise = uploadFileSession({ + file: sizedFile(10), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: vi.fn(), + abort, + signal: controller.signal, + }) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(MockXhr.instances[0].abort).toHaveBeenCalledTimes(1) + expect(abort).toHaveBeenCalledTimes(1) + }) + + it('fails before uploading when a multipart URL batch is incomplete', async () => { + const abort = vi.fn(async () => undefined) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect( + uploadFileSession({ + file: sizedFile(2), + transfer: { method: 'multipart', partSize: 1, partCount: 2 }, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/parts/1', + headers: {}, + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + complete: vi.fn(), + abort, + }) + ).rejects.toThrow('Expected 2 part URLs; received 1') + + expect(fetchMock).not.toHaveBeenCalled() + expect(abort).toHaveBeenCalledTimes(1) + }) + + it('does not retry a multipart 4xx response', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'multipart', partSize: 1, partCount: 1 }, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/parts/1', + headers: {}, + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + complete: vi.fn(), + abort: async () => undefined, + }) + ).rejects.toMatchObject({ status: 403 }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('retains a completed multipart transfer when completion fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200 })) + ) + const abort = vi.fn(async () => undefined) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'multipart', partSize: 1, partCount: 1 }, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/parts/1', + headers: {}, + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + complete: async () => { + throw new Error('finalizer unavailable') + }, + abort, + }) + ).rejects.toThrow('finalizer unavailable') + + expect(abort).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/client/upload-session.ts b/apps/sim/lib/uploads/client/upload-session.ts new file mode 100644 index 00000000000..7ad7695b3ee --- /dev/null +++ b/apps/sim/lib/uploads/client/upload-session.ts @@ -0,0 +1,374 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import type { + V2CompletedPart, + V2CompleteUploadBody, + V2MultipartUploadTransfer, + V2PutUploadTransfer, + V2UploadPartUrl, +} from '@/lib/api/contracts/v2/uploads' +import { runWithConcurrency } from '@/lib/uploads/client/concurrency' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' +import { isAbortError } from '@/lib/uploads/utils/file-utils' + +const BASE_TIMEOUT_MS = 2 * 60 * 1000 +const TIMEOUT_PER_MB_MS = 1500 +const MAX_TIMEOUT_MS = 10 * 60 * 1000 +const PART_URL_BATCH_SIZE = 25 +const PART_UPLOAD_CONCURRENCY = 3 +const MAX_RETRIES = 3 +const RETRY_BASE_MS = 500 +const RETRY_MAX_MS = 8000 + +const logger = createLogger('UploadSessionClient') + +export function calculateUploadTimeoutMs(fileSize: number): number { + const sizeInMb = fileSize / (1024 * 1024) + return Math.min(BASE_TIMEOUT_MS + sizeInMb * TIMEOUT_PER_MB_MS, MAX_TIMEOUT_MS) +} + +export class UploadSessionTransportError extends Error { + constructor( + message: string, + readonly status?: number, + readonly retryAfterMs: number | null = null, + readonly transient = false + ) { + super(message) + this.name = 'UploadSessionTransportError' + } +} + +interface UploadFileSessionCommon { + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void + complete: (body: V2CompleteUploadBody) => Promise + abort: () => Promise +} + +interface UploadPutFileSession extends UploadFileSessionCommon { + transfer: V2PutUploadTransfer + getPartUrls?: never +} + +interface UploadMultipartFileSession extends UploadFileSessionCommon { + transfer: V2MultipartUploadTransfer + getPartUrls: (partNumbers: number[]) => Promise +} + +export type UploadFileSessionParams = UploadPutFileSession | UploadMultipartFileSession + +function isPutFileSession( + params: UploadFileSessionParams +): params is UploadPutFileSession { + return params.transfer.method === 'put' +} + +export async function uploadFileSession(params: UploadFileSessionParams): Promise { + let completion: V2CompleteUploadBody + try { + if (isPutFileSession(params)) { + await uploadPut(params) + completion = {} + } else { + completion = { parts: await uploadMultipart(params) } + } + } catch (error) { + await params.abort().catch((abortError) => { + logger.warn('Failed to abort upload session after an upload error', { + error: getErrorMessage(abortError), + }) + }) + throw error + } + + return params.complete(completion) +} + +async function uploadPut(params: UploadPutFileSession): Promise { + let reportedLoaded = 0 + const reportProgress = (loaded: number) => { + reportedLoaded = Math.max(reportedLoaded, loaded) + params.onProgress?.({ + loaded: reportedLoaded, + total: params.file.size, + percent: + params.file.size === 0 + ? 100 + : Math.min(100, Math.round((reportedLoaded / params.file.size) * 100)), + }) + } + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + await uploadPutAttempt({ + file: params.file, + transfer: params.transfer, + signal: params.signal, + onProgress: reportProgress, + }) + reportProgress(params.file.size) + return + } catch (error) { + if (isAbortError(error) || !isRetryableUploadError(error) || attempt >= MAX_RETRIES) { + throw error + } + await waitForRetry(attempt + 1, error.retryAfterMs, params.file.name, params.signal) + } + } + throw new Error(`PUT upload retries exhausted for ${params.file.name}`) +} + +interface UploadPutAttemptParams { + file: File + transfer: V2PutUploadTransfer + signal?: AbortSignal + onProgress: (loaded: number) => void +} + +function uploadPutAttempt(params: UploadPutAttemptParams): Promise { + if (params.signal?.aborted) return Promise.reject(uploadAbortError(params.file.name)) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + let settled = false + + const cleanup = () => { + params.signal?.removeEventListener('abort', handleSignalAbort) + xhr.upload.removeEventListener('progress', handleProgress) + xhr.removeEventListener('load', handleLoad) + xhr.removeEventListener('error', handleError) + xhr.removeEventListener('timeout', handleTimeout) + } + + const resolveOnce = () => { + if (settled) return + settled = true + cleanup() + resolve() + } + + const rejectOnce = (error: Error) => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + const handleSignalAbort = () => { + xhr.abort() + rejectOnce(uploadAbortError(params.file.name)) + } + + const handleProgress = (event: ProgressEvent) => { + if (event.lengthComputable && !settled) params.onProgress(event.loaded) + } + + const handleLoad = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolveOnce() + return + } + rejectOnce( + new UploadSessionTransportError( + `PUT upload failed for ${params.file.name}: ${xhr.status} ${xhr.statusText}`, + xhr.status, + parseRetryAfter(xhr.getResponseHeader('Retry-After'), RETRY_MAX_MS), + isRetryableStatus(xhr.status) + ) + ) + } + + const handleError = () => { + rejectOnce( + new UploadSessionTransportError( + `Network error uploading ${params.file.name}`, + undefined, + null, + true + ) + ) + } + + const handleTimeout = () => { + rejectOnce( + new UploadSessionTransportError( + `Upload timed out for ${params.file.name}`, + undefined, + null, + true + ) + ) + } + + xhr.open('PUT', params.transfer.url) + xhr.timeout = calculateUploadTimeoutMs(params.file.size) + for (const [key, value] of Object.entries(params.transfer.headers)) { + xhr.setRequestHeader(key, value) + } + xhr.upload.addEventListener('progress', handleProgress) + xhr.addEventListener('load', handleLoad) + xhr.addEventListener('error', handleError) + xhr.addEventListener('timeout', handleTimeout) + params.signal?.addEventListener('abort', handleSignalAbort, { once: true }) + xhr.send(params.file) + }) +} + +async function uploadMultipart( + params: UploadMultipartFileSession +): Promise { + const { file, transfer, signal, onProgress } = params + const expectedPartCount = Math.ceil(file.size / transfer.partSize) + if (expectedPartCount !== transfer.partCount) { + throw new Error( + `Upload session expected ${transfer.partCount} parts, but file requires ${expectedPartCount}` + ) + } + + const completedBytes = new Array(transfer.partCount).fill(0) + const completedParts: V2CompletedPart[] = [] + for (let start = 1; start <= transfer.partCount; start += PART_URL_BATCH_SIZE) { + const partNumbers = Array.from( + { length: Math.min(PART_URL_BATCH_SIZE, transfer.partCount - start + 1) }, + (_, index) => start + index + ) + const partUrls = validatePartUrlBatch(partNumbers, await params.getPartUrls(partNumbers)) + const results = await runWithConcurrency( + partUrls, + PART_UPLOAD_CONCURRENCY, + async (part): Promise => { + const partStart = (part.partNumber - 1) * transfer.partSize + const end = Math.min(partStart + transfer.partSize, file.size) + const chunk = file.slice(partStart, end) + const etag = await uploadMultipartPart({ part, chunk, fileName: file.name, signal }) + completedBytes[part.partNumber - 1] = end - partStart + const loaded = completedBytes.reduce((sum, bytes) => sum + bytes, 0) + onProgress?.({ + loaded, + total: file.size, + percent: Math.min(100, Math.round((loaded / file.size) * 100)), + }) + return { partNumber: part.partNumber, ...(etag ? { etag } : {}) } + } + ) + completedParts.push( + ...results.map((result) => { + if (result.status === 'rejected') throw result.reason + return result.value + }) + ) + } + return completedParts +} + +function validatePartUrlBatch(requested: number[], received: V2UploadPartUrl[]): V2UploadPartUrl[] { + if (received.length !== requested.length) { + throw new Error(`Expected ${requested.length} part URLs; received ${received.length}`) + } + const byPartNumber = new Map() + for (const part of received) { + if (byPartNumber.has(part.partNumber)) { + throw new Error(`Received duplicate URL for part ${part.partNumber}`) + } + byPartNumber.set(part.partNumber, part) + } + return requested.map((partNumber) => { + const part = byPartNumber.get(partNumber) + if (!part) throw new Error(`Missing upload URL for part ${partNumber}`) + return part + }) +} + +async function uploadMultipartPart(params: { + part: V2UploadPartUrl + chunk: Blob + fileName: string + signal?: AbortSignal +}): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + // boundary-raw-fetch: signed multipart data-plane URL may target cloud storage or local Sim + const response = await fetch(params.part.url, { + method: 'PUT', + body: params.chunk, + headers: params.part.headers, + signal: params.signal, + }) + if (!response.ok) { + throw new UploadSessionTransportError( + `Part ${params.part.partNumber} failed (${response.status})`, + response.status, + parseRetryAfter(response.headers.get('Retry-After'), RETRY_MAX_MS), + isRetryableStatus(response.status) + ) + } + return response.headers.get('etag')?.replaceAll('"', '') + } catch (error) { + if (isAbortError(error)) throw error + const classified = + error instanceof TypeError + ? new UploadSessionTransportError( + `Network error uploading part ${params.part.partNumber}`, + undefined, + null, + true + ) + : error + if (!isRetryableUploadError(classified) || attempt >= MAX_RETRIES) { + throw classified + } + await waitForRetry(attempt + 1, classified.retryAfterMs, params.fileName, params.signal) + } + } + throw new Error(`Part ${params.part.partNumber} upload retries exhausted`) +} + +function isRetryableUploadError(error: unknown): error is UploadSessionTransportError { + return error instanceof UploadSessionTransportError && error.transient +} + +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || (status >= 500 && status < 600) +} + +async function waitForRetry( + attempt: number, + retryAfterMs: number | null, + fileName: string, + signal?: AbortSignal +): Promise { + if (signal?.aborted) throw uploadAbortError(fileName) + const delay = backoffWithJitter(attempt, retryAfterMs, { + baseMs: RETRY_BASE_MS, + maxMs: RETRY_MAX_MS, + }) + if (!signal) { + await sleep(delay) + return + } + await new Promise((resolve, reject) => { + const handleAbort = () => { + signal.removeEventListener('abort', handleAbort) + reject(uploadAbortError(fileName)) + } + signal.addEventListener('abort', handleAbort, { once: true }) + sleep(delay).then( + () => { + signal.removeEventListener('abort', handleAbort) + resolve() + }, + (error) => { + signal.removeEventListener('abort', handleAbort) + reject(error) + } + ) + }) +} + +function uploadAbortError(fileName: string): DOMException { + return new DOMException(`Upload aborted for ${fileName}`, 'AbortError') +} diff --git a/apps/sim/lib/uploads/config.ts b/apps/sim/lib/uploads/config.ts index 10cb9a2eff7..55cf13d7803 100644 --- a/apps/sim/lib/uploads/config.ts +++ b/apps/sim/lib/uploads/config.ts @@ -226,6 +226,7 @@ function getS3Config(context: StorageContext): StorageConfig { } case 'mothership': case 'workspace': + case 'table-import': return { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region, @@ -288,6 +289,7 @@ function getBlobConfig(context: StorageContext): StorageConfig { } case 'mothership': case 'workspace': + case 'table-import': return { accountName: BLOB_CONFIG.accountName, accountKey: BLOB_CONFIG.accountKey, @@ -347,6 +349,7 @@ function getGcsConfig(context: StorageContext): StorageConfig { return { bucket: GCS_EXECUTION_FILES_CONFIG.bucket || GCS_CONFIG.bucket } case 'mothership': case 'workspace': + case 'table-import': return { bucket: GCS_CONFIG.bucket } case 'profile-pictures': return { bucket: GCS_PROFILE_PICTURES_CONFIG.bucket || GCS_CONFIG.bucket } diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 2e9b3fdf93d..f0406e73601 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -4,11 +4,8 @@ import { deleteFile, downloadFile, generatePresignedDownloadUrl, - generatePresignedUploadUrl, uploadFile, } from '@/lib/uploads/core/storage-service' -import type { PresignedUrlResponse } from '@/lib/uploads/shared/types' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('CopilotFileManager') @@ -49,14 +46,6 @@ interface CopilotFileAttachment { media_type: string } -export interface GenerateCopilotUploadUrlOptions { - fileName: string - contentType: string - fileSize: number - userId: string - expirationSeconds?: number -} - export interface CopilotStoredFile { id: string key: string @@ -68,48 +57,6 @@ export interface CopilotStoredFile { mimeType: string } -/** - * Generate a presigned URL for copilot file upload - * - * Images and document files are allowed for copilot uploads. - * Requires authenticated user session. - * - * @param options Upload URL generation options - * @returns Presigned URL response with upload URL and file key - * @throws Error if file type is unsupported or user is not authenticated - */ -export async function generateCopilotUploadUrl( - options: GenerateCopilotUploadUrlOptions -): Promise { - const { fileName, contentType, fileSize, userId, expirationSeconds = 3600 } = options - - if (!userId?.trim()) { - throw new Error('Authenticated user session is required for copilot uploads') - } - - if (!isSupportedFileType(contentType) && !isImageFileType(contentType)) { - throw new Error( - 'Unsupported file type. Allowed: images (JPEG, PNG, GIF, WebP), PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).' - ) - } - - const presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'copilot', - userId, - expirationSeconds, - }) - - logger.info(`Generated copilot upload URL for: ${fileName}`, { - key: presignedUrlResponse.key, - userId, - }) - - return presignedUrlResponse -} - export async function uploadCopilotFile(options: { buffer: Buffer fileName: string diff --git a/apps/sim/lib/uploads/contexts/copilot/index.ts b/apps/sim/lib/uploads/contexts/copilot/index.ts index d4b1c93a3e2..5fdfc829dc4 100644 --- a/apps/sim/lib/uploads/contexts/copilot/index.ts +++ b/apps/sim/lib/uploads/contexts/copilot/index.ts @@ -1,6 +1,5 @@ export type { CopilotStoredFile } from './copilot-file-manager' export { downloadCopilotFile, - generateCopilotUploadUrl, uploadCopilotFile, } from './copilot-file-manager' diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 03f58a0d3a3..717e778af26 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1,10 +1,20 @@ import { db } from '@sim/db' import { folder as folderTable, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { acquireFolderMutationLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' +import { + buildFolderPathIndex, + folderNameFromPath, + parentFolderPath, + parseFolderPath, + requireNonRootFolderPath, +} from '@/lib/folders/paths' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -19,13 +29,6 @@ const logger = createLogger('WorkspaceFileFolders') const FILE_FOLDER_RESOURCE_TYPE = 'file' as const const isFileFolder = eq(folderTable.resourceType, FILE_FOLDER_RESOURCE_TYPE) -/** - * Bounds the workspace-file-folder advisory-lock wait so a stuck holder fails - * fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a - * server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`. - */ -const WORKSPACE_FILE_FOLDER_LOCK_TIMEOUT_MS = 5_000 - export type WorkspaceFileFolderScope = 'active' | 'archived' | 'all' export class WorkspaceFileFolderConflictError extends Error { @@ -81,10 +84,6 @@ interface RawWorkspaceFileFolder { updatedAt: Date } -interface WorkspaceFileFolderLockTx { - execute(query: SQL): Promise -} - export interface WorkspaceFileArchiveResult { folders: number files: number @@ -120,16 +119,8 @@ function fileFolderCondition(folderId?: string | null) { return normalized ? eq(workspaceFiles.folderId, normalized) : isNull(workspaceFiles.folderId) } -async function acquireWorkspaceFileFolderMutationLock( - tx: WorkspaceFileFolderLockTx, - workspaceId: string -) { - await tx.execute( - sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_FILE_FOLDER_LOCK_TIMEOUT_MS}ms`}, true)` - ) - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workspace_file_folders:${workspaceId}`}, 0))` - ) +async function acquireWorkspaceFileFolderMutationLock(tx: DbOrTx, workspaceId: string) { + await acquireFolderMutationLock(tx, workspaceId, FILE_FOLDER_RESOURCE_TYPE) } export function buildWorkspaceFileFolderPathMap( @@ -337,19 +328,43 @@ export async function getWorkspaceFileFolder( return mapFolder(folder, paths) } -export async function assertWorkspaceFileFolderTarget( +export async function resolveWorkspaceFileFolderTarget( workspaceId: string, folderId?: string | null -): Promise { +): Promise { const normalized = normalizeParentId(folderId) if (!normalized) return null const folder = await getWorkspaceFileFolder(workspaceId, normalized) if (!folder) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } - return normalized + return folder +} + +export async function assertWorkspaceFileFolderTarget( + workspaceId: string, + folderId?: string | null, + executor: DbOrTx = db +): Promise { + const normalized = normalizeParentId(folderId) + if (!normalized) return null + + const [folder] = await executor + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, normalized), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (!folder) throw new OrchestrationError('not_found', 'Target folder not found') + return folder.id } export async function createWorkspaceFileFolder(params: { @@ -380,7 +395,7 @@ export async function createWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -558,7 +573,7 @@ export async function updateWorkspaceFileFolder(params: { ) .limit(1) - if (!existing) throw new Error('Folder not found') + if (!existing) throw new OrchestrationError('not_found', 'Folder not found') const updates: Partial = { updatedAt: new Date() } const finalName = @@ -568,7 +583,8 @@ export async function updateWorkspaceFileFolder(params: { const finalParentId = params.parentId !== undefined ? normalizeParentId(params.parentId) : existing.parentId - if (finalParentId === params.folderId) throw new Error('Folder cannot be its own parent') + if (finalParentId === params.folderId) + throw new OrchestrationError('validation', 'Folder cannot be its own parent') if (finalParentId) { const [target] = await tx @@ -585,7 +601,7 @@ export async function updateWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -603,7 +619,10 @@ export async function updateWorkspaceFileFolder(params: { const descendants = collectDescendantFolderIds(activeFolders, params.folderId) if (finalParentId && descendants.includes(finalParentId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } @@ -653,7 +672,7 @@ export async function updateWorkspaceFileFolder(params: { ) .returning() - if (!updatedFolder) throw new Error('Folder not found') + if (!updatedFolder) throw new OrchestrationError('not_found', 'Folder not found') return updatedFolder } catch (error) { if (getPostgresErrorCode(error) === '23505') { @@ -694,14 +713,33 @@ export async function moveWorkspaceFileItems(params: { fileIds?: string[] folderIds?: string[] targetFolderId?: string | null + targetFolderPath?: string }): Promise<{ movedFiles: number; movedFolders: number }> { const fileIds = Array.from(new Set(params.fileIds ?? [])) const folderIds = Array.from(new Set(params.folderIds ?? [])) - const targetFolderId = normalizeParentId(params.targetFolderId) + if (params.targetFolderId !== undefined && params.targetFolderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify a target folder id or path, not both') + } return db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + let targetFolderId = normalizeParentId(params.targetFolderId) + if (params.targetFolderPath !== undefined) { + try { + parseFolderPath(params.targetFolderPath) + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const resolved = + params.targetFolderPath === '/' ? null : index.idByPath.get(params.targetFolderPath) + if (resolved === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + targetFolderId = resolved + } + if (targetFolderId) { const [target] = await tx .select({ id: folderTable.id }) @@ -717,12 +755,12 @@ export async function moveWorkspaceFileItems(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } if (folderIds.includes(targetFolderId ?? '')) { - throw new Error('Cannot move a folder into itself') + throw new OrchestrationError('validation', 'Cannot move a folder into itself') } if (folderIds.length > 0) { @@ -740,7 +778,10 @@ export async function moveWorkspaceFileItems(params: { for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) if (targetFolderId && descendants.includes(targetFolderId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } } @@ -891,7 +932,7 @@ export async function archiveWorkspaceFileFolderRecursive( ) .limit(1) - if (!folder) throw new Error('Folder not found') + if (!folder) throw new OrchestrationError('not_found', 'Folder not found') const activeFolders = await tx .select({ id: folderTable.id, parentId: folderTable.parentId }) @@ -944,7 +985,7 @@ export async function restoreWorkspaceFileFolder( ): Promise { const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore folder into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') } const { restored, restoredItems } = await db.transaction(async (tx) => { @@ -959,8 +1000,8 @@ export async function restoreWorkspaceFileFolder( .limit(1) .then((rows) => rows[0] ?? null) - if (!raw) throw new Error('Folder not found') - if (!raw.deletedAt) throw new Error('Folder is not archived') + if (!raw) throw new OrchestrationError('not_found', 'Folder not found') + if (!raw.deletedAt) throw new OrchestrationError('validation', 'Folder is not archived') const folderDeletedAt = raw.deletedAt @@ -1177,3 +1218,206 @@ export async function bulkArchiveWorkspaceFileItems(params: { } }) } + +async function loadActiveFileFolderPathIndex(tx: DbOrTx, workspaceId: string) { + const rows = await tx + .select() + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) + ) + return buildFolderPathIndex(rows) +} + +export interface WorkspaceFileFolderPathMutation { + folder: typeof folderTable.$inferSelect + path: string +} + +/** Creates one file-folder leaf with path resolution inside the file tree's mutation lock. */ +export async function createWorkspaceFileFolderAtPath(params: { + workspaceId: string + userId: string + path: string +}): Promise { + requireNonRootFolderPath(params.path) + const pathName = folderNameFromPath(params.path) + let name: string + try { + name = normalizeWorkspaceFileItemName(pathName, 'Folder') + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + if (name !== pathName) { + throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') + } + + const folder = await db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + if (index.idByPath.has(params.path)) throw new WorkspaceFileFolderConflictError(name) + + const parentPath = parentFolderPath(params.path) + const parentId = parentPath === '/' ? null : index.idByPath.get(parentPath) + if (parentPath !== '/' && !parentId) { + throw new OrchestrationError('not_found', 'Parent folder not found') + } + + const [sortOrderResult] = await tx + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + folderParentCondition(parentId), + isNull(folderTable.deletedAt) + ) + ) + + const [created] = await tx + .insert(folderTable) + .values({ + id: generateId(), + resourceType: FILE_FOLDER_RESOURCE_TYPE, + name, + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder: sortOrderResult?.minSortOrder != null ? sortOrderResult.minSortOrder - 1 : 0, + }) + .returning() + return created + }) + + return { folder, path: params.path } +} + +/** Relocates one file folder while source and destination paths share the same tree lock. */ +export async function relocateWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + destinationPath: string +}): Promise { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + const pathName = folderNameFromPath(params.destinationPath) + let name: string + try { + name = normalizeWorkspaceFileItemName(pathName, 'Folder') + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + if (name !== pathName) { + throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') + } + + const folder = await db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const folderId = index.idByPath.get(params.path) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + if (index.idByPath.has(params.destinationPath)) { + throw new WorkspaceFileFolderConflictError(name) + } + + const destinationParentPath = parentFolderPath(params.destinationPath) + if ( + destinationParentPath === params.path || + destinationParentPath.startsWith(`${params.path}/`) + ) { + throw new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + } + const parentId = + destinationParentPath === '/' ? null : index.idByPath.get(destinationParentPath) + if (destinationParentPath !== '/' && !parentId) { + throw new OrchestrationError('not_found', 'Parent folder not found') + } + + const [updated] = await tx + .update(folderTable) + .set({ name, parentId, updatedAt: new Date() }) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .returning() + if (!updated) throw new OrchestrationError('not_found', 'Folder not found') + return updated + }) + + return { folder, path: params.destinationPath } +} + +/** Deletes a file-folder subtree, or only an empty folder when `recursive` is false. */ +export async function deleteWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + recursive: boolean +}): Promise { + requireNonRootFolderPath(params.path) + const now = new Date() + + return db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const folderId = index.idByPath.get(params.path) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + + const folderIds = [ + folderId, + ...[...index.pathById.entries()] + .filter(([, path]) => path.startsWith(`${params.path}/`)) + .map(([id]) => id), + ] + + if (!params.recursive) { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.folderId, folderId), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + if (folderIds.length > 1 || file) { + throw new OrchestrationError('conflict', 'Folder is not empty') + } + } + + const archivedFiles = await tx + .update(workspaceFiles) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(workspaceFiles.folderId, folderIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .returning({ id: workspaceFiles.id }) + const archivedFolders = await tx + .update(folderTable) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .returning({ id: folderTable.id }) + + return { folders: archivedFolders.length, files: archivedFiles.length } + }) +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index a40d1f64634..3a728de58fd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,8 +9,23 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { decrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, @@ -19,9 +34,13 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' +import { acquireFolderMutationLock } from '@/lib/folders/locks' +import { parseFolderPath } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import { @@ -31,7 +50,7 @@ import { headObject, uploadFile, } from '@/lib/uploads/core/storage-service' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { MAX_WORKSPACE_FILE_SIZE, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { isUuid, sanitizeFileName } from '@/executor/constants' @@ -45,16 +64,22 @@ import { getWorkspaceFileFolderPath, listWorkspaceFileFolders, normalizeWorkspaceFileItemName, + resolveWorkspaceFileFolderTarget, } from './workspace-file-folder-manager' const logger = createLogger('WorkspaceFileStorage') export type WorkspaceFileScope = 'active' | 'archived' | 'all' -export class FileConflictError extends Error { - readonly code = 'FILE_EXISTS' as const +/** + * An {@link OrchestrationError} so every surface reaches 409 by class rather than by + * searching the message for "already exists". Carries the inherited `code: 'conflict'`; + * the old `'FILE_EXISTS'` discriminator had no readers. + */ +export class FileConflictError extends OrchestrationError { constructor(name: string) { - super(`A file named "${name}" already exists in this workspace`) + super('conflict', `A file named "${name}" already exists in this workspace`) + this.name = 'FileConflictError' } } @@ -86,6 +111,14 @@ export interface WorkspaceFileRecord { share?: ShareRecord | null } +export interface UploadedWorkspaceFileRecord extends WorkspaceFileRecord { + url: string + context: 'workspace' + folderId: string | null + folderPath: string | null + deletedAt: Date | null +} + interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] @@ -154,6 +187,10 @@ interface WorkspaceFileMetadataInsert { size: number } +function workspaceFileSize(file: typeof workspaceFiles.$inferSelect): number { + return file.sizeBytes ?? file.size +} + /** * Attempts one active workspace-file insert and reports the row that this call * created. Conflict losers receive `undefined` and must inspect the active key @@ -167,6 +204,8 @@ async function insertWorkspaceFileMetadataInTx( .insert(workspaceFiles) .values({ ...metadata, + size: toLegacyWorkspaceFileSize(metadata.size), + sizeBytes: metadata.size, context: 'workspace', displayName: metadata.originalName, deletedAt: null, @@ -187,18 +226,19 @@ class WorkspaceFileRegistrationConflictError extends Error { } /** - * Reads one active metadata row by its unique storage key. + * Reads metadata by upload-operation key across its full lifecycle, preferring an active row. */ -async function findActiveWorkspaceFileByKey( +async function findWorkspaceFileByRegistrationKey( executor: DbOrTx, key: string ): Promise { - const [file] = await executor + const files = await executor .select() .from(workspaceFiles) - .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) + .where(eq(workspaceFiles.key, key)) + .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`) .limit(1) - return file + return files[0] } /** @@ -224,7 +264,7 @@ async function findWorkspaceFileForLifecycle( } /** - * Confirms that an active-key conflict belongs to the same direct-upload + * Confirms that a key belongs to the same upload-session * operation. The generated storage key is the operation identity; immutable * ownership and object attributes prevent unrelated callers from reusing it. */ @@ -234,7 +274,6 @@ function isSameWorkspaceFileRegistration( workspaceId: string userId: string key: string - folderId: string | null contentType: string size: number } @@ -243,11 +282,9 @@ function isSameWorkspaceFileRegistration( file.key === params.key && file.workspaceId === params.workspaceId && file.userId === params.userId && - file.folderId === params.folderId && file.context === 'workspace' && file.contentType === params.contentType && - file.size === params.size && - file.deletedAt === null + workspaceFileSize(file) === params.size ) } @@ -304,11 +341,30 @@ export async function uploadWorkspaceFile( fileBuffer: Buffer, fileName: string, contentType: string, - options?: { folderId?: string | null; exactName?: boolean } -): Promise { + options?: { folderId?: string | null; folderPath?: string; exactName?: boolean } +): Promise { logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`) - const folderId = await assertWorkspaceFileFolderTarget(workspaceId, options?.folderId) + if (options?.folderId !== undefined && options.folderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify either folderId or folderPath, not both') + } + + let folderId: string | null + let folderPath: string | null + if (options?.folderPath !== undefined) { + const folderPathSegments = parseFolderPath(options.folderPath) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) + if (resolvedFolderId === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + folderId = resolvedFolderId + folderPath = resolvedFolderId ? folderPathSegments.join('/') : null + } else { + const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId) + folderId = folderTarget?.id ?? null + folderPath = folderTarget?.path ?? null + } const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File') const exactName = options?.exactName ?? false const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -323,7 +379,7 @@ export async function uploadWorkspaceFile( throw new FileConflictError(uniqueName) } const storageKey = generateWorkspaceFileKey(workspaceId, uniqueName) - let fileId = `wf_${generateShortId()}` + const fileId = `wf_${generateShortId()}` try { logger.info(`Generated storage key: ${storageKey}`) @@ -334,7 +390,7 @@ export async function uploadWorkspaceFile( purpose: 'workspace', userId: userId, workspaceId: workspaceId, - ...(folderId ? { folderId } : {}), + ...(folderId && options?.folderPath === undefined ? { folderId } : {}), } const uploadResult = await uploadFile({ @@ -350,15 +406,30 @@ export async function uploadWorkspaceFile( logger.info(`Upload returned key: ${uploadResult.key}`) - let updatedUsage: number | undefined + let finalized: { + inserted: typeof workspaceFiles.$inferSelect + updatedUsage: number | undefined + } try { - const finalized = await db.transaction(async (tx) => { + finalized = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, 'file') + let activeFolderId: string | null + if (options?.folderPath !== undefined) { + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file', tx) + const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) + if (resolvedFolderId === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + activeFolderId = resolvedFolderId + } else { + activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) + } const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key: uploadResult.key, userId, workspaceId, - folderId, + folderId: activeFolderId, originalName: uniqueName, contentType, size: fileBuffer.length, @@ -373,36 +444,22 @@ export async function uploadWorkspaceFile( ) return { inserted, updatedUsage: usage } }) - fileId = finalized.inserted.id - updatedUsage = finalized.updatedUsage } catch (finalizationError) { await cleanupWorkspaceStorageObject(uploadResult.key, 'metadata finalization failure') throw finalizationError } - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, updatedUsage) + void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage) logger.info( `Successfully uploaded workspace file: ${uniqueName} with key: ${uploadResult.key}` ) - const pathPrefix = getServePathPrefix() - const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace` - - // Fan out the live-tree signal for the direct-upload paths (multipart - // fallback, copilot create, /api/files/upload, v1 files) — the presigned - // path already notifies from its register route. + // Fan out the live-tree signal for this server-buffered path. Upload-session + // finalization sends its own notification after registering metadata. await notifyWorkspaceFilesChanged(workspaceId) - return { - id: fileId, - name: uniqueName, - size: fileBuffer.length, - type: contentType, - url: serveUrl, - key: uploadResult.key, - context: 'workspace', - } + return mapUploadedWorkspaceFileRecord(finalized.inserted, workspaceId, folderPath) } catch (error) { lastError = error if (error instanceof FileConflictError) { @@ -423,8 +480,15 @@ export async function uploadWorkspaceFile( ) continue } + // A classified failure (a blown storage quota, a missing target folder) keeps its class: + // re-wrapping it in a bare Error is what forced every caller to substring-match the + // message to recover the status. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to upload workspace file ${fileName}:`, error) - throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -436,8 +500,8 @@ export async function uploadWorkspaceFile( } /** - * Finalize a workspace file that was uploaded directly to cloud storage - * (presigned PUT or completed multipart). Verifies the object exists, + * Finalize a workspace file that was uploaded through a transfer session + * (signed PUT or completed multipart). Verifies the object exists, * checks quota, allocates a non-colliding display name, inserts metadata, * and increments storage usage. * @@ -461,10 +525,6 @@ export async function registerUploadedWorkspaceFile(params: { const { workspaceId, userId, key, originalName, contentType } = params const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File') - if (!hasCloudStorage()) { - throw new Error('Direct-upload registration requires cloud storage') - } - if (parseWorkspaceFileKey(key) !== workspaceId) { throw new Error('Storage key does not belong to this workspace') } @@ -474,7 +534,6 @@ export async function registerUploadedWorkspaceFile(params: { throw new Error('Uploaded object not found in storage') } const verifiedSize = head.size - const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId) if (verifiedSize > MAX_WORKSPACE_FILE_SIZE) { await cleanupWorkspaceStorageObject(key, 'size-cap rejection') @@ -485,22 +544,22 @@ export async function registerUploadedWorkspaceFile(params: { workspaceId, userId, key, - folderId, contentType, size: verifiedSize, } - const existing = await findActiveWorkspaceFileByKey(db, key) + const existing = await findWorkspaceFileByRegistrationKey(db, key) if (existing) { if (!isSameWorkspaceFileRegistration(existing, registrationIdentity)) { throw new WorkspaceFileRegistrationConflictError(key) } - logger.info(`Using existing metadata record for direct upload: ${key}`) + assertActiveWorkspaceFileRegistration(existing) + logger.info(`Using existing metadata record for upload session: ${key}`) const pathPrefix = getServePathPrefix() return { file: { id: existing.id, name: existing.originalName, - size: existing.size, + size: workspaceFileSize(existing), type: existing.contentType, url: `${pathPrefix}${encodeURIComponent(existing.key)}?context=workspace`, key: existing.key, @@ -510,6 +569,8 @@ export async function registerUploadedWorkspaceFile(params: { } } + const folderId = params.folderId ?? null + const storageBillingContext = await resolveStorageBillingContext(workspaceId) for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { const fileId = `wf_${generateShortId()}` @@ -520,22 +581,25 @@ export async function registerUploadedWorkspaceFile(params: { ) const finalized = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, 'file') + const activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key, userId, workspaceId, - folderId, + folderId: activeFolderId, originalName: displayName, contentType, size: verifiedSize, }) if (!inserted) { - const raceWinner = await findActiveWorkspaceFileByKey(tx, key) + const raceWinner = await findWorkspaceFileByRegistrationKey(tx, key) if (!raceWinner) return { kind: 'name-conflict' } as const if (!isSameWorkspaceFileRegistration(raceWinner, registrationIdentity)) { throw new WorkspaceFileRegistrationConflictError(key) } + assertActiveWorkspaceFileRegistration(raceWinner) return { kind: 'existing', file: raceWinner } as const } @@ -563,7 +627,7 @@ export async function registerUploadedWorkspaceFile(params: { file: { id: finalized.file.id, name: finalized.file.originalName, - size: finalized.file.size, + size: workspaceFileSize(finalized.file), type: finalized.file.contentType, url: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`, key: finalized.file.key, @@ -576,6 +640,12 @@ export async function registerUploadedWorkspaceFile(params: { throw new FileConflictError(normalizedOriginalName) } +function assertActiveWorkspaceFileRegistration(file: typeof workspaceFiles.$inferSelect): void { + if (file.deletedAt) { + throw new OrchestrationError('conflict', 'Upload result was deleted') + } +} + /** * Like `withCopySuffix` but with `n=1` meaning "no suffix" — used by retry loops where * the first attempt should try the original name (`image.png`, `image (2).png`, ...). @@ -826,7 +896,7 @@ function mapWorkspaceFileRecord( name: file.originalName, key: file.key, path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`, - size: file.size, + size: workspaceFileSize(file), type: file.contentType, uploadedBy: file.userId, folderId: file.folderId, @@ -838,6 +908,26 @@ function mapWorkspaceFileRecord( } } +function mapUploadedWorkspaceFileRecord( + file: typeof workspaceFiles.$inferSelect, + workspaceId: string, + folderPath: string | null +): UploadedWorkspaceFileRecord { + const record = mapWorkspaceFileRecord( + file, + workspaceId, + file.folderId && folderPath ? new Map([[file.folderId, folderPath]]) : new Map() + ) + return { + ...record, + url: record.path, + context: 'workspace', + folderId: record.folderId ?? null, + folderPath: record.folderPath ?? null, + deletedAt: record.deletedAt ?? null, + } +} + async function mapSingleWorkspaceFileRecord( file: typeof workspaceFiles.$inferSelect, workspaceId: string @@ -886,6 +976,33 @@ export async function getWorkspaceFileByName( return mapSingleWorkspaceFileRecord(files[0], workspaceId) } +/** Workspace-file rows for one scope: live, Recently Deleted, or both. */ +function workspaceFileScopeCondition(workspaceId: string, scope: WorkspaceFileScope) { + const base = [ + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + ] + if (scope === 'all') return and(...base) + return scope === 'archived' + ? and(...base, isNotNull(workspaceFiles.deletedAt)) + : and(...base, isNull(workspaceFiles.deletedAt)) +} + +/** Resolves `folderPath` for a page of rows, reading the folder tree only if any row needs it. */ +async function hydrateWorkspaceFilePaths( + files: (typeof workspaceFiles.$inferSelect)[], + workspaceId: string, + options?: { folders?: WorkspaceFileFolderRecord[]; hydrateFolderPaths?: boolean } +): Promise { + const needsFolderPaths = + files.some((file) => file.folderId) && (options?.hydrateFolderPaths ?? true) + const folders = needsFolderPaths + ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) + : [] + const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() + return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) +} + /** * List all files for a workspace */ @@ -894,37 +1011,14 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { scope = 'active', hydrateFolderPaths = true } = options ?? {} + const { scope = 'active' } = options ?? {} const files = await db .select() .from(workspaceFiles) - .where( - scope === 'all' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') - ) - : scope === 'archived' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - sql`${workspaceFiles.deletedAt} IS NOT NULL` - ) - : and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) + .where(workspaceFileScopeCondition(workspaceId, scope)) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = files.some((file) => file.folderId) && hydrateFolderPaths - const folders = needsFolderPaths - ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) - : [] - const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - - return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + return hydrateWorkspaceFilePaths(files, workspaceId, options) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) if (options?.throwOnError) throw error @@ -932,6 +1026,102 @@ export async function listWorkspaceFiles( } } +/** + * The keysets behind {@link queryWorkspaceFiles}' sortable fields. `satisfies` + * makes this total over the contract enum: a new sortable field in the contract + * fails to compile until it has a keyset here, rather than silently falling + * through to an unordered scan. + * + * Every key column is `NOT NULL`, and `id` closes each keyset so a page + * boundary inside a run of equal names/sizes/timestamps is still stable. + */ +const fileId = textKey(workspaceFiles.id, (row) => row.id) + +const WORKSPACE_FILE_SORTS = { + name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId], + size: [ + numberKey( + sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(Number), + (row) => row.size + ), + fileId, + ], + uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId], + updatedAt: [timestampKey(workspaceFiles.updatedAt, (row) => row.updatedAt), fileId], +} satisfies Record[]> + +export interface QueryWorkspaceFilesOptions { + scope?: WorkspaceFileScope + /** Restrict to one file folder. */ + /** `undefined` lists every folder, `null` lists only root files. */ + folderId?: string | null + /** Case-insensitive substring match on the file name. */ + search?: string + sortBy: V2FileSortBy + sortOrder: V2SortOrder + limit: number + /** Keyset values from a cursor, in the sort's key order. */ + after?: CursorKey[] +} + +export interface QueryWorkspaceFilesResult { + files: WorkspaceFileRecord[] + /** Keyset values to resume from, or `null` when this page is the last one. */ + nextKeys: CursorKey[] | null +} + +/** + * One filtered, sorted, bounded page of a workspace's files. + * + * Distinct from {@link listWorkspaceFiles}, which materializes the whole scope + * for callers that genuinely need it. Here the filter, the ordering, and the + * slice are all in the query: a name search must not become "read every row, + * then discard almost all of them in JS". + * + * Throws rather than returning a short page — a swallowed storage error here is + * indistinguishable from "no more results" and would silently end pagination. + * A cursor that does not fit the requested sort is a classified `validation` + * failure, so the route renders it as a 400 rather than a 500. + */ +export async function queryWorkspaceFiles( + workspaceId: string, + options: QueryWorkspaceFilesOptions +): Promise { + const { scope = 'active', folderId, search, sortBy, sortOrder, limit, after } = options + const keys: readonly KeysetKey[] = WORKSPACE_FILE_SORTS[sortBy] + + let resumeAfter: SQL | undefined + if (after) { + const condition = keysetAfter(keys, after, sortOrder) + if (!condition) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + resumeAfter = condition + } + + const conditions = [ + workspaceFileScopeCondition(workspaceId, scope), + folderId === undefined + ? undefined + : folderId === null + ? isNull(workspaceFiles.folderId) + : eq(workspaceFiles.folderId, folderId), + searchFilter(workspaceFiles.originalName, search), + resumeAfter, + ] + + const rows = await db + .select() + .from(workspaceFiles) + .where(and(...conditions)) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const files = await hydrateWorkspaceFilePaths(rows.slice(0, limit), workspaceId) + const last = files.at(-1) + + return { files, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } +} + /** * Normalize a workspace file reference to either a display name or canonical file ID. * Supports raw IDs, `files/{name}`, `files/{name}/content`, and `files/{name}/meta.json`. @@ -1196,7 +1386,7 @@ export async function updateWorkspaceFileContent( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -1246,7 +1436,7 @@ export async function updateWorkspaceFileContent( .for('update') .limit(1) if (!currentFile) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } // Optimistic-concurrency guard: the row is `FOR UPDATE`-locked, so comparing its committed @@ -1263,7 +1453,7 @@ export async function updateWorkspaceFileContent( throw new ContentVersionConflictError(fileId) } - const sizeDiff = content.length - currentFile.size + const sizeDiff = content.length - workspaceFileSize(currentFile) const now = new Date() // `contentUpdatedAt` is the persist If-Match token, so it MUST be strictly monotonic per file — a // bare `new Date()` is not: cross-instance clock skew can stamp a later write with an earlier time, @@ -1278,7 +1468,8 @@ export async function updateWorkspaceFileContent( .update(workspaceFiles) .set({ key: uploadResult.key, - size: content.length, + size: toLegacyWorkspaceFileSize(content.length), + sizeBytes: content.length, contentType: nextContentType, updatedAt: now, contentUpdatedAt, @@ -1293,7 +1484,7 @@ export async function updateWorkspaceFileContent( ) .returning() if (!updatedFile) { - throw new Error('File not found or could not be updated') + throw new OrchestrationError('not_found', 'File not found or could not be updated') } let updatedUsage: number | undefined @@ -1364,7 +1555,7 @@ export async function updateWorkspaceFileContent( name: finalized.file.originalName, key: finalized.file.key, path: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`, - size: finalized.file.size, + size: workspaceFileSize(finalized.file), type: finalized.file.contentType, uploadedBy: finalized.file.userId, folderId: finalized.file.folderId, @@ -1379,8 +1570,15 @@ export async function updateWorkspaceFileContent( // the optimistic-concurrency guard, not a failure to wrap. The orphan upload was already cleaned up // by the inner finalization catch before it propagated here. if (error instanceof ContentVersionConflictError) throw error + // Same reasoning for an already-classified failure: a missing file and a blown storage quota are + // caller-fixable outcomes that every surface maps to 404/413 by class. Re-wrapping them in a bare + // Error stripped that classification and turned both into a 500. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to update workspace file content ${fileId}:`, error) - throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -1399,7 +1597,7 @@ export async function renameWorkspaceFile( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.name === normalizedName) { @@ -1412,10 +1610,11 @@ export async function renameWorkspaceFile( } let updated: { id: string }[] + const renamedAt = new Date() try { updated = await db .update(workspaceFiles) - .set({ originalName: normalizedName, updatedAt: new Date() }) + .set({ originalName: normalizedName, updatedAt: renamedAt }) .where( and( eq(workspaceFiles.id, fileId), @@ -1432,7 +1631,7 @@ export async function renameWorkspaceFile( } if (updated.length === 0) { - throw new Error('File not found or could not be renamed') + throw new OrchestrationError('not_found', 'File not found or could not be renamed') } logger.info(`Successfully renamed workspace file ${fileId} to "${normalizedName}"`) @@ -1440,6 +1639,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + updatedAt: renamedAt, } } @@ -1460,7 +1660,7 @@ export async function moveRenameWorkspaceFile(params: { const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const targetFolderId = await assertWorkspaceFileFolderTarget( @@ -1500,7 +1700,7 @@ export async function moveRenameWorkspaceFile(params: { } if (updated.length === 0) { - throw new Error('File not found or could not be moved') + throw new OrchestrationError('not_found', 'File not found or could not be moved') } return { @@ -1523,7 +1723,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): try { const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.deletedAt) return @@ -1556,7 +1756,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (!fileRecord.deletedAt) { @@ -1565,7 +1765,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore file into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore file into an archived workspace') } /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts new file mode 100644 index 00000000000..f4c81ef8bc3 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + * + * `queryWorkspaceFiles` — the paged, filtered, sorted read behind + * `GET /api/v2/files`. The assertions are on the query it builds, because the + * point of this function existing is that the scope filter, the name search, + * the ordering, and the page slice all happen in SQL rather than over a + * full-workspace result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/storage', () => ({ + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/uploads', () => ({ + getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFile: vi.fn(), + downloadFile: vi.fn(), + hasCloudStorage: vi.fn(() => false), + headObject: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(async () => null), + buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), + fileNameExistsInWorkspaceFolder: vi.fn(async () => false), + findWorkspaceFileFolderIdByPath: vi.fn(), + getWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(async () => []), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: vi.fn(async () => null), +})) + +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const WS = 'workspace-1' + +const DEFAULTS = { sortBy: 'uploadedAt', sortOrder: 'asc', limit: 100 } as const + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + key: 'workspace/ws/1-x-data.csv', + userId: 'user-1', + workspaceId: WS, + folderId: null, + context: 'workspace', + originalName: 'data.csv', + contentType: 'text/csv', + size: 1024, + deletedAt: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + contentUpdatedAt: new Date('2024-01-01T00:00:00Z'), + ...overrides, + } +} + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +describe('queryWorkspaceFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('narrows the query with a case-insensitive substring match on the file name', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: 'data' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + column: schemaMock.workspaceFiles.originalName, + pattern: '%data%', + }) + }) + + it('escapes LIKE wildcards in the search term', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: '50%_off' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%50\\%\\_off%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, DEFAULTS) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('filters to one folder in the query', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, folderId: 'fold_1' }) + + expect( + lastConditions().some( + (c) => + c.type === 'eq' && c.left === schemaMock.workspaceFiles.folderId && c.right === 'fold_1' + ) + ).toBe(true) + }) + + it('orders by the requested field, with id closing the keyset', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', sortOrder: 'desc' }) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workspaceFiles.originalName }, + { type: 'desc', column: schemaMock.workspaceFiles.id }, + ]) + }) + + it('bounds the page in SQL by fetching one row past the limit', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 25 }) + + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(26) + }) + + it('reports no further keys when the page is not full, terminating pagination', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 10 }) + + expect(files).toHaveLength(1) + expect(nextKeys).toBeNull() + }) + + it('returns the keyset of the last row when more results exist', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow(), + buildRow({ id: 'wf_2', originalName: 'b.csv' }), + ]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + limit: 1, + }) + + expect(files.map((f) => f.id)).toEqual(['wf_1']) + expect(nextKeys).toEqual(['data.csv', 'wf_1']) + }) + + it('resumes strictly after the cursor keys, alongside the filter', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow({ id: 'wf_2', originalName: 'data-2.csv' }), + ]) + + await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + search: 'data', + after: ['data.csv', 'wf_1'], + }) + + const conditions = lastConditions() + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%data%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + /** + * Cursor contents are caller-controlled, so a value the key cannot hold is a + * client error. Classified `validation` so the route renders 400, not 500. + */ + it('rejects a cursor whose values do not fit the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'uploadedAt', after: ['not-a-date', 'wf_1'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('rejects a cursor with the wrong number of keys for the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', after: ['data.csv'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index cf0b5cf7189..311095ba896 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -10,11 +10,16 @@ const { mockGetWorkspaceWithOwner, mockHasCloudStorage, mockHeadObject, + mockAcquireFolderMutationLock, + mockAssertWorkspaceFileFolderTarget, mockIncrementStorageUsageForBillingContextInTx, + mockLoadActiveFolderPathIndex, mockMaybeNotifyStorageLimitForBillingContext, mockMergeEditIntoLiveFileDoc, mockNotifyWorkspaceFilesChanged, mockResolveStorageBillingContext, + mockResolveFolderPathFromIndex, + mockResolveWorkspaceFileFolderTarget, mockUploadFile, } = vi.hoisted(() => ({ mockDecrementStorageUsageForBillingContextInTx: vi.fn(), @@ -22,11 +27,16 @@ const { mockGetWorkspaceWithOwner: vi.fn(), mockHasCloudStorage: vi.fn(), mockHeadObject: vi.fn(), + mockAcquireFolderMutationLock: vi.fn(), + mockAssertWorkspaceFileFolderTarget: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), mockMergeEditIntoLiveFileDoc: vi.fn(), mockNotifyWorkspaceFilesChanged: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockResolveFolderPathFromIndex: vi.fn(), + mockResolveWorkspaceFileFolderTarget: vi.fn(), mockUploadFile: vi.fn(), })) @@ -55,13 +65,23 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - assertWorkspaceFileFolderTarget: vi.fn(async () => null), + assertWorkspaceFileFolderTarget: mockAssertWorkspaceFileFolderTarget, buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), fileNameExistsInWorkspaceFolder: vi.fn(async () => false), findWorkspaceFileFolderIdByPath: vi.fn(), getWorkspaceFileFolderPath: vi.fn(), listWorkspaceFileFolders: vi.fn(async () => []), normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: mockResolveWorkspaceFileFolderTarget, +})) + +vi.mock('@/lib/folders/locks', () => ({ + acquireFolderMutationLock: mockAcquireFolderMutationLock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, + resolveFolderPathFromIndex: mockResolveFolderPathFromIndex, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -108,6 +128,8 @@ describe('workspace file metadata and storage accounting', () => { vi.clearAllMocks() resetDbChainMock() mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) + mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) + mockAssertWorkspaceFileFolderTarget.mockResolvedValue(null) mockHasCloudStorage.mockReturnValue(false) mockHeadObject.mockResolvedValue({ size: FILE_ROW.size }) mockUploadFile.mockResolvedValue({ key: FILE_ROW.key }) @@ -120,6 +142,86 @@ describe('workspace file metadata and storage accounting', () => { mockNotifyWorkspaceFilesChanged.mockResolvedValue(undefined) }) + it('returns the canonical inserted record with the pre-resolved folder path', async () => { + const folderId = 'folder-1' + const folderPath = 'Docs/Notes' + const inserted = { ...FILE_ROW, folderId } + mockResolveWorkspaceFileFolderTarget.mockResolvedValueOnce({ id: folderId, path: folderPath }) + dbChainMockFns.returning.mockResolvedValueOnce([inserted]) + + const uploaded = await uploadWorkspaceFile( + FILE_ROW.workspaceId, + FILE_ROW.userId, + Buffer.from('hello'), + FILE_ROW.originalName, + FILE_ROW.contentType, + { folderId } + ) + + const serveUrl = `/api/files/serve/s3/${encodeURIComponent(FILE_ROW.key)}?context=workspace` + expect(uploaded).toEqual( + expect.objectContaining({ + id: FILE_ROW.id, + workspaceId: FILE_ROW.workspaceId, + name: FILE_ROW.originalName, + key: FILE_ROW.key, + path: serveUrl, + url: serveUrl, + size: FILE_ROW.size, + type: FILE_ROW.contentType, + uploadedBy: FILE_ROW.userId, + folderId, + folderPath, + deletedAt: null, + uploadedAt: FILE_ROW.uploadedAt, + updatedAt: FILE_ROW.updatedAt, + contentUpdatedAt: FILE_ROW.contentUpdatedAt, + context: 'workspace', + }) + ) + expect(mockResolveWorkspaceFileFolderTarget).toHaveBeenCalledOnce() + }) + + it('re-resolves a canonical folder path under the tree lock before inserting metadata', async () => { + const initialIndex = { version: 'initial' } + const lockedIndex = { version: 'locked' } + const inserted = { ...FILE_ROW, folderId: 'folder-final' } + mockLoadActiveFolderPathIndex + .mockResolvedValueOnce(initialIndex) + .mockResolvedValueOnce(lockedIndex) + mockResolveFolderPathFromIndex + .mockReturnValueOnce('folder-initial') + .mockReturnValueOnce('folder-final') + dbChainMockFns.returning.mockResolvedValueOnce([inserted]) + + await uploadWorkspaceFile( + FILE_ROW.workspaceId, + FILE_ROW.userId, + Buffer.from('hello'), + FILE_ROW.originalName, + FILE_ROW.contentType, + { folderPath: '/Reports', exactName: true } + ) + + expect(mockAcquireFolderMutationLock).toHaveBeenCalledWith( + expect.any(Object), + FILE_ROW.workspaceId, + 'file' + ) + expect(mockLoadActiveFolderPathIndex).toHaveBeenNthCalledWith( + 2, + FILE_ROW.workspaceId, + 'file', + expect.any(Object) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-final' }) + ) + expect(mockAcquireFolderMutationLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.values.mock.invocationCallOrder[0] + ) + }) + it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( @@ -196,7 +298,7 @@ describe('workspace file metadata and storage accounting', () => { expect(mockDeleteFile).not.toHaveBeenCalled() }) - it('does not delete a direct-upload object when atomic finalization rolls back', async () => { + it('does not delete an upload-session object when atomic finalization rolls back', async () => { mockHasCloudStorage.mockReturnValue(true) dbChainMockFns.limit.mockResolvedValueOnce([]) dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) @@ -220,6 +322,28 @@ describe('workspace file metadata and storage accounting', () => { ) }) + it('rejects archived upload metadata without charging storage again', async () => { + const archivedFile = { + ...FILE_ROW, + deletedAt: new Date('2026-07-02T00:00:00.000Z'), + } + mockHasCloudStorage.mockReturnValue(true) + dbChainMockFns.limit.mockResolvedValueOnce([archivedFile]) + + await expect( + registerUploadedWorkspaceFile({ + workspaceId: FILE_ROW.workspaceId, + userId: FILE_ROW.userId, + key: FILE_ROW.key, + originalName: FILE_ROW.originalName, + contentType: FILE_ROW.contentType, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() + }) + it('archives metadata without changing stored-byte counters', async () => { dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]) dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts index eaf463774bc..4f3b0af4102 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts @@ -8,11 +8,13 @@ const { mockIncrementStorageUsageForBillingContextInTx, mockMaybeNotifyStorageLimitForBillingContext, mockResolveStorageBillingContext, + mockResolveWorkspaceFileFolderTarget, mockUploadFile, } = vi.hoisted(() => ({ mockIncrementStorageUsageForBillingContextInTx: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockResolveWorkspaceFileFolderTarget: vi.fn(), mockUploadFile: vi.fn(), })) @@ -43,6 +45,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => getWorkspaceFileFolderPath: vi.fn(), listWorkspaceFileFolders: vi.fn(async () => []), normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: mockResolveWorkspaceFileFolderTarget, })) import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -60,6 +63,7 @@ describe('workspace file storage attribution', () => { vi.clearAllMocks() resetDbChainMock() mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) + mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(5) mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) mockUploadFile.mockResolvedValue({ diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 0a49d320e02..70bbb925ceb 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -30,11 +30,14 @@ import { const logger = createLogger('StorageService') +/** Sidecar attached to local objects promoted through the upload-session transport. */ +export const LOCAL_UPLOAD_METADATA_SUFFIX = '.upload-metadata.json' + /** * Create a Blob config from StorageConfig * @throws Error if required properties are missing */ -function createBlobConfig(config: StorageConfig): BlobConfig { +export function createBlobConfig(config: StorageConfig): BlobConfig { if (!config.containerName) { throw new Error('Blob configuration missing required property: containerName') } @@ -57,7 +60,7 @@ function createBlobConfig(config: StorageConfig): BlobConfig { * Create an S3 config from StorageConfig * @throws Error if required properties are missing */ -function createS3Config(config: StorageConfig): S3Config { +export function createS3Config(config: StorageConfig): S3Config { if (!config.bucket || !config.region) { throw new Error('S3 configuration missing required properties: bucket and region') } @@ -72,7 +75,7 @@ function createS3Config(config: StorageConfig): S3Config { * Create a GCS config from StorageConfig * @throws Error if required properties are missing */ -function createGcsConfig(config: StorageConfig): GcsConfig { +export function createGcsConfig(config: StorageConfig): GcsConfig { if (!config.bucket) { throw new Error('GCS configuration missing required property: bucket') } @@ -556,7 +559,7 @@ export async function deleteFile(options: DeleteFileOptions): Promise { } } - const { unlink } = await import('fs/promises') + const { rm, unlink } = await import('fs/promises') const { join } = await import('path') const { UPLOAD_DIR_SERVER } = await import('./setup.server') @@ -564,6 +567,7 @@ export async function deleteFile(options: DeleteFileOptions): Promise { const filePath = join(UPLOAD_DIR_SERVER, safeKey) await unlink(filePath) + await rm(`${filePath}${LOCAL_UPLOAD_METADATA_SUFFIX}`, { force: true }) } /** AWS SDK v3 silently caps HTTP connections at 50/endpoint — stay well under. */ @@ -634,7 +638,17 @@ export async function headObject( return headGcsObject(key, createGcsConfig(config)) } - return null + const { stat } = await import('fs/promises') + const { join } = await import('path') + const { UPLOAD_DIR_SERVER } = await import('./setup.server') + try { + const file = await stat(join(UPLOAD_DIR_SERVER, sanitizeFileKey(key))) + return { size: file.size } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return null + throw error + } } /** @@ -836,36 +850,6 @@ async function generateBlobPresignedUrl( } } -/** - * Generate multiple presigned URLs at once (batch operation) - */ -export async function generateBatchPresignedUploadUrls( - files: Array<{ - fileName: string - contentType: string - fileSize: number - }>, - context: StorageContext, - userId?: string, - expirationSeconds?: number -): Promise { - const results: PresignedUrlResponse[] = [] - - for (const file of files) { - const result = await generatePresignedUploadUrl({ - fileName: file.fileName, - contentType: file.contentType, - fileSize: file.fileSize, - context, - userId, - expirationSeconds, - }) - results.push(result) - } - - return results -} - /** * Generate a presigned URL for downloading/accessing an existing file */ diff --git a/apps/sim/lib/uploads/core/upload-token.test.ts b/apps/sim/lib/uploads/core/upload-token.test.ts new file mode 100644 index 00000000000..85beca8acbc --- /dev/null +++ b/apps/sim/lib/uploads/core/upload-token.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' + +const TIMESTAMPS = { + createdAt: '2099-08-03T20:00:00.000Z', + expiresAt: '2099-08-04T20:00:00.000Z', +} as const + +describe('upload token', () => { + it('round-trips strict multipart session state', () => { + const payload = { + uploadId: 'upload-1', + actorId: 'user-1', + workspaceId: 'workspace-1', + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + context: 'knowledge-base', + finalKey: 'kb/final-file.csv', + stagingKey: 'upload-sessions/upload-1/file.csv', + provider: 's3', + providerUploadId: 'provider-upload-1', + method: 'multipart', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + partSize: 8, + partCount: 2, + metadata: { tag1: 'product' }, + ...TIMESTAMPS, + } as const + const token = signUploadToken(payload) + + expect(verifyUploadToken(token)).toEqual({ valid: true, payload }) + }) + + it('round-trips a user-scoped PUT without a synthetic workspace', () => { + const payload = { + uploadId: 'upload-2', + actorId: 'user-1', + workspaceId: null, + purpose: 'profile_picture', + context: 'profile-pictures', + finalKey: 'profile-pictures/upload-2-avatar.png', + stagingKey: 'upload-sessions/upload-2/avatar.png', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 12, + metadata: {}, + ...TIMESTAMPS, + } as const + + expect(verifyUploadToken(signUploadToken(payload))).toEqual({ valid: true, payload }) + }) + + it('round-trips an empty workspace-file PUT', () => { + const payload = { + uploadId: 'upload-empty', + actorId: 'user-1', + workspaceId: 'workspace-1', + purpose: 'workspace_file', + context: 'workspace', + finalKey: 'workspace/workspace-1/empty.md', + stagingKey: 'upload-sessions/upload-empty/empty.md', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'empty.md', + contentType: 'text/markdown', + fileSize: 0, + metadata: {}, + ...TIMESTAMPS, + } as const + + expect(verifyUploadToken(signUploadToken(payload))).toEqual({ valid: true, payload }) + }) + + it('rejects an empty PUT for non-workspace-file purposes', () => { + expect(() => + signUploadToken({ + uploadId: 'upload-empty', + actorId: 'user-1', + workspaceId: null, + purpose: 'profile_picture', + context: 'profile-pictures', + finalKey: 'profile-pictures/empty.png', + stagingKey: 'upload-sessions/upload-empty/empty.png', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'empty.png', + contentType: 'image/png', + fileSize: 0, + metadata: {}, + ...TIMESTAMPS, + }) + ).toThrow('Upload token payload has invalid object state') + }) + + it('rejects a modified token', () => { + const token = signUploadToken({ + uploadId: 'upload-1', + actorId: 'user-1', + workspaceId: 'workspace-1', + purpose: 'workspace_file', + context: 'workspace', + finalKey: 'workspace/workspace-1/final.csv', + stagingKey: 'upload-sessions/upload-1/file.csv', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + metadata: {}, + ...TIMESTAMPS, + }) + const [payload, signature] = token.split('.') + + expect(verifyUploadToken(`${payload}x.${signature}`)).toEqual({ valid: false }) + }) +}) diff --git a/apps/sim/lib/uploads/core/upload-token.ts b/apps/sim/lib/uploads/core/upload-token.ts index 21a7b199a9a..86fba13110e 100644 --- a/apps/sim/lib/uploads/core/upload-token.ts +++ b/apps/sim/lib/uploads/core/upload-token.ts @@ -1,27 +1,113 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' import { env } from '@/lib/core/config/env' -import type { StorageContext } from '@/lib/uploads/shared/types' -export interface UploadTokenPayload { +export type UploadSessionPurpose = + | 'workspace_file' + | 'table_import' + | 'knowledge_document' + | 'profile_picture' + | 'workspace_logo' + | 'mothership_attachment' + | 'execution_attachment' + +export type UploadStorageProvider = 's3' | 'blob' | 'gcs' | 'local' +export type UploadTransferMethod = 'put' | 'multipart' + +type UploadPurposeScope = + | { + purpose: 'workspace_file' + workspaceId: string + context: 'workspace' + } + | { + purpose: 'table_import' + workspaceId: string + context: 'table-import' + } + | { + purpose: 'knowledge_document' + workspaceId: string + context: 'knowledge-base' + knowledgeBaseId: string + } + | { + purpose: 'profile_picture' + workspaceId: null + context: 'profile-pictures' + } + | { + purpose: 'workspace_logo' + workspaceId: string + context: 'workspace-logos' + } + | { + purpose: 'mothership_attachment' + workspaceId: string + context: 'mothership' + } + | { + purpose: 'execution_attachment' + workspaceId: string + context: 'execution' + workflowId: string + executionId: string + } + +type UploadTransferState = + | { + method: 'put' + providerUploadId: null + } + | { + method: 'multipart' + providerUploadId: string | null + partSize: number + partCount: number + } + +interface UploadTokenBase { uploadId: string - key: string - userId: string - workspaceId: string - context: StorageContext - /** Original file name, carried so the completion handler can record ownership metadata. */ - fileName?: string - /** File MIME type, carried for ownership metadata at completion. */ - contentType?: string - /** File size in bytes, carried for ownership metadata at completion. */ - fileSize?: number + actorId: string + finalKey: string + stagingKey: string + provider: UploadStorageProvider + fileName: string + contentType: string + fileSize: number + metadata: Record + createdAt: string + expiresAt: string } -interface SignedPayload extends UploadTokenPayload { +export type UploadTokenPayload = UploadTokenBase & UploadPurposeScope & UploadTransferState + +type SignedPayload = UploadTokenPayload & { exp: number - v: 1 + v: 2 } +const BASE_KEYS = [ + 'uploadId', + 'actorId', + 'finalKey', + 'stagingKey', + 'provider', + 'providerUploadId', + 'method', + 'purpose', + 'workspaceId', + 'context', + 'fileName', + 'contentType', + 'fileSize', + 'metadata', + 'createdAt', + 'expiresAt', + 'exp', + 'v', +] as const + const toBase64Url = (input: string): string => Buffer.from(input, 'utf8').toString('base64url') const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url').toString('utf8') @@ -29,14 +115,19 @@ const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url') const sign = (payload: string): string => hmacSha256Base64(payload, env.INTERNAL_API_SECRET) /** - * Sign an upload session token binding (uploadId, key, userId, workspaceId, context). - * Used to prevent IDOR on multipart upload follow-up calls (get-part-urls, complete, abort). + * Signs the complete, immutable state of one upload session. + * + * Version 2 intentionally has no compatibility parser for legacy multipart tokens. A token must + * carry a purpose-specific scope, transfer method, staging and final keys, provider state, exact + * object identity, and one canonical expiry. */ -export function signUploadToken(payload: UploadTokenPayload, expiresInSeconds = 60 * 60): string { +export function signUploadToken(payload: UploadTokenPayload): string { + assertUploadTokenPayload(payload) + const expiresAt = new Date(payload.expiresAt) const signed: SignedPayload = { ...payload, - exp: Math.floor(Date.now() / 1000) + expiresInSeconds, - v: 1, + exp: Math.floor(expiresAt.getTime() / 1000), + v: 2, } const encoded = toBase64Url(JSON.stringify(signed)) return `${encoded}.${sign(encoded)}` @@ -47,50 +138,172 @@ export type UploadTokenVerification = | { valid: false } export function verifyUploadToken(token: string): UploadTokenVerification { - if (typeof token !== 'string') { - return { valid: false } - } + if (typeof token !== 'string') return { valid: false } const parts = token.split('.') if (parts.length !== 2) return { valid: false } const [encoded, signature] = parts - if (!encoded || !signature) return { valid: false } + if (!encoded || !signature || !safeCompare(signature, sign(encoded))) return { valid: false } - const expected = sign(encoded) - if (!safeCompare(signature, expected)) { + let parsed: unknown + try { + parsed = JSON.parse(fromBase64Url(encoded)) + } catch { return { valid: false } } - let parsed: SignedPayload + if (!isRecord(parsed) || parsed.v !== 2 || !isSafePositiveInteger(parsed.exp)) { + return { valid: false } + } + if (parsed.exp <= Math.floor(Date.now() / 1000)) return { valid: false } + try { - parsed = JSON.parse(fromBase64Url(encoded)) as SignedPayload + assertUploadTokenPayload(parsed) } catch { return { valid: false } } + if (Math.floor(new Date(parsed.expiresAt).getTime() / 1000) !== parsed.exp) { + return { valid: false } + } + + const { exp: _exp, v: _version, ...payload } = parsed + return { valid: true, payload } +} + +function assertUploadTokenPayload(value: unknown): asserts value is UploadTokenPayload { + if (!isRecord(value)) throw new Error('Upload token payload must be an object') + + const purposeKeys = + value.purpose === 'knowledge_document' + ? ['knowledgeBaseId'] + : value.purpose === 'execution_attachment' + ? ['workflowId', 'executionId'] + : [] + const methodKeys = value.method === 'multipart' ? ['partSize', 'partCount'] : [] + const allowedKeys = new Set([...BASE_KEYS, ...purposeKeys, ...methodKeys]) + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new Error('Upload token payload contains unexpected state') + } + if ( - parsed.v !== 1 || - typeof parsed.exp !== 'number' || - parsed.exp < Math.floor(Date.now() / 1000) || - typeof parsed.uploadId !== 'string' || - typeof parsed.key !== 'string' || - typeof parsed.userId !== 'string' || - typeof parsed.workspaceId !== 'string' || - typeof parsed.context !== 'string' + !isNonEmptyString(value.uploadId) || + !isNonEmptyString(value.actorId) || + !isNonEmptyString(value.finalKey) || + !isNonEmptyString(value.stagingKey) || + value.finalKey === value.stagingKey || + !value.stagingKey.startsWith(`upload-sessions/${value.uploadId}/`) || + !isNonEmptyString(value.fileName) || + !isNonEmptyString(value.contentType) || + !isValidFileSize(value.purpose, value.fileSize) || + !isPlainRecord(value.metadata) ) { - return { valid: false } + throw new Error('Upload token payload has invalid object state') + } + + if ( + value.provider !== 's3' && + value.provider !== 'blob' && + value.provider !== 'gcs' && + value.provider !== 'local' + ) { + throw new Error('Upload token payload has an invalid provider') + } + + if (value.method === 'put') { + if (value.providerUploadId !== null || 'partSize' in value || 'partCount' in value) { + throw new Error('PUT upload token has multipart state') + } + } else if (value.method === 'multipart') { + if (!isSafePositiveInteger(value.partSize) || !isSafePositiveInteger(value.partCount)) { + throw new Error('Multipart upload token has invalid geometry') + } + if (value.provider === 'local') { + if (value.providerUploadId !== null) { + throw new Error('Local multipart upload token has a provider upload id') + } + } else if (!isNonEmptyString(value.providerUploadId)) { + throw new Error('Cloud multipart upload token is missing its provider upload id') + } + } else { + throw new Error('Upload token payload has an invalid transfer method') + } + + assertPurposeScope(value) + + if (!isNonEmptyString(value.createdAt) || !isNonEmptyString(value.expiresAt)) { + throw new Error('Upload token payload is missing timestamps') + } + const createdAt = new Date(value.createdAt).getTime() + const expiresAt = new Date(value.expiresAt).getTime() + if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt) || expiresAt <= createdAt) { + throw new Error('Upload token payload has invalid timestamps') } +} - return { - valid: true, - payload: { - uploadId: parsed.uploadId, - key: parsed.key, - userId: parsed.userId, - workspaceId: parsed.workspaceId, - context: parsed.context as StorageContext, - ...(typeof parsed.fileName === 'string' ? { fileName: parsed.fileName } : {}), - ...(typeof parsed.contentType === 'string' ? { contentType: parsed.contentType } : {}), - ...(typeof parsed.fileSize === 'number' ? { fileSize: parsed.fileSize } : {}), - }, +function assertPurposeScope(value: Record): void { + switch (value.purpose) { + case 'workspace_file': + assertWorkspacePurpose(value, 'workspace') + break + case 'table_import': + assertWorkspacePurpose(value, 'table-import') + break + case 'knowledge_document': + assertWorkspacePurpose(value, 'knowledge-base') + if (!isNonEmptyString(value.knowledgeBaseId)) { + throw new Error('Knowledge upload token is missing knowledgeBaseId') + } + break + case 'profile_picture': + if (value.workspaceId !== null || value.context !== 'profile-pictures') { + throw new Error('Profile-picture upload token has invalid scope') + } + break + case 'workspace_logo': + assertWorkspacePurpose(value, 'workspace-logos') + break + case 'mothership_attachment': + assertWorkspacePurpose(value, 'mothership') + break + case 'execution_attachment': + assertWorkspacePurpose(value, 'execution') + if (!isNonEmptyString(value.workflowId) || !isNonEmptyString(value.executionId)) { + throw new Error('Execution upload token is missing workflow scope') + } + break + default: + throw new Error('Upload token payload has an invalid purpose') } } + +function assertWorkspacePurpose(value: Record, context: string): void { + if (!isNonEmptyString(value.workspaceId) || value.context !== context) { + throw new Error('Upload token payload has invalid workspace scope') + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isSafePositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +function isValidFileSize(purpose: unknown, value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + (value > 0 || (purpose === 'workspace_file' && value === 0)) + ) +} diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index 19b06c93a91..5b604425474 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -10,6 +10,9 @@ const { mockDownload, mockDelete, mockDeleteIfExists, + mockBeginCopyFromURL, + mockPollUntilDone, + mockGetProperties, mockGetBlockBlobClient, mockGetContainerClient, mockFromConnectionString, @@ -21,6 +24,9 @@ const { mockDownload: vi.fn(), mockDelete: vi.fn(), mockDeleteIfExists: vi.fn(), + mockBeginCopyFromURL: vi.fn(), + mockPollUntilDone: vi.fn(), + mockGetProperties: vi.fn(), mockGetBlockBlobClient: vi.fn(), mockGetContainerClient: vi.fn(), mockFromConnectionString: vi.fn(), @@ -51,10 +57,15 @@ vi.mock('@/lib/uploads/config', () => ({ })) import { + abortMultipartUpload, + deleteBlobObjectVersion, deleteFromBlob, downloadFromBlob, + getBlobPresignedUploadUrl, getPresignedUrl, + headBlobObject, parseConnectionString, + promoteBlobObject, uploadToBlob, } from '@/lib/uploads/providers/blob/client' import { sanitizeFilenameForMetadata } from '@/lib/uploads/utils/file-utils' @@ -70,6 +81,8 @@ describe('Azure Blob Storage Client', () => { download: mockDownload, delete: mockDelete, deleteIfExists: mockDeleteIfExists, + beginCopyFromURL: mockBeginCopyFromURL, + getProperties: mockGetProperties, url: 'https://test.blob.core.windows.net/container/test-file', }) @@ -84,6 +97,8 @@ describe('Azure Blob Storage Client', () => { mockGenerateBlobSASQueryParameters.mockReturnValue({ toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test', }) + mockBeginCopyFromURL.mockResolvedValue({ pollUntilDone: mockPollUntilDone }) + mockPollUntilDone.mockResolvedValue({ copyStatus: 'success' }) }) describe('uploadToBlob', () => { @@ -135,6 +150,94 @@ describe('Azure Blob Storage Client', () => { }) }) + describe('staged upload primitives', () => { + const customConfig = { + containerName: 'testcontainer', + accountName: 'testaccount', + accountKey: 'testkey', + connectionString: + 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net', + } + + it('signs a PUT with the required blob and metadata headers', async () => { + mockBlobSASPermissionsParse.mockReturnValueOnce('w') + + const result = await getBlobPresignedUploadUrl({ + key: 'upload-sessions/upload-1/file.bin', + contentType: 'application/octet-stream', + metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + customConfig, + expiresIn: 600, + }) + + expect(mockBlobSASPermissionsParse).toHaveBeenCalledWith('w') + expect(result).toEqual({ + url: expect.stringContaining('?sv=2021-06-08'), + headers: { + 'Content-Type': 'application/octet-stream', + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-content-type': 'application/octet-stream', + 'x-ms-meta-uploadId': 'upload-1', + 'x-ms-meta-purpose': 'workspace_file', + }, + }) + }) + + it('pins the source ETag and requires an absent promotion destination', async () => { + await promoteBlobObject({ + sourceKey: 'upload-sessions/upload-1/file.bin', + destinationKey: 'workspace/workspace-1/file.bin', + sourceEtag: '"etag-1"', + customConfig, + }) + + expect(mockBeginCopyFromURL).toHaveBeenCalledWith( + 'https://test.blob.core.windows.net/container/test-file', + { + conditions: { ifNoneMatch: '*' }, + sourceConditions: { ifMatch: '"etag-1"' }, + } + ) + expect(mockPollUntilDone).toHaveBeenCalledOnce() + }) + + it('returns only completed copied objects as usable upload identities', async () => { + mockGetProperties.mockResolvedValueOnce({ + contentLength: 3, + contentType: 'application/octet-stream', + metadata: { uploadid: 'upload-1' }, + etag: '"etag-1"', + copyStatus: 'success', + }) + + await expect(headBlobObject('workspace/workspace-1/file.bin', customConfig)).resolves.toEqual( + { + size: 3, + contentType: 'application/octet-stream', + uploadId: 'upload-1', + version: '"etag-1"', + } + ) + + mockGetProperties.mockResolvedValueOnce({ copyStatus: 'pending' }) + await expect(headBlobObject('workspace/workspace-1/file.bin', customConfig)).rejects.toThrow( + 'Blob copy for workspace/workspace-1/file.bin is pending' + ) + }) + + it('deletes staging only when its ETag still matches', async () => { + mockDeleteIfExists.mockResolvedValueOnce({}) + + await deleteBlobObjectVersion({ + key: 'upload-sessions/upload-1/file.bin', + etag: '"etag-1"', + customConfig, + }) + + expect(mockDeleteIfExists).toHaveBeenCalledWith({ conditions: { ifMatch: '"etag-1"' } }) + }) + }) + describe('downloadFromBlob', () => { it('should download a file from Azure Blob Storage', async () => { const testKey = 'test-file-key' @@ -194,6 +297,15 @@ describe('Azure Blob Storage Client', () => { }) }) + describe('abortMultipartUpload', () => { + it('leaves the blob key untouched while Azure garbage-collects uncommitted blocks', async () => { + await abortMultipartUpload('test-file-key') + + expect(mockGetBlockBlobClient).not.toHaveBeenCalled() + expect(mockDeleteIfExists).not.toHaveBeenCalled() + }) + }) + describe('getPresignedUrl', () => { it('should generate a presigned URL for Azure Blob Storage', async () => { const testKey = 'test-file-key' diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index eda490a8b82..993eeda8c74 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -257,6 +257,52 @@ export async function getPresignedUrlWithConfig( return `${blockBlobClient.url}?${sasToken}` } +/** Generates a SAS-backed single-object PUT for a caller-selected staging key. */ +export async function getBlobPresignedUploadUrl(params: { + key: string + contentType: string + metadata: Record + customConfig: BlobConfig + expiresIn: number +}): Promise<{ url: string; headers: Record }> { + const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } = + await import('@azure/storage-blob') + const client = await getBlockBlobClientFor(params.key, params.customConfig) + const credentials = params.customConfig.connectionString + ? parseConnectionString(params.customConfig.connectionString) + : { + accountName: params.customConfig.accountName, + accountKey: params.customConfig.accountKey, + } + if (!credentials.accountName || !credentials.accountKey) { + throw new Error('Azure Blob SAS generation requires accountName and accountKey') + } + const startsOn = new Date() + const expiresOn = new Date(startsOn.getTime() + params.expiresIn * 1000) + const sasToken = generateBlobSASQueryParameters( + { + containerName: params.customConfig.containerName, + blobName: params.key, + permissions: BlobSASPermissions.parse('w'), + startsOn, + expiresOn, + }, + new StorageSharedKeyCredential(credentials.accountName, credentials.accountKey) + ).toString() + const metadata = sanitizeStorageMetadata(params.metadata, 8000) + return { + url: `${client.url}?${sasToken}`, + headers: { + 'Content-Type': params.contentType, + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-content-type': params.contentType, + ...Object.fromEntries( + Object.entries(metadata).map(([key, value]) => [`x-ms-meta-${key}`, value]) + ), + }, + } +} + /** * Download a file from Azure Blob Storage * @param key Blob name @@ -392,7 +438,12 @@ export async function downloadFromBlobStream( export async function headBlobObject( key: string, customConfig?: BlobConfig -): Promise<{ size: number; contentType?: string } | null> { +): Promise<{ + size: number + contentType?: string + uploadId?: string + version: string +} | null> { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType let containerName: string @@ -423,9 +474,14 @@ export async function headBlobObject( try { const properties = await blockBlobClient.getProperties() + if (properties.copyStatus && properties.copyStatus !== 'success') { + throw new Error(`Blob copy for ${key} is ${properties.copyStatus}`) + } return { size: properties.contentLength ?? 0, contentType: properties.contentType, + uploadId: readUploadId(properties.metadata), + version: properties.etag ?? '', } } catch (err) { const status = (err as { statusCode?: number }).statusCode @@ -437,6 +493,40 @@ export async function headBlobObject( } } +/** + * Copies one immutable staging version into a destination that must not already exist. + * The asynchronous API supports objects above the synchronous copy operation's 256 MiB limit. + */ +export async function promoteBlobObject(params: { + sourceKey: string + destinationKey: string + sourceEtag: string + customConfig: BlobConfig +}): Promise { + if (!params.sourceEtag) throw new Error('Blob staging object is missing its ETag') + const source = await getBlockBlobClientFor(params.sourceKey, params.customConfig) + const destination = await getBlockBlobClientFor(params.destinationKey, params.customConfig) + const copy = await destination.beginCopyFromURL(source.url, { + conditions: { ifNoneMatch: '*' }, + sourceConditions: { ifMatch: params.sourceEtag }, + }) + const result = await copy.pollUntilDone() + if (result.copyStatus !== 'success') { + throw new Error(`Blob promotion finished with status ${result.copyStatus ?? 'unknown'}`) + } +} + +/** Deletes a staging blob only if it is still the version completion inspected. */ +export async function deleteBlobObjectVersion(params: { + key: string + etag: string + customConfig: BlobConfig +}): Promise { + if (!params.etag) throw new Error('Blob staging object is missing its ETag') + const client = await getBlockBlobClientFor(params.key, params.customConfig) + await client.deleteIfExists({ conditions: { ifMatch: params.etag } }) +} + /** * Delete a file from Azure Blob Storage * @param key Blob name @@ -498,49 +588,13 @@ export function deriveBlobBlockId(partNumber: number): string { export async function initiateMultipartUpload( options: AzureMultipartUploadInit ): Promise<{ uploadId: string; key: string }> { - const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') - const { fileName, contentType, customConfig, customKey } = options - - let blobServiceClient: BlobServiceClientType - let containerName: string - - if (customConfig) { - if (customConfig.connectionString) { - blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) - } else if (customConfig.accountName && customConfig.accountKey) { - const credential = new StorageSharedKeyCredential( - customConfig.accountName, - customConfig.accountKey - ) - blobServiceClient = new BlobServiceClient( - `https://${customConfig.accountName}.blob.core.windows.net`, - credential - ) - } else { - throw new Error('Invalid custom blob configuration') - } - containerName = customConfig.containerName - } else { - blobServiceClient = await getBlobServiceClient() - containerName = BLOB_CONFIG.containerName - } + const { fileName, customKey } = options const safeFileName = sanitizeFileName(fileName) const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}` const uploadId = generateId() - const containerClient = blobServiceClient.getContainerClient(containerName) - const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey) - - await blockBlobClient.setMetadata({ - uploadId, - fileName: encodeURIComponent(fileName), - contentType, - uploadStarted: new Date().toISOString(), - multipartUpload: 'true', - }) - return { uploadId, key: uniqueKey, @@ -691,7 +745,9 @@ export async function commitBlobBlockList( export async function completeMultipartUpload( key: string, parts: AzureMultipartPart[], - customConfig?: BlobConfig + customConfig?: BlobConfig, + contentType?: string, + metadata?: Record ): Promise<{ location: string; path: string; key: string }> { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType @@ -726,9 +782,11 @@ export async function completeMultipartUpload( .map((part) => part.blockId) await blockBlobClient.commitBlockList(sortedBlockIds, { + ...(contentType ? { blobHTTPHeaders: { blobContentType: contentType } } : {}), metadata: { multipartUpload: 'completed', uploadCompletedAt: new Date().toISOString(), + ...sanitizeStorageMetadata(metadata ?? {}, 8000), }, }) @@ -743,40 +801,17 @@ export async function completeMultipartUpload( } /** - * Abort multipart upload by deleting the blob if it exists + * Abandons an Azure multipart upload without deleting its key. + * + * Azure has no cancellation operation for staged blocks and garbage-collects uncommitted blocks + * after a week. Deleting by key is unsafe because a concurrent completion may already have + * committed the final blob at that key. */ -export async function abortMultipartUpload(key: string, customConfig?: BlobConfig): Promise { - const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') - let blobServiceClient: BlobServiceClientType - let containerName: string - - if (customConfig) { - if (customConfig.connectionString) { - blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) - } else if (customConfig.accountName && customConfig.accountKey) { - const credential = new StorageSharedKeyCredential( - customConfig.accountName, - customConfig.accountKey - ) - blobServiceClient = new BlobServiceClient( - `https://${customConfig.accountName}.blob.core.windows.net`, - credential - ) - } else { - throw new Error('Invalid custom blob configuration') - } - containerName = customConfig.containerName - } else { - blobServiceClient = await getBlobServiceClient() - containerName = BLOB_CONFIG.containerName - } - - const containerClient = blobServiceClient.getContainerClient(containerName) - const blockBlobClient = containerClient.getBlockBlobClient(key) +export function abortMultipartUpload(_key: string, _customConfig?: BlobConfig): Promise { + return Promise.resolve() +} - try { - await blockBlobClient.deleteIfExists() - } catch (error) { - logger.warn('Error cleaning up multipart upload:', error) - } +function readUploadId(metadata?: Record): string | undefined { + if (!metadata) return undefined + return Object.entries(metadata).find(([key]) => key.toLowerCase() === 'uploadid')?.[1] } diff --git a/apps/sim/lib/uploads/providers/blob/types.ts b/apps/sim/lib/uploads/providers/blob/types.ts index a24a87e384e..5d0e36e307a 100644 --- a/apps/sim/lib/uploads/providers/blob/types.ts +++ b/apps/sim/lib/uploads/providers/blob/types.ts @@ -15,6 +15,8 @@ export interface AzureMultipartUploadInit { * Caller is responsible for uniqueness and prefix conventions. */ customKey?: string + /** Additional object metadata preserved when the block list is committed. */ + metadata?: Record } export interface AzurePartUploadUrl { diff --git a/apps/sim/lib/uploads/providers/gcs/client.test.ts b/apps/sim/lib/uploads/providers/gcs/client.test.ts index dc0b01d2093..280066feec3 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.test.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.test.ts @@ -21,6 +21,7 @@ const { getMetadata: vi.fn(), delete: vi.fn(), getSignedUrl: vi.fn(), + copy: vi.fn(), } const mockBucket = { file: vi.fn(() => mockFile) } const mockGetAccessToken = vi.fn() @@ -71,6 +72,7 @@ import { abortGcsMultipartUpload, completeGcsMultipartUpload, deleteFromGcs, + deleteGcsObjectVersion, downloadFromGcs, getGcsClient, getGcsMultipartPartUrls, @@ -78,6 +80,7 @@ import { getPresignedUrlWithConfig, headGcsObject, initiateGcsMultipartUpload, + promoteGcsObject, resetGcsClientForTesting, uploadGcsPart, uploadToGcs, @@ -300,11 +303,23 @@ describe('GCS Client', () => { describe('headGcsObject', () => { it('should return size and content type when the object exists', async () => { - mockFile.getMetadata.mockResolvedValueOnce([{ size: '2048', contentType: 'text/csv' }]) + mockFile.getMetadata.mockResolvedValueOnce([ + { + size: '2048', + contentType: 'text/csv', + generation: '42', + metadata: { uploadid: 'upload-1' }, + }, + ]) const result = await headGcsObject('data.csv') - expect(result).toEqual({ size: 2048, contentType: 'text/csv' }) + expect(result).toEqual({ + size: 2048, + contentType: 'text/csv', + uploadId: 'upload-1', + version: '42', + }) }) it('should return null when the object is missing', async () => { @@ -326,6 +341,42 @@ describe('GCS Client', () => { }) }) + describe('staged upload promotion', () => { + it('pins the source generation and requires an absent destination', async () => { + mockFile.copy.mockResolvedValueOnce(undefined) + + await promoteGcsObject({ + sourceKey: 'upload-sessions/upload-1/file.bin', + destinationKey: 'workspace/workspace-1/file.bin', + sourceGeneration: '42', + customConfig: { bucket: 'test-bucket' }, + }) + + expect(mockBucket.file).toHaveBeenCalledWith('upload-sessions/upload-1/file.bin', { + generation: '42', + }) + expect(mockBucket.file).toHaveBeenCalledWith('workspace/workspace-1/file.bin') + expect(mockFile.copy).toHaveBeenCalledWith(mockFile, { + preconditionOpts: { ifGenerationMatch: 0 }, + }) + }) + + it('deletes staging only at the inspected generation', async () => { + mockFile.delete.mockResolvedValueOnce(undefined) + + await deleteGcsObjectVersion({ + key: 'upload-sessions/upload-1/file.bin', + generation: '42', + customConfig: { bucket: 'test-bucket' }, + }) + + expect(mockBucket.file).toHaveBeenCalledWith('upload-sessions/upload-1/file.bin', { + generation: '42', + }) + expect(mockFile.delete).toHaveBeenCalledWith({ ifGenerationMatch: '42' }) + }) + }) + describe('deleteFromGcs', () => { it('should delete a file, ignoring missing objects', async () => { mockFile.delete.mockResolvedValueOnce(undefined) @@ -483,10 +534,10 @@ describe('GCS Client', () => { expect(init.method).toBe('DELETE') }) - it('should swallow abort errors', async () => { + it('should surface abort errors', async () => { mockFetch.mockResolvedValueOnce(new Response('boom', { status: 500, statusText: 'ISE' })) - await expect(abortGcsMultipartUpload('key.csv', 'upload-123')).resolves.toBeUndefined() + await expect(abortGcsMultipartUpload('key.csv', 'upload-123')).rejects.toThrow('500 ISE') }) it('should fail multipart calls when no access token is available', async () => { diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts index b4d5194a0d9..29c45559ef7 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.ts @@ -328,7 +328,12 @@ export async function downloadFromGcsStream( export async function headGcsObject( key: string, customConfig?: GcsConfig -): Promise<{ size: number; contentType?: string } | null> { +): Promise<{ + size: number + contentType?: string + uploadId?: string + version: string +} | null> { const config = customConfig || { bucket: GCS_CONFIG.bucket } const storage = await getGcsClient() @@ -337,6 +342,8 @@ export async function headGcsObject( return { size: Number(fileMetadata.size) || 0, contentType: fileMetadata.contentType, + uploadId: readUploadId(fileMetadata.metadata as Record | undefined), + version: String(fileMetadata.generation ?? ''), } } catch (error) { const code = (error as { code?: number } | null)?.code @@ -347,6 +354,35 @@ export async function headGcsObject( } } +/** Copies one immutable staging generation into a destination that must not already exist. */ +export async function promoteGcsObject(params: { + sourceKey: string + destinationKey: string + sourceGeneration: string + customConfig: GcsConfig +}): Promise { + if (!params.sourceGeneration) throw new Error('GCS staging object is missing its generation') + const storage = await getGcsClient() + const bucket = storage.bucket(params.customConfig.bucket) + const source = bucket.file(params.sourceKey, { generation: params.sourceGeneration }) + const destination = bucket.file(params.destinationKey) + await source.copy(destination, { preconditionOpts: { ifGenerationMatch: 0 } }) +} + +/** Deletes a staging object only if it is still the generation completion inspected. */ +export async function deleteGcsObjectVersion(params: { + key: string + generation: string + customConfig: GcsConfig +}): Promise { + if (!params.generation) throw new Error('GCS staging object is missing its generation') + const storage = await getGcsClient() + await storage + .bucket(params.customConfig.bucket) + .file(params.key, { generation: params.generation }) + .delete({ ifGenerationMatch: params.generation }) +} + /** * Get the custom metadata stored on a GCS object. */ @@ -439,7 +475,7 @@ async function gcsXmlApiRequest( export async function initiateGcsMultipartUpload( options: GcsMultipartUploadInit ): Promise<{ uploadId: string; key: string }> { - const { fileName, contentType, customConfig, customKey, purpose } = options + const { fileName, contentType, customConfig, customKey, purpose, metadata } = options const config = customConfig || { bucket: GCS_CONFIG.bucket } @@ -452,6 +488,12 @@ export async function initiateGcsMultipartUpload( 'x-goog-meta-originalname': encodeURIComponent(sanitizeFilenameForMetadata(fileName)), 'x-goog-meta-uploadedat': new Date().toISOString(), 'x-goog-meta-purpose': purpose || 'knowledge-base', + ...Object.fromEntries( + Object.entries(sanitizeStorageMetadata(metadata ?? {}, 8000)).map(([key, value]) => [ + `x-goog-meta-${key.toLowerCase()}`, + value, + ]) + ), }, }) @@ -581,9 +623,10 @@ export async function abortGcsMultipartUpload( customConfig?: GcsConfig ): Promise { const config = customConfig || { bucket: GCS_CONFIG.bucket } - try { - await gcsXmlApiRequest('DELETE', config.bucket, key, `uploadId=${encodeURIComponent(uploadId)}`) - } catch (error) { - logger.warn('Error cleaning up GCS multipart upload:', error) - } + await gcsXmlApiRequest('DELETE', config.bucket, key, `uploadId=${encodeURIComponent(uploadId)}`) +} + +function readUploadId(metadata?: Record): string | undefined { + if (!metadata) return undefined + return Object.entries(metadata).find(([key]) => key.toLowerCase() === 'uploadid')?.[1] } diff --git a/apps/sim/lib/uploads/providers/gcs/types.ts b/apps/sim/lib/uploads/providers/gcs/types.ts index 4a54bf3c1d3..3705296edb7 100644 --- a/apps/sim/lib/uploads/providers/gcs/types.ts +++ b/apps/sim/lib/uploads/providers/gcs/types.ts @@ -17,6 +17,8 @@ export interface GcsMultipartUploadInit { * for backwards compatibility. */ purpose?: string + /** Additional object metadata fixed when the multipart upload is initiated. */ + metadata?: Record } export interface GcsPartUploadUrl { diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts index 75eea9a3dde..0979c5c874a 100644 --- a/apps/sim/lib/uploads/providers/s3/client.test.ts +++ b/apps/sim/lib/uploads/providers/s3/client.test.ts @@ -11,7 +11,9 @@ const { mockS3ClientConstructor, mockPutObjectCommand, mockGetObjectCommand, + mockHeadObjectCommand, mockDeleteObjectCommand, + mockCopyObjectCommand, mockCompleteMultipartUploadCommand, mockGetSignedUrl, mockEnv, @@ -51,7 +53,9 @@ const { ), mockPutObjectCommand: vi.fn().mockImplementation(class {}), mockGetObjectCommand: vi.fn().mockImplementation(class {}), + mockHeadObjectCommand: vi.fn().mockImplementation(class {}), mockDeleteObjectCommand: vi.fn().mockImplementation(class {}), + mockCopyObjectCommand: vi.fn().mockImplementation(class {}), mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}), mockGetSignedUrl: vi.fn(), mockEnv, @@ -62,7 +66,9 @@ vi.mock('@aws-sdk/client-s3', () => ({ S3Client: mockS3ClientConstructor, PutObjectCommand: mockPutObjectCommand, GetObjectCommand: mockGetObjectCommand, + HeadObjectCommand: mockHeadObjectCommand, DeleteObjectCommand: mockDeleteObjectCommand, + CopyObjectCommand: mockCopyObjectCommand, CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand, })) @@ -97,9 +103,13 @@ vi.mock('@/lib/uploads/config', () => ({ import { completeS3MultipartUpload, deleteFromS3, + deleteS3ObjectVersion, downloadFromS3, getPresignedUrl, getS3Client, + getS3PresignedUploadUrl, + headS3Object, + promoteS3Object, resetS3ClientForTesting, uploadToS3, } from '@/lib/uploads/providers/s3/client' @@ -236,6 +246,89 @@ describe('S3 Client', () => { }) }) + describe('staged upload primitives', () => { + it('signs metadata without returning duplicate x-amz-meta headers', async () => { + mockGetSignedUrl.mockResolvedValueOnce('https://example.com/signed-put') + + const result = await getS3PresignedUploadUrl({ + key: 'upload-sessions/upload-1/file.bin', + contentType: 'application/octet-stream', + fileSize: 3, + metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + customConfig: mockS3Config, + expiresIn: 600, + }) + + expect(mockPutObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'upload-sessions/upload-1/file.bin', + ContentType: 'application/octet-stream', + ContentLength: 3, + Metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + }) + expect(result).toEqual({ + url: 'https://example.com/signed-put', + headers: { + 'Content-Type': 'application/octet-stream', + }, + }) + }) + + it('reads the upload identity and immutable ETag', async () => { + mockSend.mockResolvedValueOnce({ + ContentLength: 3, + ContentType: 'application/octet-stream', + Metadata: { uploadid: 'upload-1' }, + ETag: '"etag-1"', + }) + + await expect( + headS3Object('upload-sessions/upload-1/file.bin', mockS3Config) + ).resolves.toEqual({ + size: 3, + contentType: 'application/octet-stream', + uploadId: 'upload-1', + version: '"etag-1"', + }) + }) + + it('pins the source ETag and requires an absent promotion destination', async () => { + mockSend.mockResolvedValueOnce({}) + + await promoteS3Object({ + sourceKey: 'upload-sessions/upload-1/file.bin', + destinationKey: 'workspace/workspace-1/file.bin', + sourceEtag: '"etag-1"', + customConfig: mockS3Config, + }) + + expect(mockCopyObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'workspace/workspace-1/file.bin', + CopySource: 'test-bucket/upload-sessions/upload-1/file.bin', + CopySourceIfMatch: '"etag-1"', + IfNoneMatch: '*', + MetadataDirective: 'COPY', + }) + }) + + it('deletes staging only when its ETag still matches', async () => { + mockSend.mockResolvedValueOnce({}) + + await deleteS3ObjectVersion({ + key: 'upload-sessions/upload-1/file.bin', + etag: '"etag-1"', + customConfig: mockS3Config, + }) + + expect(mockDeleteObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'upload-sessions/upload-1/file.bin', + IfMatch: '"etag-1"', + }) + }) + }) + describe('downloadFromS3', () => { it('should download a file from S3', async () => { const mockStream = { diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index fafe4fc8897..ecbc8879562 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -2,6 +2,7 @@ import type { Readable } from 'node:stream' import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, + CopyObjectCommand, CreateMultipartUploadCommand, DeleteObjectCommand, DeleteObjectsCommand, @@ -167,6 +168,37 @@ export async function getPresignedUrlWithConfig( return getSignedUrl(getS3Client(), command, { expiresIn }) } +/** + * Generates a signed single-object PUT for a caller-selected staging key. + * The AWS presigner hoists `x-amz-meta-*` values into the signed query string, + * so only ordinary transfer headers are returned. Repeating that metadata as + * request headers makes S3 reject the otherwise-valid signature. + */ +export async function getS3PresignedUploadUrl(params: { + key: string + contentType: string + fileSize: number + metadata: Record + customConfig: S3Config + expiresIn: number +}): Promise<{ url: string; headers: Record }> { + const metadata = sanitizeStorageMetadata(params.metadata, 2000) + const command = new PutObjectCommand({ + Bucket: params.customConfig.bucket, + Key: params.key, + ContentType: params.contentType, + ContentLength: params.fileSize, + Metadata: metadata, + }) + const url = await getSignedUrl(getS3Client(), command, { expiresIn: params.expiresIn }) + return { + url, + headers: { + 'Content-Type': params.contentType, + }, + } +} + /** * Download a file from S3 * @param key S3 object key @@ -243,7 +275,12 @@ export async function downloadFromS3Stream( export async function headS3Object( key: string, customConfig?: S3Config -): Promise<{ size: number; contentType?: string } | null> { +): Promise<{ + size: number + contentType?: string + uploadId?: string + version: string +} | null> { const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region } try { @@ -253,6 +290,8 @@ export async function headS3Object( return { size: response.ContentLength ?? 0, contentType: response.ContentType, + uploadId: readUploadId(response.Metadata), + version: response.ETag ?? '', } } catch (error) { const code = (error as { name?: string; $metadata?: { httpStatusCode?: number } } | null)?.name @@ -265,6 +304,48 @@ export async function headS3Object( } } +/** + * Copies one immutable staging version into a destination that must not already exist. + */ +export async function promoteS3Object(params: { + sourceKey: string + destinationKey: string + sourceEtag: string + customConfig: S3Config +}): Promise { + if (!params.sourceEtag) throw new Error('S3 staging object is missing its ETag') + const encodedSource = `${params.customConfig.bucket}/${params.sourceKey + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/')}` + await getS3Client().send( + new CopyObjectCommand({ + Bucket: params.customConfig.bucket, + Key: params.destinationKey, + CopySource: encodedSource, + CopySourceIfMatch: params.sourceEtag, + IfNoneMatch: '*', + MetadataDirective: 'COPY', + }) + ) +} + +/** Deletes a staging object only if it is still the version completion inspected. */ +export async function deleteS3ObjectVersion(params: { + key: string + etag: string + customConfig: S3Config +}): Promise { + if (!params.etag) throw new Error('S3 staging object is missing its ETag') + await getS3Client().send( + new DeleteObjectCommand({ + Bucket: params.customConfig.bucket, + Key: params.key, + IfMatch: params.etag, + }) + ) +} + /** * Delete a file from S3 * @param key S3 object key @@ -341,7 +422,7 @@ export async function deleteManyFromS3( export async function initiateS3MultipartUpload( options: S3MultipartUploadInit ): Promise<{ uploadId: string; key: string }> { - const { fileName, contentType, customConfig, customKey, purpose } = options + const { fileName, contentType, customConfig, customKey, purpose, metadata } = options const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region } const s3Client = getS3Client() @@ -357,6 +438,7 @@ export async function initiateS3MultipartUpload( originalName: sanitizeFilenameForMetadata(fileName), uploadedAt: new Date().toISOString(), purpose: purpose || 'knowledge-base', + ...sanitizeStorageMetadata(metadata ?? {}, 2000), }, }) @@ -372,6 +454,11 @@ export async function initiateS3MultipartUpload( } } +function readUploadId(metadata?: Record): string | undefined { + if (!metadata) return undefined + return Object.entries(metadata).find(([key]) => key.toLowerCase() === 'uploadid')?.[1] +} + /** * Upload a single multipart part from the server (Body in hand), returning its * `{ PartNumber, ETag }`. The presigned variant ({@link getS3MultipartPartUrls}) diff --git a/apps/sim/lib/uploads/providers/s3/types.ts b/apps/sim/lib/uploads/providers/s3/types.ts index 266a86a862c..0f960e3e4a0 100644 --- a/apps/sim/lib/uploads/providers/s3/types.ts +++ b/apps/sim/lib/uploads/providers/s3/types.ts @@ -18,6 +18,8 @@ export interface S3MultipartUploadInit { * for backwards compatibility. */ purpose?: string + /** Additional object metadata fixed when the multipart upload is initiated. */ + metadata?: Record } export interface S3PartUploadUrl { diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index 4a3faa58366..48c2e50939b 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -3,7 +3,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' -import type { StorageContext } from '../shared/types' +import { type StorageContext, toLegacyWorkspaceFileSize } from '../shared/types' const logger = createLogger('FileMetadata') @@ -49,7 +49,8 @@ export async function insertFileMetadata( originalName, displayName: originalName, contentType, - size, + size: toLegacyWorkspaceFileSize(size), + sizeBytes: size, deletedAt: null, uploadedAt: new Date(), }) @@ -86,7 +87,8 @@ export async function insertFileMetadata( originalName, displayName: originalName, contentType, - size, + size: toLegacyWorkspaceFileSize(size), + sizeBytes: size, deletedAt: null, uploadedAt: new Date(), }) @@ -112,44 +114,6 @@ export async function insertFileMetadata( } } -/** - * Bulk-insert file metadata rows in a single statement. - * - * Intended for batch upload flows that create many fresh keys at once (e.g. the - * presigned batch route), replacing a fan-out of individual `insertFileMetadata` - * calls. Uses `ON CONFLICT DO NOTHING` on the active-key unique index, so it is - * safe against a concurrent single insert and idempotent for already-present - * active keys. Unlike {@link insertFileMetadata} it does NOT restore - * soft-deleted rows — callers use this only for newly generated keys. - */ -export async function insertFileMetadataMany( - rows: Array & { id?: string }> -): Promise { - if (rows.length === 0) { - return - } - - await db - .insert(workspaceFiles) - .values( - rows.map((row) => ({ - id: row.id || generateId(), - key: row.key, - userId: row.userId, - workspaceId: row.workspaceId || null, - folderId: row.folderId ?? null, - context: row.context, - originalName: row.originalName, - displayName: row.originalName, - contentType: row.contentType, - size: row.size, - deletedAt: null, - uploadedAt: new Date(), - })) - ) - .onConflictDoNothing() -} - /** * Get file metadata by key with optional context filter */ @@ -252,26 +216,11 @@ export interface KnowledgeBaseFileOwnership { * Record the ownership binding for a single knowledge-base upload. KB file * authorization (`verifyKBFileAccess`) resolves the owning workspace from this * binding, so every KB object must have exactly one. Single source of truth for - * the binding shape across the presigned, batch-presigned, and multipart upload - * paths — keep all callers routed through here so they cannot drift. + * the binding shape for knowledge upload sessions — keep all callers routed + * through here so they cannot drift. */ export async function recordKnowledgeBaseFileOwnership( ownership: KnowledgeBaseFileOwnership ): Promise { await insertFileMetadata({ ...ownership, context: 'knowledge-base' }) } - -/** - * Bulk variant of {@link recordKnowledgeBaseFileOwnership} for batch upload flows. - * Idempotent against the active-key unique index (ON CONFLICT DO NOTHING). - */ -export async function recordKnowledgeBaseFileOwnershipMany( - ownerships: KnowledgeBaseFileOwnership[] -): Promise { - if (ownerships.length === 0) { - return - } - await insertFileMetadataMany( - ownerships.map((ownership) => ({ ...ownership, context: 'knowledge-base' })) - ) -} diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index 65f2570ecaf..b26977f117e 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -1,16 +1,30 @@ /** * Defense-in-depth ceiling on the size of any single workspace file upload. - * Enforced both server-side (presigned route) and client-side (Files tab) so + * Enforced both server-side (upload-session creation) and client-side (Files tab) so * users get fast feedback before bytes are streamed. */ export const MAX_WORKSPACE_FILE_SIZE = 5 * 1024 * 1024 * 1024 +const MAX_POSTGRES_INTEGER = 2_147_483_647 + +/** + * Keeps the legacy int4 metadata projection writable while `size_bytes` stores the exact value. + */ +export function toLegacyWorkspaceFileSize(size: number): number { + if (!Number.isSafeInteger(size) || size < 0) + throw new Error(`Invalid workspace file size: ${size}`) + return Math.min(size, MAX_POSTGRES_INTEGER) +} + /** * Cap on the legacy FormData upload route, which buffers the whole file in * worker memory. Direct-to-storage uploads use {@link MAX_WORKSPACE_FILE_SIZE}. */ export const MAX_WORKSPACE_FORMDATA_FILE_SIZE = 100 * 1024 * 1024 +/** Maximum size accepted by the knowledge-document parsing pipeline. */ +export const MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE = 100 * 1024 * 1024 + export type StorageContext = | 'knowledge-base' | 'chat' @@ -18,6 +32,7 @@ export type StorageContext = | 'mothership' | 'execution' | 'workspace' + | 'table-import' | 'profile-pictures' | 'og-images' | 'logs' diff --git a/apps/sim/lib/uploads/upload-session/README.md b/apps/sim/lib/uploads/upload-session/README.md new file mode 100644 index 00000000000..c29f9a312cc --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/README.md @@ -0,0 +1,27 @@ +# Upload sessions + +Upload sessions use a signed, stateless control-plane token and an immutable staging object. Files +up to and including 50 MiB use one signed `PUT`; larger files use multipart upload. Completion +verifies the staged object's upload ID, byte size, and content type before promoting it to a +create-only final key. + +The `upload-sessions/` prefix is temporary. Production S3 and GCS buckets must expire objects under +that prefix after two days and abort incomplete multipart uploads after two days. Azure containers +must expire committed blobs under that prefix after two days; Azure automatically garbage-collects +uncommitted blocks after seven days. These policies exceed the 24-hour token lifetime, preserve a +retry window, and bound abandoned provider state. Local storage applies the equivalent 25-hour +policy with the bounded cleanup sweep in `cleanup.ts`. The local sweep retains process-local +directory cursors between bounded runs, so a large set of fresh entries cannot indefinitely hide +expired entries later in either directory. + +Local cleanup currently runs opportunistically when that same Sim process creates an upload +session. The repository has no scheduler that safely reaches every process-local filesystem in a +multi-replica self-hosted deployment: an HTTP cron request can land on only one replica, while the +Trigger workers do not own the web replica's disk. Operators using non-shared local disks must +therefore ensure uploads continue to trigger the sweep on each replica or invoke the exported +bounded sweep from their own per-replica maintenance hook. Cloud deployments should use the +provider lifecycle rules above instead. + +Final objects are not covered by the staging lifecycle. Completion retains staging until the +domain finalizer succeeds, then conditionally deletes only the exact staging version it verified. +Abort is also staging-only and must never delete a promoted final object. diff --git a/apps/sim/lib/uploads/upload-session/cleanup.test.ts b/apps/sim/lib/uploads/upload-session/cleanup.test.ts new file mode 100644 index 00000000000..2a9dad064a2 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/cleanup.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { testUploadDirectory } = vi.hoisted(() => ({ + testUploadDirectory: `/tmp/sim-upload-session-cleanup-${process.pid}`, +})) + +vi.mock('@/lib/uploads/core/setup.server', () => ({ + UPLOAD_DIR_SERVER: testUploadDirectory, +})) + +import { + LOCAL_UPLOAD_ARTIFACT_TTL_MS, + maybeCleanupLocalUploadArtifacts, + resetLocalUploadCleanupForTesting, + sweepLocalUploadArtifacts, +} from '@/lib/uploads/upload-session/cleanup' + +describe('local upload artifact cleanup', () => { + beforeEach(async () => { + resetLocalUploadCleanupForTesting() + await rm(testUploadDirectory, { recursive: true, force: true }) + await mkdir(testUploadDirectory, { recursive: true }) + }) + + it('removes expired multipart and staging entries while retaining fresh entries', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createArtifact('.multipart/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createArtifact('upload-sessions/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createArtifact('upload-sessions/fresh', now) + + await expect(sweepLocalUploadArtifacts({ now })).resolves.toEqual({ scanned: 3, removed: 2 }) + await expect(stat(`${testUploadDirectory}/.multipart/expired`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(stat(`${testUploadDirectory}/upload-sessions/expired`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(stat(`${testUploadDirectory}/upload-sessions/fresh`)).resolves.toBeDefined() + }) + + it('bounds each sweep by the requested entry count', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createArtifact('.multipart/one', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createArtifact('.multipart/two', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + + const result = await sweepLocalUploadArtifacts({ now, maxEntries: 1 }) + + expect(result).toEqual({ scanned: 1, removed: 1 }) + }) + + it('continues from its directory cursor so old entries cannot starve behind fresh ones', async () => { + const now = Date.UTC(2026, 7, 4, 12) + for (let index = 0; index < 5; index++) { + await createArtifact(`.multipart/fresh-${index}`, now) + } + await createArtifact('.multipart/expired-last', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + + const sweeps = [ + await sweepLocalUploadArtifacts({ now, maxEntries: 2 }), + await sweepLocalUploadArtifacts({ now, maxEntries: 2 }), + await sweepLocalUploadArtifacts({ now, maxEntries: 2 }), + ] + + expect(sweeps.reduce((total, result) => total + result.scanned, 0)).toBe(6) + expect(sweeps.reduce((total, result) => total + result.removed, 0)).toBe(1) + await expect(stat(`${testUploadDirectory}/.multipart/expired-last`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + it('coalesces concurrent cleanup and rate-limits the next sweep', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createArtifact('.multipart/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + + const [first, concurrent] = await Promise.all([ + maybeCleanupLocalUploadArtifacts(now), + maybeCleanupLocalUploadArtifacts(now), + ]) + + expect(first).toEqual({ scanned: 1, removed: 1 }) + expect(concurrent).toEqual(first) + await expect(maybeCleanupLocalUploadArtifacts(now)).resolves.toEqual({ scanned: 0, removed: 0 }) + }) +}) + +async function createArtifact(relativePath: string, modifiedAt: number): Promise { + const path = `${testUploadDirectory}/${relativePath}` + await mkdir(path, { recursive: true }) + await writeFile(`${path}/payload`, 'test') + const time = new Date(modifiedAt) + await utimes(path, time, time) +} diff --git a/apps/sim/lib/uploads/upload-session/cleanup.ts b/apps/sim/lib/uploads/upload-session/cleanup.ts new file mode 100644 index 00000000000..bdb39b75cb9 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/cleanup.ts @@ -0,0 +1,127 @@ +import type { Dirent } from 'node:fs' +import { opendir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' + +export const LOCAL_UPLOAD_CLEANUP_INTERVAL_MS = 15 * 60 * 1000 +export const LOCAL_UPLOAD_ARTIFACT_TTL_MS = 25 * 60 * 60 * 1000 +export const LOCAL_UPLOAD_CLEANUP_MAX_ENTRIES = 200 + +export interface LocalUploadCleanupResult { + scanned: number + removed: number +} + +let activeCleanup: Promise | null = null +let lastCleanupAt = 0 + +const CLEANUP_ROOTS = ['.multipart', 'upload-sessions'] as const + +interface CleanupRootState { + directory: Awaited> | null +} + +const cleanupRootStates: CleanupRootState[] = CLEANUP_ROOTS.map(() => ({ directory: null })) +let nextCleanupRootIndex = 0 + +/** + * Opportunistically removes expired local multipart and staged-PUT state. + * Calls are single-flight and rate-limited; each sweep examines a bounded number of entries. + */ +export function maybeCleanupLocalUploadArtifacts( + now = Date.now() +): Promise { + if (activeCleanup) return activeCleanup + if (now - lastCleanupAt < LOCAL_UPLOAD_CLEANUP_INTERVAL_MS) { + return Promise.resolve({ scanned: 0, removed: 0 }) + } + activeCleanup = sweepLocalUploadArtifacts({ now }).then((result) => { + lastCleanupAt = now + return result + }) + return activeCleanup.finally(() => { + activeCleanup = null + }) +} + +/** Performs one bounded sweep for per-replica maintenance hooks and deterministic tests. */ +export async function sweepLocalUploadArtifacts(params?: { + now?: number + maxEntries?: number +}): Promise { + const now = params?.now ?? Date.now() + const maxEntries = params?.maxEntries ?? LOCAL_UPLOAD_CLEANUP_MAX_ENTRIES + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) { + throw new Error('maxEntries must be a positive integer') + } + const cutoff = now - LOCAL_UPLOAD_ARTIFACT_TTL_MS + let scanned = 0 + let removed = 0 + const exhaustedRoots = new Set() + + while (scanned < maxEntries) { + const artifact = await readNextCleanupArtifact(exhaustedRoots) + if (!artifact) break + scanned++ + const path = join(artifact.directoryPath, artifact.entry.name) + let file + try { + file = await stat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + if (file.mtimeMs > cutoff) continue + await rm(path, { recursive: artifact.entry.isDirectory(), force: true }) + removed++ + } + + return { scanned, removed } +} + +export function resetLocalUploadCleanupForTesting(): void { + for (const state of cleanupRootStates) { + if (!state.directory) continue + try { + state.directory.closeSync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') throw error + } + state.directory = null + } + nextCleanupRootIndex = 0 + activeCleanup = null + lastCleanupAt = 0 +} + +async function readNextCleanupArtifact( + exhaustedRoots: Set +): Promise<{ directoryPath: string; entry: Dirent } | null> { + for (let attempt = 0; attempt < CLEANUP_ROOTS.length; attempt++) { + const rootIndex = nextCleanupRootIndex + nextCleanupRootIndex = (nextCleanupRootIndex + 1) % CLEANUP_ROOTS.length + if (exhaustedRoots.has(rootIndex)) continue + + const directoryPath = join(UPLOAD_DIR_SERVER, CLEANUP_ROOTS[rootIndex]) + const state = cleanupRootStates[rootIndex] + if (!state.directory) { + try { + state.directory = await opendir(directoryPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + exhaustedRoots.add(rootIndex) + continue + } + throw error + } + } + + const entry = await state.directory.read() + if (entry) return { directoryPath, entry } + + await state.directory.close() + state.directory = null + exhaustedRoots.add(rootIndex) + } + return null +} diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts new file mode 100644 index 00000000000..7fa9990ea11 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -0,0 +1,243 @@ +/** + * @vitest-environment node + */ +import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { testUploadDirectory } = vi.hoisted(() => ({ + testUploadDirectory: `/tmp/sim-upload-session-provider-${process.pid}`, +})) + +vi.mock('@/lib/uploads/core/setup.server', () => ({ + UPLOAD_DIR_SERVER: testUploadDirectory, +})) + +vi.mock('@/lib/uploads/config', () => ({ + USE_BLOB_STORAGE: false, + USE_GCS_STORAGE: false, + USE_S3_STORAGE: false, + getStorageConfig: vi.fn(() => ({})), +})) + +import { deleteFile } from '@/lib/uploads/core/storage-service' +import { + headProviderObject, + LocalUploadBodyError, + promoteProviderObject, + writeLocalMultipartPart, + writeLocalPutObject, +} from '@/lib/uploads/upload-session/provider' + +const CONTEXT = 'workspace' as const +const METADATA = { + uploadId: 'upload-1', + userId: 'user-1', + originalName: 'file.bin', + purpose: 'workspace_file', + workspaceId: 'workspace-1', +} + +describe('local upload-session provider', () => { + beforeEach(async () => { + await rm(testUploadDirectory, { recursive: true, force: true }) + await mkdir(testUploadDirectory, { recursive: true }) + }) + + it('streams an exact-size PUT and persists its object identity', async () => { + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/file.bin', + body: byteStream('ab', 'cd'), + expectedSize: 4, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + + await expect(readFile(localPath('upload-sessions/upload-1/file.bin'), 'utf8')).resolves.toBe( + 'abcd' + ) + await expect( + headProviderObject({ + provider: 'local', + key: 'upload-sessions/upload-1/file.bin', + context: CONTEXT, + }) + ).resolves.toMatchObject({ + size: 4, + contentType: 'application/octet-stream', + uploadId: 'upload-1', + version: expect.any(String), + }) + expect(await temporaryFiles('upload-sessions/upload-1')).toEqual([]) + }) + + it('persists an empty PUT object with its identity metadata', async () => { + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/empty.md', + body: byteStream(), + expectedSize: 0, + contentType: 'text/markdown', + metadata: METADATA, + }) + + await expect(stat(localPath('upload-sessions/upload-1/empty.md'))).resolves.toMatchObject({ + size: 0, + }) + await expect( + headProviderObject({ + provider: 'local', + key: 'upload-sessions/upload-1/empty.md', + context: CONTEXT, + }) + ).resolves.toMatchObject({ + size: 0, + contentType: 'text/markdown', + uploadId: 'upload-1', + version: expect.any(String), + }) + }) + + it.each([ + { name: 'short', chunks: ['ab'], expectedSize: 3 }, + { name: 'oversized', chunks: ['ab', 'cd'], expectedSize: 3 }, + ])('rejects a $name PUT and removes its temporary files', async ({ chunks, expectedSize }) => { + await expect( + writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/file.bin', + body: byteStream(...chunks), + expectedSize, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + ).rejects.toBeInstanceOf(LocalUploadBodyError) + + await expect( + headProviderObject({ + provider: 'local', + key: 'upload-sessions/upload-1/file.bin', + context: CONTEXT, + }) + ).resolves.toBeNull() + expect(await temporaryFiles('upload-sessions/upload-1')).toEqual([]) + }) + + it('publishes a multipart part atomically after exact-size validation', async () => { + await writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 1, + body: byteStream('abc'), + expectedSize: 3, + }) + + await expect(readFile(localPath('.multipart/upload-1/1.part'), 'utf8')).resolves.toBe('abc') + + await expect( + writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 1, + body: byteStream('x'), + expectedSize: 3, + }) + ).rejects.toBeInstanceOf(LocalUploadBodyError) + + await expect(readFile(localPath('.multipart/upload-1/1.part'), 'utf8')).resolves.toBe('abc') + expect(await temporaryFiles('.multipart/upload-1')).toEqual([]) + }) + + it('promotes only the inspected source version into a new destination', async () => { + const stagingKey = 'upload-sessions/upload-1/file.bin' + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey, + body: byteStream('old'), + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + const inspected = await requiredLocalHead(stagingKey) + + await promoteProviderObject({ + provider: 'local', + sourceKey: stagingKey, + destinationKey: 'workspace/workspace-1/file.bin', + sourceVersion: inspected.version, + context: CONTEXT, + }) + + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe('old') + await expect( + promoteProviderObject({ + provider: 'local', + sourceKey: stagingKey, + destinationKey: 'workspace/workspace-1/file.bin', + sourceVersion: inspected.version, + context: CONTEXT, + }) + ).rejects.toMatchObject({ code: 'EEXIST' }) + + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey, + body: byteStream('new'), + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + await expect( + promoteProviderObject({ + provider: 'local', + sourceKey: stagingKey, + destinationKey: 'workspace/workspace-1/changed.bin', + sourceVersion: inspected.version, + context: CONTEXT, + }) + ).rejects.toThrow('Local staging object changed during promotion') + await expect( + headProviderObject({ + provider: 'local', + key: 'workspace/workspace-1/changed.bin', + context: CONTEXT, + }) + ).resolves.toBeNull() + + await deleteFile({ key: 'workspace/workspace-1/file.bin', context: CONTEXT }) + await expect(stat(localPath('workspace/workspace-1/file.bin'))).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect( + stat(localPath('workspace/workspace-1/file.bin.upload-metadata.json')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) + +function byteStream(...chunks: string[]): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) +} + +function localPath(key: string): string { + return `${testUploadDirectory}/${key}` +} + +async function temporaryFiles(relativeDirectory: string): Promise { + const entries = await readdir(localPath(relativeDirectory)).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return [] + throw error + } + ) + return entries.filter((entry) => entry.startsWith('.')) +} + +async function requiredLocalHead(key: string) { + const head = await headProviderObject({ provider: 'local', key, context: CONTEXT }) + if (!head) throw new Error(`Missing local test object ${key}`) + return head +} diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts new file mode 100644 index 00000000000..f40f74ee921 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -0,0 +1,754 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { link, mkdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { + getStorageConfig, + USE_BLOB_STORAGE, + USE_GCS_STORAGE, + USE_S3_STORAGE, +} from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { + createBlobConfig, + createGcsConfig, + createS3Config, + LOCAL_UPLOAD_METADATA_SUFFIX, +} from '@/lib/uploads/core/storage-service' +import type { UploadStorageProvider } from '@/lib/uploads/core/upload-token' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' + +export type { UploadStorageProvider } from '@/lib/uploads/core/upload-token' + +export interface CompletedUploadPart { + partNumber: number + etag?: string +} + +export interface UploadPartUrl { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +export interface UploadObjectHead { + size: number + contentType: string + uploadId: string + version: string +} + +interface LocalUploadMetadata { + uploadId: string + contentType: string + metadata: Record +} + +export class LocalUploadBodyError extends Error { + constructor(message: string) { + super(message) + this.name = 'LocalUploadBodyError' + } +} + +export function uploadStorageProvider(): UploadStorageProvider { + if (USE_BLOB_STORAGE) return 'blob' + if (USE_S3_STORAGE) return 's3' + if (USE_GCS_STORAGE) return 'gcs' + return 'local' +} + +export async function initiateMultipartProviderUpload(params: { + stagingKey: string + fileName: string + contentType: string + fileSize: number + context: StorageContext + uploadId: string + metadata: Record +}): Promise<{ provider: UploadStorageProvider; providerUploadId: string | null }> { + const provider = uploadStorageProvider() + const config = getStorageConfig(params.context) + const metadata = { ...params.metadata, uploadId: params.uploadId } + + if (provider === 's3') { + const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + const result = await initiateS3MultipartUpload({ + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + customConfig: createS3Config(config), + customKey: params.stagingKey, + purpose: params.context, + metadata, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'blob') { + const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + const result = await initiateMultipartUpload({ + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + customConfig: createBlobConfig(config), + customKey: params.stagingKey, + metadata, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'gcs') { + const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + const result = await initiateGcsMultipartUpload({ + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + customConfig: createGcsConfig(config), + customKey: params.stagingKey, + purpose: params.context, + metadata, + }) + return { provider, providerUploadId: result.uploadId } + } + + await mkdir(localPartsDirectory(params.uploadId), { recursive: true }) + return { provider, providerUploadId: null } +} + +export async function createPutProviderTransfer(params: { + provider: UploadStorageProvider + stagingKey: string + contentType: string + fileSize: number + context: StorageContext + uploadId: string + uploadToken: string + localOrigin?: string + expiresAt: Date + metadata: Record +}): Promise<{ method: 'put'; url: string; headers: Record }> { + const expiresIn = Math.floor((params.expiresAt.getTime() - Date.now()) / 1000) + if (expiresIn < 1) throw new Error('Cannot sign an expired PUT upload session') + + if (params.provider === 'local') { + if (!params.localOrigin) throw new Error('localOrigin is required for local PUT uploads') + const origin = new URL(params.localOrigin) + const url = new URL(`/api/v2/uploads/${encodeURIComponent(params.uploadId)}`, origin) + return { + method: 'put', + url: url.toString(), + headers: { + 'Content-Type': params.contentType, + 'upload-token': params.uploadToken, + }, + } + } + + const config = getStorageConfig(params.context) + const metadata = { ...params.metadata, uploadId: params.uploadId } + if (params.provider === 's3') { + const { getS3PresignedUploadUrl } = await import('@/lib/uploads/providers/s3/client') + const transfer = await getS3PresignedUploadUrl({ + key: params.stagingKey, + contentType: params.contentType, + fileSize: params.fileSize, + metadata, + customConfig: createS3Config(config), + expiresIn, + }) + return { method: 'put', ...transfer } + } + if (params.provider === 'blob') { + const { getBlobPresignedUploadUrl } = await import('@/lib/uploads/providers/blob/client') + const transfer = await getBlobPresignedUploadUrl({ + key: params.stagingKey, + contentType: params.contentType, + metadata, + customConfig: createBlobConfig(config), + expiresIn, + }) + return { method: 'put', ...transfer } + } + const { getGcsPresignedUploadUrl } = await import('@/lib/uploads/providers/gcs/client') + const transfer = await getGcsPresignedUploadUrl( + params.stagingKey, + params.contentType, + metadata, + createGcsConfig(config), + expiresIn + ) + return { method: 'put', url: transfer.url, headers: transfer.signedHeaders } +} + +export async function getMultipartProviderPartUrls(params: { + provider: UploadStorageProvider + providerUploadId: string | null + stagingKey: string + context: StorageContext + partNumbers: number[] + localUrl: (partNumber: number) => string +}): Promise { + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + if (params.provider === 'local') { + return params.partNumbers.map((partNumber) => ({ + partNumber, + url: params.localUrl(partNumber), + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (!params.providerUploadId) throw new Error(`Missing ${params.provider} multipart upload id`) + const config = getStorageConfig(params.context) + + if (params.provider === 's3') { + const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') + const urls = await getS3MultipartPartUrls( + params.stagingKey, + params.providerUploadId, + params.partNumbers, + createS3Config(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (params.provider === 'blob') { + const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') + const urls = await getMultipartPartUrls( + params.stagingKey, + params.partNumbers, + createBlobConfig(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') + const urls = await getGcsMultipartPartUrls( + params.stagingKey, + params.providerUploadId, + params.partNumbers, + createGcsConfig(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) +} + +export async function completeMultipartProviderUpload(params: { + provider: UploadStorageProvider + providerUploadId: string | null + uploadId: string + stagingKey: string + contentType: string + context: StorageContext + parts: CompletedUploadPart[] + metadata: Record +}): Promise { + if (params.provider === 'local') { + await assembleLocalParts( + params.uploadId, + params.stagingKey, + params.parts, + params.contentType, + params.metadata + ) + return + } + if (!params.providerUploadId) throw new Error(`Missing ${params.provider} multipart upload id`) + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await completeS3MultipartUpload( + params.stagingKey, + params.providerUploadId, + params.parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag('s3', part), + })), + createS3Config(config) + ) + return + } + if (params.provider === 'blob') { + const { completeMultipartUpload, deriveBlobBlockId } = await import( + '@/lib/uploads/providers/blob/client' + ) + await completeMultipartUpload( + params.stagingKey, + params.parts.map((part) => ({ + partNumber: part.partNumber, + blockId: deriveBlobBlockId(part.partNumber), + })), + createBlobConfig(config), + params.contentType, + { ...params.metadata, uploadId: params.uploadId } + ) + return + } + const { completeGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await completeGcsMultipartUpload( + params.stagingKey, + params.providerUploadId, + params.parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag('gcs', part), + })), + createGcsConfig(config) + ) +} + +export async function headProviderObject(params: { + provider: UploadStorageProvider + key: string + context: StorageContext +}): Promise { + if (params.provider === 'local') return headLocalObject(params.key) + const config = getStorageConfig(params.context) + const head = + params.provider === 's3' + ? await import('@/lib/uploads/providers/s3/client').then(({ headS3Object }) => + headS3Object(params.key, createS3Config(config)) + ) + : params.provider === 'blob' + ? await import('@/lib/uploads/providers/blob/client').then(({ headBlobObject }) => + headBlobObject(params.key, createBlobConfig(config)) + ) + : await import('@/lib/uploads/providers/gcs/client').then(({ headGcsObject }) => + headGcsObject(params.key, createGcsConfig(config)) + ) + if (!head) return null + if (!head.contentType || !head.uploadId || !head.version) { + throw new Error(`Upload object ${params.key} is missing required provider metadata`) + } + return { + size: head.size, + contentType: head.contentType, + uploadId: head.uploadId, + version: head.version, + } +} + +export async function promoteProviderObject(params: { + provider: UploadStorageProvider + sourceKey: string + destinationKey: string + sourceVersion: string + context: StorageContext +}): Promise { + if (params.provider === 'local') { + await promoteLocalObject(params.sourceKey, params.destinationKey, params.sourceVersion) + return + } + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { promoteS3Object } = await import('@/lib/uploads/providers/s3/client') + await promoteS3Object({ + sourceKey: params.sourceKey, + destinationKey: params.destinationKey, + sourceEtag: params.sourceVersion, + customConfig: createS3Config(config), + }) + return + } + if (params.provider === 'blob') { + const { promoteBlobObject } = await import('@/lib/uploads/providers/blob/client') + await promoteBlobObject({ + sourceKey: params.sourceKey, + destinationKey: params.destinationKey, + sourceEtag: params.sourceVersion, + customConfig: createBlobConfig(config), + }) + return + } + const { promoteGcsObject } = await import('@/lib/uploads/providers/gcs/client') + await promoteGcsObject({ + sourceKey: params.sourceKey, + destinationKey: params.destinationKey, + sourceGeneration: params.sourceVersion, + customConfig: createGcsConfig(config), + }) +} + +export async function deleteProviderObjectVersion(params: { + provider: UploadStorageProvider + key: string + version: string + context: StorageContext +}): Promise { + if (params.provider === 'local') { + await deleteLocalObjectVersion(params.key, params.version) + return + } + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { deleteS3ObjectVersion } = await import('@/lib/uploads/providers/s3/client') + await deleteS3ObjectVersion({ + key: params.key, + etag: params.version, + customConfig: createS3Config(config), + }) + return + } + if (params.provider === 'blob') { + const { deleteBlobObjectVersion } = await import('@/lib/uploads/providers/blob/client') + await deleteBlobObjectVersion({ + key: params.key, + etag: params.version, + customConfig: createBlobConfig(config), + }) + return + } + const { deleteGcsObjectVersion } = await import('@/lib/uploads/providers/gcs/client') + await deleteGcsObjectVersion({ + key: params.key, + generation: params.version, + customConfig: createGcsConfig(config), + }) +} + +export async function abortProviderUpload(params: { + provider: UploadStorageProvider + method: 'put' | 'multipart' + providerUploadId: string | null + uploadId: string + stagingKey: string + context: StorageContext +}): Promise { + if (params.provider === 'local') { + await rm(localPartsDirectory(params.uploadId), { recursive: true, force: true }) + await rm(localUploadDirectory(params.uploadId), { recursive: true, force: true }) + return + } + + const config = getStorageConfig(params.context) + if (params.method === 'multipart') { + if (!params.providerUploadId) { + throw new Error(`Missing ${params.provider} multipart upload id`) + } + if (params.provider === 's3') { + const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await abortS3MultipartUpload( + params.stagingKey, + params.providerUploadId, + createS3Config(config) + ) + } else if (params.provider === 'blob') { + const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + await abortMultipartUpload(params.stagingKey, createBlobConfig(config)) + } else { + const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await abortGcsMultipartUpload( + params.stagingKey, + params.providerUploadId, + createGcsConfig(config) + ) + } + } + + if (params.provider === 's3') { + const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/client') + await deleteFromS3(params.stagingKey, createS3Config(config)) + } else if (params.provider === 'blob') { + const { deleteFromBlob } = await import('@/lib/uploads/providers/blob/client') + await deleteFromBlob(params.stagingKey, createBlobConfig(config)) + } else { + const { deleteFromGcs } = await import('@/lib/uploads/providers/gcs/client') + await deleteFromGcs(params.stagingKey, createGcsConfig(config)) + } +} + +export async function writeLocalPutObject(params: { + uploadId: string + stagingKey: string + body: ReadableStream + expectedSize: number + contentType: string + metadata: Record +}): Promise { + assertLocalStagingKey(params.stagingKey, params.uploadId) + const { Readable, Transform } = await import('node:stream') + const directory = localUploadDirectory(params.uploadId) + const destination = localObjectPath(params.stagingKey) + const temporary = join(directory, `.put-${generateId()}`) + const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` + await mkdir(dirname(destination), { recursive: true }) + let bytes = 0 + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > params.expectedSize) { + callback(new LocalUploadBodyError(`Upload exceeds ${params.expectedSize} bytes`)) + return + } + callback(null, chunk) + }, + }) + + try { + await pipeline( + Readable.fromWeb(params.body as Parameters[0]), + counter, + createWriteStream(temporary, { flags: 'wx' }) + ) + if (bytes !== params.expectedSize) { + throw new LocalUploadBodyError(`Upload has ${bytes} bytes; expected ${params.expectedSize}`) + } + await writeLocalMetadata(temporaryMetadata, { + uploadId: params.uploadId, + contentType: params.contentType, + metadata: { ...params.metadata, uploadId: params.uploadId }, + }) + await rename(temporary, destination) + try { + await rename(temporaryMetadata, localMetadataPath(params.stagingKey)) + } catch (error) { + await rm(destination, { force: true }) + throw error + } + } catch (error) { + await Promise.allSettled([ + rm(temporary, { force: true }), + rm(temporaryMetadata, { force: true }), + ]) + if (error instanceof LocalUploadBodyError) throw error + throw new Error(getErrorMessage(error, 'Failed to store PUT upload'), { cause: error }) + } +} + +export async function writeLocalMultipartPart(params: { + uploadId: string + partNumber: number + body: ReadableStream + expectedSize: number +}): Promise { + const { Readable, Transform } = await import('node:stream') + const directory = localPartsDirectory(params.uploadId) + await mkdir(directory, { recursive: true }) + const destination = localPartPath(params.uploadId, params.partNumber) + const temporary = join(directory, `.${params.partNumber}-${generateId()}.part`) + let bytes = 0 + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > params.expectedSize) { + callback( + new LocalUploadBodyError(`Part ${params.partNumber} exceeds ${params.expectedSize} bytes`) + ) + return + } + callback(null, chunk) + }, + }) + try { + await pipeline( + Readable.fromWeb(params.body as Parameters[0]), + counter, + createWriteStream(temporary, { flags: 'wx' }) + ) + if (bytes !== params.expectedSize) { + throw new LocalUploadBodyError( + `Part ${params.partNumber} has ${bytes} bytes; expected ${params.expectedSize}` + ) + } + await rename(temporary, destination) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + if (error instanceof LocalUploadBodyError) throw error + throw new Error(getErrorMessage(error, `Failed to store part ${params.partNumber}`), { + cause: error, + }) + } +} + +function localPartsDirectory(uploadId: string): string { + return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) +} + +function localUploadDirectory(uploadId: string): string { + return join(UPLOAD_DIR_SERVER, 'upload-sessions', uploadId) +} + +function localPartPath(uploadId: string, partNumber: number): string { + return join(localPartsDirectory(uploadId), `${partNumber}.part`) +} + +function localObjectPath(key: string): string { + return join(UPLOAD_DIR_SERVER, sanitizeFileKey(key)) +} + +function localMetadataPath(key: string): string { + return `${localObjectPath(key)}${LOCAL_UPLOAD_METADATA_SUFFIX}` +} + +async function assembleLocalParts( + uploadId: string, + stagingKey: string, + parts: CompletedUploadPart[], + contentType: string, + metadata: Record +): Promise { + assertLocalStagingKey(stagingKey, uploadId) + const destination = localObjectPath(stagingKey) + const temporary = join(localUploadDirectory(uploadId), `.multipart-${generateId()}`) + const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` + await mkdir(dirname(destination), { recursive: true }) + try { + for (const part of parts) { + await pipeline( + createReadStream(localPartPath(uploadId, part.partNumber)), + createWriteStream(temporary, { flags: 'a' }) + ) + } + await writeLocalMetadata(temporaryMetadata, { + uploadId, + contentType, + metadata: { ...metadata, uploadId }, + }) + await rename(temporary, destination) + try { + await rename(temporaryMetadata, localMetadataPath(stagingKey)) + } catch (error) { + await rm(destination, { force: true }) + throw error + } + await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) + } catch (error) { + await Promise.allSettled([ + rm(temporary, { force: true }), + rm(temporaryMetadata, { force: true }), + ]) + throw error + } +} + +async function headLocalObject(key: string): Promise { + const path = localObjectPath(key) + let file: Awaited> + try { + file = await stat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + const metadata = await readLocalMetadata(localMetadataPath(key)) + return { + size: file.size, + contentType: metadata.contentType, + uploadId: metadata.uploadId, + version: localVersion(file), + } +} + +async function promoteLocalObject( + sourceKey: string, + destinationKey: string, + sourceVersion: string +): Promise { + const source = localObjectPath(sourceKey) + const sourceMetadata = localMetadataPath(sourceKey) + const destination = localObjectPath(destinationKey) + const destinationMetadata = localMetadataPath(destinationKey) + const metadata = await readLocalMetadata(sourceMetadata) + await mkdir(dirname(destination), { recursive: true }) + + let createdMetadata = false + try { + await link(sourceMetadata, destinationMetadata) + createdMetadata = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + const existing = await readLocalMetadata(destinationMetadata) + if (existing.uploadId !== metadata.uploadId) throw error + } + + try { + await link(source, destination) + } catch (error) { + if (createdMetadata) await rm(destinationMetadata, { force: true }) + throw error + } + + const destinationStat = await stat(destination) + if (localVersion(destinationStat) !== sourceVersion) { + await Promise.allSettled([ + rm(destination, { force: true }), + ...(createdMetadata ? [rm(destinationMetadata, { force: true })] : []), + ]) + throw new Error('Local staging object changed during promotion') + } +} + +async function deleteLocalObjectVersion(key: string, version: string): Promise { + const path = localObjectPath(key) + let current: Awaited> + try { + current = await stat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (localVersion(current) !== version) return + await unlink(path) + await rm(localMetadataPath(key), { force: true }) + await rmdir(dirname(path)).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOTEMPTY' && error.code !== 'ENOENT') throw error + }) +} + +async function writeLocalMetadata(path: string, metadata: LocalUploadMetadata): Promise { + await writeFile(path, JSON.stringify(metadata), { encoding: 'utf8', flag: 'wx' }) +} + +async function readLocalMetadata(path: string): Promise { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')) + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + typeof (parsed as Record).uploadId !== 'string' || + typeof (parsed as Record).contentType !== 'string' || + typeof (parsed as Record).metadata !== 'object' || + (parsed as Record).metadata === null || + Array.isArray((parsed as Record).metadata) + ) { + throw new Error(`Invalid local upload metadata at ${path}`) + } + const record = parsed as Record + const metadata = record.metadata as Record + if (Object.values(metadata).some((value) => typeof value !== 'string')) { + throw new Error(`Invalid local upload metadata at ${path}`) + } + return { + uploadId: record.uploadId as string, + contentType: record.contentType as string, + metadata: metadata as Record, + } +} + +function localVersion(file: Awaited>): string { + return `${file.dev}:${file.ino}:${file.size}:${file.mtimeMs}` +} + +function assertLocalStagingKey(stagingKey: string, uploadId: string): void { + if (!stagingKey.startsWith(`upload-sessions/${uploadId}/`)) { + throw new Error('Local staging key does not belong to this upload') + } +} + +function requiredEtag(provider: 's3' | 'gcs', part: CompletedUploadPart): string { + if (!part.etag) throw new Error(`Missing etag for ${provider} part ${part.partNumber}`) + return part.etag +} diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts new file mode 100644 index 00000000000..d228c24980a --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -0,0 +1,432 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckStorageQuota, + mockCompleteMultipart, + mockCreatePutTransfer, + mockDeleteObjectVersion, + mockHeadObject, + mockInitiateMultipart, + mockPromoteObject, + mockResolveBillingContext, +} = vi.hoisted(() => ({ + mockCheckStorageQuota: vi.fn(), + mockCompleteMultipart: vi.fn(), + mockCreatePutTransfer: vi.fn(), + mockDeleteObjectVersion: vi.fn(), + mockHeadObject: vi.fn(), + mockInitiateMultipart: vi.fn(), + mockPromoteObject: vi.fn(), + mockResolveBillingContext: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuotaForBillingContext: mockCheckStorageQuota, + resolveStorageBillingContext: mockResolveBillingContext, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/final-${fileName}` + ), +})) + +vi.mock('@/lib/uploads/upload-session/cleanup', () => ({ + maybeCleanupLocalUploadArtifacts: vi.fn().mockResolvedValue({ scanned: 0, removed: 0 }), +})) + +vi.mock('@/lib/uploads/upload-session/provider', () => ({ + abortProviderUpload: vi.fn(), + completeMultipartProviderUpload: mockCompleteMultipart, + createPutProviderTransfer: mockCreatePutTransfer, + deleteProviderObjectVersion: mockDeleteObjectVersion, + getMultipartProviderPartUrls: vi.fn(), + headProviderObject: mockHeadObject, + initiateMultipartProviderUpload: mockInitiateMultipart, + promoteProviderObject: mockPromoteObject, + uploadStorageProvider: vi.fn(() => 's3'), +})) + +import { + MAX_WORKSPACE_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, +} from '@/lib/uploads/shared/types' +import { + completeUploadSession, + createUploadSession, + UPLOAD_SESSION_PUT_MAX_BYTES, + validateUploadCompletion, + verifyUploadSessionToken, +} from '@/lib/uploads/upload-session/service' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +describe('upload sessions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveBillingContext.mockResolvedValue({ workspaceId: WORKSPACE_ID }) + mockCheckStorageQuota.mockResolvedValue({ allowed: true }) + mockCreatePutTransfer.mockResolvedValue({ + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/octet-stream' }, + }) + mockInitiateMultipart.mockResolvedValue({ + provider: 's3', + providerUploadId: 'provider-upload-1', + }) + }) + + it('selects PUT at exactly 50 MiB', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES) + + expect(created.transfer.method).toBe('put') + expect(created.method).toBe('put') + expect(created.partSize).toBeNull() + expect(created.partCount).toBeNull() + expect(mockInitiateMultipart).not.toHaveBeenCalled() + }) + + it('creates a PUT session for an empty workspace file', async () => { + const created = await createWorkspaceUpload(0) + + expect(created).toMatchObject({ + purpose: 'workspace_file', + fileSize: 0, + method: 'put', + transfer: { method: 'put' }, + }) + expect(verifyUploadSessionToken(created.uploadToken)).toMatchObject({ + purpose: 'workspace_file', + fileSize: 0, + method: 'put', + }) + }) + + it('rejects an empty upload for non-workspace-file purposes', async () => { + await expect( + createUploadSession({ + id: 'empty-attachment', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'empty.txt', + contentType: 'text/plain', + fileSize: 0, + }) + ).rejects.toThrow('fileSize must be a positive integer') + }) + + it('rejects a negative workspace-file size', async () => { + await expect(createWorkspaceUpload(-1)).rejects.toThrow( + 'fileSize must be a non-negative integer' + ) + }) + + it('selects multipart at 50 MiB plus one byte', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + + expect(created.transfer).toMatchObject({ method: 'multipart', partCount: 7 }) + expect(created.method).toBe('multipart') + expect(mockInitiateMultipart).toHaveBeenCalledOnce() + }) + + it('binds purpose scope, staging, destination, method, and identity into the token', async () => { + const created = await createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + + expect(verifyUploadSessionToken(created.uploadToken)).toMatchObject({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + method: 'put', + storageContext: 'knowledge-base', + storageProvider: 's3', + stagingKey: 'upload-sessions/upload-1/guide.pdf', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + }) + + it('quota-gates durable files while exempting retention-scoped attachments', async () => { + await createWorkspaceUpload(1024) + expect(mockResolveBillingContext).toHaveBeenCalledOnce() + expect(mockCheckStorageQuota).toHaveBeenCalledOnce() + + await createUploadSession({ + id: 'execution-upload', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', + purpose: 'execution_attachment', + fileName: 'result.txt', + contentType: 'text/plain', + fileSize: 1024, + }) + expect(mockResolveBillingContext).toHaveBeenCalledOnce() + expect(mockCheckStorageQuota).toHaveBeenCalledOnce() + + await createUploadSession({ + id: 'mothership-upload', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'prompt.txt', + contentType: 'text/plain', + fileSize: 1024, + }) + expect(mockResolveBillingContext).toHaveBeenCalledOnce() + expect(mockCheckStorageQuota).toHaveBeenCalledOnce() + }) + + it('preserves the 5 GiB mothership limit while bounding execution attachments at 100 MiB', async () => { + await expect( + createUploadSession({ + id: 'mothership-upload', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'archive.zip', + contentType: 'application/zip', + fileSize: MAX_WORKSPACE_FILE_SIZE, + }) + ).resolves.toMatchObject({ + method: 'multipart', + transfer: { method: 'multipart', partCount: 640 }, + }) + + await expect( + createUploadSession({ + id: 'oversized-mothership-upload', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'archive.zip', + contentType: 'application/zip', + fileSize: MAX_WORKSPACE_FILE_SIZE + 1, + }) + ).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes`) + + await expect( + createUploadSession({ + id: 'execution-upload', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', + purpose: 'execution_attachment', + fileName: 'result.txt', + contentType: 'text/plain', + fileSize: MAX_WORKSPACE_FORMDATA_FILE_SIZE + 1, + }) + ).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes`) + }) + + it('validates PUT completion input independently of finalization', async () => { + const created = await createWorkspaceUpload(1024) + + expect(validateUploadCompletion(created, {})).toEqual([]) + expect(() => validateUploadCompletion(created, { parts: [] })).toThrow( + 'PUT completion must not include parts' + ) + }) + + it('requires every multipart part and cloud ETag before finalization or replay', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const parts = Array.from({ length: created.partCount ?? 0 }, (_, index) => ({ + partNumber: index + 1, + etag: `etag-${index + 1}`, + })) + + expect(validateUploadCompletion(created, { parts })).toBe(parts) + expect(() => validateUploadCompletion(created, {})).toThrow( + 'Multipart completion requires parts' + ) + expect(() => + validateUploadCompletion(created, { parts: parts.map(({ partNumber }) => ({ partNumber })) }) + ).toThrow('etag is required for s3 part 1') + }) + + it('resumes after multipart assembly without consuming the provider upload twice', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1', completedFileId: 'file-1' }), + }) + ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: false }) + + expect(mockCompleteMultipart).not.toHaveBeenCalled() + expect(mockPromoteObject).toHaveBeenCalledOnce() + }) + + it('completes multipart at staging when no assembled object exists yet', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + + expect(mockCompleteMultipart).toHaveBeenCalledOnce() + expect(mockCompleteMultipart).toHaveBeenCalledWith( + expect.objectContaining({ stagingKey: created.stagingKey }) + ) + }) + + it('recovers when another completion consumes the provider upload concurrently', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockCompleteMultipart.mockRejectedValueOnce(new Error('NoSuchUpload')) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: false }) + + expect(mockCompleteMultipart).toHaveBeenCalledOnce() + expect(mockPromoteObject).toHaveBeenCalledOnce() + }) + + it('preserves the provider completion error when no staged object was created', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + mockCompleteMultipart.mockRejectedValueOnce(new Error('NoSuchUpload')) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + ).rejects.toThrow('NoSuchUpload') + + expect(mockPromoteObject).not.toHaveBeenCalled() + }) + + it('rejects a mismatched staged multipart object before provider completion', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + mockHeadObject.mockResolvedValueOnce(null).mockResolvedValueOnce({ + ...objectIdentity(created.id, created.fileSize, created.contentType), + uploadId: 'another-upload', + }) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + ).rejects.toThrow('Uploaded object belongs to another upload') + + expect(mockCompleteMultipart).not.toHaveBeenCalled() + expect(mockPromoteObject).not.toHaveBeenCalled() + }) + + it('retains staging when finalization fails after promotion', async () => { + const created = await createWorkspaceUpload(1024) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: {}, + finalize: async () => { + throw new Error('database unavailable') + }, + }) + ).rejects.toThrow('database unavailable') + + expect(mockPromoteObject).toHaveBeenCalledOnce() + expect(mockDeleteObjectVersion).not.toHaveBeenCalled() + }) + + it('retries finalization from an exact final object, then removes staging conditionally', async () => { + const created = await createWorkspaceUpload(1024) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject.mockResolvedValueOnce(identity).mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: {}, + finalize: async () => ({ value: 'file-1', completedFileId: 'file-1' }), + }) + ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: true }) + + expect(mockPromoteObject).not.toHaveBeenCalled() + expect(mockDeleteObjectVersion).toHaveBeenCalledWith( + expect.objectContaining({ key: created.stagingKey, version: 'version-1' }) + ) + }) +}) + +async function createWorkspaceUpload(fileSize: number) { + return createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + fileName: 'file.bin', + contentType: 'application/octet-stream', + fileSize, + }) +} + +function objectIdentity(uploadId: string, size: number, contentType: string) { + return { uploadId, size, contentType, version: 'version-1' } +} + +function completedParts(partCount: number | null) { + return Array.from({ length: partCount ?? 0 }, (_, index) => ({ + partNumber: index + 1, + etag: `etag-${index + 1}`, + })) +} diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts new file mode 100644 index 00000000000..048ff658c11 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -0,0 +1,792 @@ +import { generateId } from '@sim/utils/id' +import { + checkStorageQuotaForBillingContext, + resolveStorageBillingContext, +} from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' +import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' +import { + signUploadToken, + type UploadSessionPurpose, + type UploadStorageProvider, + type UploadTokenPayload, + type UploadTransferMethod, + verifyUploadToken, +} from '@/lib/uploads/core/upload-token' +import { + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, + type StorageContext, +} from '@/lib/uploads/shared/types' +import { maybeCleanupLocalUploadArtifacts } from '@/lib/uploads/upload-session/cleanup' +import { + abortProviderUpload, + type CompletedUploadPart, + completeMultipartProviderUpload, + createPutProviderTransfer, + deleteProviderObjectVersion, + getMultipartProviderPartUrls, + headProviderObject, + initiateMultipartProviderUpload, + promoteProviderObject, + type UploadPartUrl, + uploadStorageProvider, +} from '@/lib/uploads/upload-session/provider' +import { sanitizeFileName } from '@/executor/constants' + +export const UPLOAD_SESSION_PUT_MAX_BYTES = 50 * 1024 * 1024 +export const UPLOAD_SESSION_PART_SIZE = 8 * 1024 * 1024 +export const UPLOAD_SESSION_MAX_PART_URLS = 100 +export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000 +export const UPLOAD_SESSION_ASSET_MAX_BYTES = 5 * 1024 * 1024 + +export type { UploadSessionPurpose, UploadTransferMethod } + +export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' + +export type UploadSessionTransfer = + | { method: 'put'; url: string; headers: Record } + | { method: 'multipart'; partSize: number; partCount: number } + +export interface UploadSessionRecord { + id: string + workspaceId: string | null + userId: string + knowledgeBaseId: string | null + workflowId: string | null + executionId: string | null + purpose: UploadSessionPurpose + method: UploadTransferMethod + storageContext: StorageContext + /** Canonical destination key retained for existing domain finalizers. */ + storageKey: string + finalKey: string + stagingKey: string + storageProvider: UploadStorageProvider + providerUploadId: string | null + fileName: string + contentType: string + fileSize: number + partSize: number | null + partCount: number | null + status: UploadSessionStatus + metadata: Record + uploadToken: string + createdAt: Date + expiresAt: Date + completedFileId: string | null + error: string | null + completedAt: Date | null + updatedAt: Date +} + +export interface CreatedUploadSession extends UploadSessionRecord { + transfer: UploadSessionTransfer +} + +export type UploadCompletion = { parts?: never } | { parts: CompletedUploadPart[] } + +export class UploadSessionError extends OrchestrationError { + constructor( + code: 'validation' | 'not_found' | 'forbidden' | 'conflict' | 'payload_too_large' | 'internal', + message: string + ) { + super(code, message) + this.name = 'UploadSessionError' + } +} + +interface CreateUploadSessionBaseParams { + id?: string + userId: string + fileName: string + contentType: string + fileSize: number + metadata?: Record + localOrigin?: string +} + +export type CreateUploadSessionParams = CreateUploadSessionBaseParams & + ( + | { purpose: 'workspace_file' | 'table_import'; workspaceId: string } + | { purpose: 'knowledge_document'; workspaceId: string; knowledgeBaseId: string } + | { purpose: 'profile_picture'; workspaceId?: null } + | { purpose: 'workspace_logo' | 'mothership_attachment'; workspaceId: string } + | { + purpose: 'execution_attachment' + workspaceId: string + workflowId: string + executionId: string + } + ) + +export async function createUploadSession( + params: CreateUploadSessionParams +): Promise { + validateFile(params) + const id = params.id ?? generateId() + const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId + const { storageContext, finalKey } = resolveUploadStorage(params, id) + const stagingKey = `upload-sessions/${id}/${sanitizeFileName(params.fileName)}` + const method: UploadTransferMethod = + params.fileSize <= UPLOAD_SESSION_PUT_MAX_BYTES ? 'put' : 'multipart' + const partSize = method === 'multipart' ? UPLOAD_SESSION_PART_SIZE : null + const partCount = + method === 'multipart' ? Math.ceil(params.fileSize / UPLOAD_SESSION_PART_SIZE) : null + + if (requiresStorageQuota(params.purpose)) { + if (!workspaceId) throw new Error(`${params.purpose} upload is missing workspaceId`) + const billingContext = await resolveStorageBillingContext(workspaceId) + const quota = await checkStorageQuotaForBillingContext(billingContext, params.fileSize) + if (!quota.allowed) { + throw new UploadSessionError('payload_too_large', quota.error ?? 'Storage limit exceeded') + } + } + + const provider = uploadStorageProvider() + if (provider === 'local') await maybeCleanupLocalUploadArtifacts() + const objectMetadata = uploadSessionObjectMetadata({ + id, + userId: params.userId, + workspaceId, + purpose: params.purpose, + fileName: params.fileName, + knowledgeBaseId: params.purpose === 'knowledge_document' ? params.knowledgeBaseId : null, + workflowId: params.purpose === 'execution_attachment' ? params.workflowId : null, + executionId: params.purpose === 'execution_attachment' ? params.executionId : null, + }) + const initiated = + method === 'multipart' + ? await initiateMultipartProviderUpload({ + stagingKey, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + uploadId: id, + metadata: objectMetadata, + }) + : { provider, providerUploadId: null } + if (initiated.provider !== provider) { + throw new Error('Storage provider changed while creating upload session') + } + + const createdAt = new Date() + const expiresAt = new Date(createdAt.getTime() + UPLOAD_SESSION_TTL_MS) + const metadata = params.metadata ?? {} + const tokenPayload = createUploadTokenPayload({ + params, + id, + workspaceId, + storageContext, + finalKey, + stagingKey, + provider, + providerUploadId: initiated.providerUploadId, + method, + partSize, + partCount, + metadata, + createdAt, + expiresAt, + }) + const uploadToken = signUploadToken(tokenPayload) + const transfer: UploadSessionTransfer = + method === 'put' + ? await createPutProviderTransfer({ + provider, + stagingKey, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + uploadId: id, + uploadToken, + localOrigin: params.localOrigin, + expiresAt, + metadata: objectMetadata, + }) + : { method, partSize: requireNumber(partSize), partCount: requireNumber(partCount) } + + return sessionFromPayload(tokenPayload, uploadToken, transfer) +} + +export function getOwnedUploadSession(params: { + uploadId: string + uploadToken: string + userId?: string + workspaceId?: string | null + purpose?: UploadSessionPurpose + knowledgeBaseId?: string + workflowId?: string + executionId?: string +}): UploadSessionRecord { + const session = verifyUploadSessionToken(params.uploadToken) + if (session.id !== params.uploadId) throw uploadNotFound() + if (params.userId !== undefined && session.userId !== params.userId) throw uploadNotFound() + if (params.workspaceId !== undefined && session.workspaceId !== params.workspaceId) { + throw uploadNotFound() + } + if (params.purpose !== undefined && session.purpose !== params.purpose) throw uploadNotFound() + if (params.knowledgeBaseId !== undefined && session.knowledgeBaseId !== params.knowledgeBaseId) { + throw uploadNotFound() + } + if (params.workflowId !== undefined && session.workflowId !== params.workflowId) { + throw uploadNotFound() + } + if (params.executionId !== undefined && session.executionId !== params.executionId) { + throw uploadNotFound() + } + return session +} + +export function verifyUploadSessionToken(uploadToken: string): UploadSessionRecord { + const verified = verifyUploadToken(uploadToken) + if (!verified.valid) throw new UploadSessionError('forbidden', 'Invalid or expired upload token') + return sessionFromPayload(verified.payload, uploadToken) +} + +export async function createUploadPartUrls(params: { + session: UploadSessionRecord + partNumbers: number[] + localOrigin: string +}): Promise { + assertUploadable(params.session) + if (params.session.method !== 'multipart' || !params.session.partCount) { + throw new UploadSessionError('conflict', 'PUT upload sessions do not have multipart parts') + } + const unique = new Set(params.partNumbers) + if (unique.size !== params.partNumbers.length) { + throw new UploadSessionError('validation', 'partNumbers must not contain duplicates') + } + if (params.partNumbers.length === 0 || params.partNumbers.length > UPLOAD_SESSION_MAX_PART_URLS) { + throw new UploadSessionError( + 'validation', + `partNumbers must contain between 1 and ${UPLOAD_SESSION_MAX_PART_URLS} entries` + ) + } + for (const partNumber of params.partNumbers) { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > params.session.partCount) { + throw new UploadSessionError( + 'validation', + `partNumber must be between 1 and ${params.session.partCount}` + ) + } + } + + return getMultipartProviderPartUrls({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + stagingKey: params.session.stagingKey, + context: params.session.storageContext, + partNumbers: params.partNumbers, + localUrl: (partNumber) => + `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(params.session.uploadToken)}`, + }) +} + +export async function completeUploadSession(params: { + session: UploadSessionRecord + completion: UploadCompletion + finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> +}): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { + assertUploadable(params.session) + const parts = validateUploadCompletion(params.session, params.completion) + let existingFinal = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.finalKey, + context: params.session.storageContext, + }) + const alreadyCompleted = existingFinal !== null + if (existingFinal) assertObjectIdentity(params.session, existingFinal, 'Final') + + if (!existingFinal) { + let staging = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.stagingKey, + context: params.session.storageContext, + }) + if (staging) assertObjectIdentity(params.session, staging, 'Uploaded') + + if (!staging && params.session.method === 'multipart') { + try { + await completeMultipartProviderUpload({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + uploadId: params.session.id, + stagingKey: params.session.stagingKey, + contentType: params.session.contentType, + context: params.session.storageContext, + parts, + metadata: uploadSessionObjectMetadata(params.session), + }) + } catch (completionError) { + staging = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.stagingKey, + context: params.session.storageContext, + }) + if (!staging) throw completionError + assertObjectIdentity(params.session, staging, 'Uploaded') + } + } + + if (!staging) { + staging = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.stagingKey, + context: params.session.storageContext, + }) + } + + if (!staging) throw new UploadSessionError('conflict', 'Uploaded staging object not found') + assertObjectIdentity(params.session, staging, 'Uploaded') + + try { + await promoteProviderObject({ + provider: params.session.storageProvider, + sourceKey: params.session.stagingKey, + destinationKey: params.session.finalKey, + sourceVersion: staging.version, + context: params.session.storageContext, + }) + } catch (error) { + existingFinal = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.finalKey, + context: params.session.storageContext, + }) + if (!existingFinal) throw error + assertObjectIdentity(params.session, existingFinal, 'Final') + } + + const promoted = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.finalKey, + context: params.session.storageContext, + }) + if (!promoted) throw new Error('Promoted upload object not found') + assertObjectIdentity(params.session, promoted, 'Promoted') + } + + const finalized = await params.finalize(params.session) + await cleanupStagingObject(params.session) + const completedAt = new Date() + return { + session: { + ...params.session, + status: 'completed', + completedFileId: finalized.completedFileId ?? null, + completedAt, + updatedAt: completedAt, + }, + value: finalized.value, + alreadyCompleted, + } +} + +export async function abortUploadSession( + session: UploadSessionRecord +): Promise { + assertUploadable(session) + await abortProviderUpload({ + provider: session.storageProvider, + method: session.method, + providerUploadId: session.providerUploadId, + uploadId: session.id, + stagingKey: session.stagingKey, + context: session.storageContext, + }) + const completedAt = new Date() + return { ...session, status: 'aborted', completedAt, updatedAt: completedAt } +} + +export function expectedUploadPartSize(session: UploadSessionRecord, partNumber: number): number { + if (session.method !== 'multipart' || !session.partSize || !session.partCount) { + throw new UploadSessionError('conflict', 'PUT upload sessions do not have multipart parts') + } + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > session.partCount) { + throw new UploadSessionError('validation', 'Invalid upload part number') + } + if (partNumber < session.partCount) return session.partSize + return session.fileSize - session.partSize * (session.partCount - 1) +} + +export function uploadSessionObjectMetadata( + session: Pick< + UploadSessionRecord, + | 'id' + | 'userId' + | 'workspaceId' + | 'purpose' + | 'fileName' + | 'knowledgeBaseId' + | 'workflowId' + | 'executionId' + > +): Record { + return { + uploadId: session.id, + userId: session.userId, + originalName: session.fileName, + purpose: session.purpose, + ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + ...(session.knowledgeBaseId ? { knowledgeBaseId: session.knowledgeBaseId } : {}), + ...(session.workflowId ? { workflowId: session.workflowId } : {}), + ...(session.executionId ? { executionId: session.executionId } : {}), + } +} + +function sessionFromPayload( + payload: UploadTokenPayload, + uploadToken: string, + transfer: UploadSessionTransfer +): CreatedUploadSession +function sessionFromPayload( + payload: UploadTokenPayload, + uploadToken: string, + transfer?: undefined +): UploadSessionRecord +function sessionFromPayload( + payload: UploadTokenPayload, + uploadToken: string, + transfer?: UploadSessionTransfer +): CreatedUploadSession | UploadSessionRecord { + const createdAt = new Date(payload.createdAt) + const expiresAt = new Date(payload.expiresAt) + const session: UploadSessionRecord = { + id: payload.uploadId, + workspaceId: payload.workspaceId, + userId: payload.actorId, + knowledgeBaseId: payload.purpose === 'knowledge_document' ? payload.knowledgeBaseId : null, + workflowId: payload.purpose === 'execution_attachment' ? payload.workflowId : null, + executionId: payload.purpose === 'execution_attachment' ? payload.executionId : null, + purpose: payload.purpose, + method: payload.method, + storageContext: payload.context, + storageKey: payload.finalKey, + finalKey: payload.finalKey, + stagingKey: payload.stagingKey, + storageProvider: payload.provider, + providerUploadId: payload.providerUploadId, + fileName: payload.fileName, + contentType: payload.contentType, + fileSize: payload.fileSize, + partSize: payload.method === 'multipart' ? payload.partSize : null, + partCount: payload.method === 'multipart' ? payload.partCount : null, + status: 'uploading', + metadata: payload.metadata, + uploadToken, + createdAt, + expiresAt, + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date(), + } + return transfer ? { ...session, transfer } : session +} + +function createUploadTokenPayload(params: { + params: CreateUploadSessionParams + id: string + workspaceId: string | null + storageContext: StorageContext + finalKey: string + stagingKey: string + provider: UploadStorageProvider + providerUploadId: string | null + method: UploadTransferMethod + partSize: number | null + partCount: number | null + metadata: Record + createdAt: Date + expiresAt: Date +}): UploadTokenPayload { + const base = { + uploadId: params.id, + actorId: params.params.userId, + finalKey: params.finalKey, + stagingKey: params.stagingKey, + provider: params.provider, + fileName: params.params.fileName, + contentType: params.params.contentType, + fileSize: params.params.fileSize, + metadata: params.metadata, + createdAt: params.createdAt.toISOString(), + expiresAt: params.expiresAt.toISOString(), + } + const transfer = + params.method === 'put' + ? ({ method: 'put', providerUploadId: null } as const) + : ({ + method: 'multipart', + providerUploadId: params.providerUploadId, + partSize: requireNumber(params.partSize), + partCount: requireNumber(params.partCount), + } as const) + + switch (params.params.purpose) { + case 'workspace_file': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'workspace', + } + case 'table_import': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'table-import', + } + case 'knowledge_document': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'knowledge-base', + knowledgeBaseId: params.params.knowledgeBaseId, + } + case 'profile_picture': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: null, + context: 'profile-pictures', + } + case 'workspace_logo': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'workspace-logos', + } + case 'mothership_attachment': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'mothership', + } + case 'execution_attachment': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'execution', + workflowId: params.params.workflowId, + executionId: params.params.executionId, + } + } +} + +function assertUploadable(session: UploadSessionRecord): void { + if (session.status !== 'uploading') { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + if (session.expiresAt.getTime() <= Date.now()) { + throw new UploadSessionError('conflict', 'Upload session has expired') + } +} + +/** + * Validates method-specific completion input before any idempotent replay shortcut is taken. + * Callers that can return an already-completed resource must run this first as well. + */ +export function validateUploadCompletion( + session: UploadSessionRecord, + completion: UploadCompletion +): CompletedUploadPart[] { + if (session.method === 'put') { + if ('parts' in completion) { + throw new UploadSessionError('validation', 'PUT completion must not include parts') + } + return [] + } + if (!('parts' in completion) || !completion.parts) { + throw new UploadSessionError('validation', 'Multipart completion requires parts') + } + validateCompletedParts(session, completion.parts) + return completion.parts +} + +function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { + if (!session.partCount) throw new Error('Multipart upload is missing partCount') + if (parts.length !== session.partCount) { + throw new UploadSessionError( + 'validation', + `Expected ${session.partCount} completed parts; received ${parts.length}` + ) + } + const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) + for (let index = 0; index < sorted.length; index++) { + if (sorted[index].partNumber !== index + 1) { + throw new UploadSessionError( + 'validation', + 'Completed parts must contain every part exactly once' + ) + } + if ( + (session.storageProvider === 's3' || session.storageProvider === 'gcs') && + !sorted[index].etag + ) { + throw new UploadSessionError( + 'validation', + `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` + ) + } + } +} + +function assertObjectIdentity( + session: UploadSessionRecord, + object: { size: number; contentType: string; uploadId: string }, + label: string +): void { + if (object.uploadId !== session.id) { + throw new UploadSessionError('conflict', `${label} object belongs to another upload`) + } + if (object.size !== session.fileSize) { + throw new UploadSessionError( + 'conflict', + `${label} object has ${object.size} bytes; expected ${session.fileSize}` + ) + } + if (object.contentType !== session.contentType) { + throw new UploadSessionError( + 'conflict', + `${label} object has content type ${object.contentType}; expected ${session.contentType}` + ) + } +} + +async function cleanupStagingObject(session: UploadSessionRecord): Promise { + const staging = await headProviderObject({ + provider: session.storageProvider, + key: session.stagingKey, + context: session.storageContext, + }) + if (!staging) return + assertObjectIdentity(session, staging, 'Staging') + await deleteProviderObjectVersion({ + provider: session.storageProvider, + key: session.stagingKey, + version: staging.version, + context: session.storageContext, + }) +} + +function validateFile(params: CreateUploadSessionParams): void { + if (!params.fileName.trim()) { + throw new UploadSessionError('validation', 'fileName must not be empty') + } + if (!params.contentType.trim()) { + throw new UploadSessionError('validation', 'contentType must not be empty') + } + const minimum = params.purpose === 'workspace_file' ? 0 : 1 + if (!Number.isSafeInteger(params.fileSize) || params.fileSize < minimum) { + const range = minimum === 0 ? 'a non-negative integer' : 'a positive integer' + throw new UploadSessionError('validation', `fileSize must be ${range}`) + } + const maximum = maximumFileSize(params.purpose) + if (params.fileSize > maximum) { + throw new UploadSessionError('validation', `File size exceeds maximum of ${maximum} bytes`) + } + if (params.purpose !== 'profile_picture' && !params.workspaceId.trim()) { + throw new UploadSessionError('validation', 'workspaceId must not be empty') + } + if (params.purpose === 'knowledge_document' && !params.knowledgeBaseId.trim()) { + throw new UploadSessionError('validation', 'knowledgeBaseId must not be empty') + } + if ( + params.purpose === 'execution_attachment' && + (!params.workflowId.trim() || !params.executionId.trim()) + ) { + throw new UploadSessionError('validation', 'workflowId and executionId must not be empty') + } +} + +function maximumFileSize(purpose: UploadSessionPurpose): number { + if (purpose === 'knowledge_document') return MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + if (purpose === 'profile_picture' || purpose === 'workspace_logo') { + return UPLOAD_SESSION_ASSET_MAX_BYTES + } + if (purpose === 'execution_attachment') { + return MAX_WORKSPACE_FORMDATA_FILE_SIZE + } + return MAX_WORKSPACE_FILE_SIZE +} + +function requiresStorageQuota(purpose: UploadSessionPurpose): boolean { + return purpose === 'workspace_file' || purpose === 'knowledge_document' +} + +function resolveUploadStorage( + params: CreateUploadSessionParams, + id: string +): { storageContext: StorageContext; finalKey: string } { + switch (params.purpose) { + case 'workspace_file': + return { + storageContext: 'workspace', + finalKey: generateWorkspaceFileKey(params.workspaceId, params.fileName), + } + case 'table_import': + return { + storageContext: 'table-import', + finalKey: `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}`, + } + case 'knowledge_document': + return { + storageContext: 'knowledge-base', + finalKey: generateKnowledgeBaseFileKey(params.fileName), + } + case 'profile_picture': + return { + storageContext: 'profile-pictures', + finalKey: `profile-pictures/${id}-${sanitizeFileName(params.fileName)}`, + } + case 'workspace_logo': + return { + storageContext: 'workspace-logos', + finalKey: `workspace-logos/${params.workspaceId}/${id}-${sanitizeFileName(params.fileName)}`, + } + case 'mothership_attachment': + return { + storageContext: 'mothership', + finalKey: generateWorkspaceFileKey(params.workspaceId, params.fileName), + } + case 'execution_attachment': + return { + storageContext: 'execution', + finalKey: generateExecutionFileKey( + { + workspaceId: params.workspaceId, + workflowId: params.workflowId, + executionId: params.executionId, + }, + `${id}-${params.fileName}` + ), + } + } +} + +function uploadNotFound(): UploadSessionError { + return new UploadSessionError('not_found', 'Upload session not found') +} + +function requireNumber(value: number | null): number { + if (value === null) throw new Error('Multipart upload geometry is missing') + return value +} diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 8fbccef1b43..32e2ba8bf3c 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import { and, desc, eq, isNull, or } from 'drizzle-orm' +import { and, type Column, desc, eq, isNull, or } from 'drizzle-orm' +import type { V2CustomToolSortBy } from '@/lib/api/contracts/v2/custom-tools' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('CustomToolsOperations') @@ -128,6 +131,109 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st .orderBy(desc(customTools.createdAt)) } +/** + * Workspace-scoped reads and deletes. + * + * The functions above tolerate legacy personal tools (`workspace_id IS NULL`, + * owned by one user) alongside workspace ones. The public API is workspace- + * scoped in every direction, so it uses these instead — a caller holding a + * workspace key must never reach another user's personal tool. + */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so tools sharing a timestamp still come + * back in a stable order. + */ +const CUSTOM_TOOL_SORTS = { + title: [customTools.title, customTools.id], + createdAt: [customTools.createdAt, customTools.id], + updatedAt: [customTools.updatedAt, customTools.id], +} satisfies Record + +export async function listWorkspaceCustomTools(params: { + workspaceId: string + /** Case-insensitive substring match on the tool title. */ + search?: string + sortBy?: V2CustomToolSortBy + sortOrder?: V2SortOrder +}) { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params + return db + .select() + .from(customTools) + .where( + and( + eq(customTools.workspaceId, params.workspaceId), + searchFilter(customTools.title, params.search) + ) + ) + .orderBy(...listOrderBy(CUSTOM_TOOL_SORTS[sortBy], sortOrder)) +} + +export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { + const [row] = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .limit(1) + return row ?? null +} + +/** Titles are unique per workspace (`custom_tools_workspace_title_unique`). */ +export async function getWorkspaceCustomToolByTitle(params: { + workspaceId: string + title: string +}) { + const [row] = await db + .select() + .from(customTools) + .where( + and(eq(customTools.workspaceId, params.workspaceId), eq(customTools.title, params.title)) + ) + .limit(1) + return row ?? null +} + +/** + * Updates a workspace tool in place, returning the updated row or null when the + * id no longer resolves in that workspace. + * + * Deliberately not `upsertCustomTools`: that treats an unresolvable id as a + * create and inserts under a *new* id, so a tool deleted concurrently with an + * edit would be silently re-created as an orphan under a different id while the + * caller's follow-up read of the original id 404s. + */ +export async function updateWorkspaceCustomTool(params: { + workspaceId: string + toolId: string + title: string + schema: unknown + code: string +}) { + const [row] = await db + .update(customTools) + .set({ + title: params.title, + schema: params.schema, + code: params.code, + updatedAt: new Date(), + }) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning() + return row ?? null +} + +export async function deleteWorkspaceCustomTool(params: { + workspaceId: string + toolId: string +}): Promise { + const deleted = await db + .delete(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning({ id: customTools.id }) + return deleted.length > 0 +} + export async function getCustomToolById(params: { toolId: string userId: string diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts new file mode 100644 index 00000000000..949c98be1dc --- /dev/null +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -0,0 +1,195 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' +import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' +import type { CoreTriggerType } from '@/stores/logs/filters/types' + +const logger = createLogger('WorkflowEnqueueExecution') + +const ASYNC_ENQUEUE_ATTEMPTS = 2 +export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' + +export interface EnqueueWorkflowExecutionParams { + requestId: string + workflowId: string + userId: string + billingAttribution: BillingAttributionSnapshot + workspaceId: string + input: unknown + triggerType: CoreTriggerType + executionId: string + callChain?: string[] +} + +/** + * Outcome of an async enqueue attempt. Slot/claim semantics are encoded here, + * not in HTTP statuses (which are ambiguous — a 503 can mean five different + * things on the execute surface): + * - `queued`: the job holds the execution slot (`admissionCompleted: true`) and + * the execution-id claim must be RETAINED — the worker writes the durable log + * row under that id. + * - `rejected`: the queue definitively refused; the slot was released here and + * the claim must be released by the caller. + * - `ambiguous`: acceptance is unknown — a job may exist, so NEITHER the slot + * nor the claim may be released (releasing would double-free under a live + * job); the slot leaks to TTL only if the job genuinely never landed. + */ +export type EnqueueWorkflowExecutionResult = + | { outcome: 'queued'; jobId: string; executionId: string; retainExecutionClaim: true } + | { outcome: 'rejected'; executionId: string; retainExecutionClaim: false } + | { outcome: 'ambiguous'; executionId: string; retainExecutionClaim: true } + +/** + * Enqueues an async workflow execution. The caller must have already admitted, + * claimed the execution id, and reserved the execution slot (via + * `preprocessExecution`); the enqueued job inherits that reservation. + * Shared by the v1 and v2 execute routes so retry, acceptance classification, + * and inline-execution dispatch can never drift between surfaces. + */ +export async function enqueueWorkflowExecution( + params: EnqueueWorkflowExecutionParams +): Promise { + const { + requestId, + workflowId, + userId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + callChain, + } = params + const asyncLogger = logger.withMetadata({ + requestId, + workflowId, + workspaceId, + userId, + executionId, + }) + + const correlation = { + executionId, + requestId, + source: 'workflow' as const, + workflowId, + triggerType, + } + + const payload: WorkflowExecutionPayload = { + workflowId, + userId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + requestId, + correlation, + callChain, + executionMode: 'async', + admissionCompleted: true, + } + + let jobQueue: Awaited> + try { + jobQueue = await getJobQueue() + } catch (error) { + asyncLogger.error('Failed to initialize async execution queue', { + error: toError(error).message, + }) + await releaseExecutionSlot(executionId) + return { outcome: 'rejected', executionId, retainExecutionClaim: false } + } + + const deterministicJobId = `${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}` + const enqueueOptions = { + jobId: deterministicJobId, + metadata: { workflowId, workspaceId, userId, correlation }, + } + let jobId: string | undefined + let enqueueError: unknown + let acceptanceCouldBeUnknown = false + + for (let attempt = 1; attempt <= ASYNC_ENQUEUE_ATTEMPTS; attempt++) { + try { + jobId = await jobQueue.enqueue('workflow-execution', payload, enqueueOptions) + enqueueError = undefined + break + } catch (error) { + enqueueError = error + const classifiedError = isAsyncJobEnqueueError(error) ? error : undefined + const attemptAcceptance = classifiedError?.acceptance ?? 'unknown' + acceptanceCouldBeUnknown ||= attemptAcceptance === 'unknown' + asyncLogger.warn('Async workflow enqueue attempt failed', { + acceptance: attemptAcceptance, + attempt, + error: toError(error).message, + jobId: deterministicJobId, + }) + if (classifiedError?.retryable === false || attempt === ASYNC_ENQUEUE_ATTEMPTS) { + break + } + } + } + + if (!jobId) { + const acceptance = acceptanceCouldBeUnknown + ? 'unknown' + : isAsyncJobEnqueueError(enqueueError) + ? enqueueError.acceptance + : 'unknown' + asyncLogger.error('Failed to queue async execution', { + acceptance, + error: toError(enqueueError).message, + jobId: deterministicJobId, + }) + + if (acceptance === 'rejected') { + await releaseExecutionSlot(executionId) + return { outcome: 'rejected', executionId, retainExecutionClaim: false } + } + + return { outcome: 'ambiguous', executionId, retainExecutionClaim: true } + } + + asyncLogger.info('Queued async workflow execution', { jobId }) + + if (shouldExecuteInline()) { + void (async () => { + let workerOwnsReservation = false + try { + await jobQueue.startJob(jobId) + workerOwnsReservation = true + const output = await executeWorkflowJob(payload) + await jobQueue.completeJob(jobId, output) + } catch (error) { + const errorMessage = toError(error).message + asyncLogger.error('Async workflow execution failed', { + jobId, + error: errorMessage, + }) + /** + * Before worker ownership transfers, no LoggingSession exists to + * release the route's reservation. + */ + if (!workerOwnsReservation) { + await releaseExecutionSlot(executionId) + } + try { + await jobQueue.markJobFailed(jobId, errorMessage) + } catch (markFailedError) { + asyncLogger.error('Failed to mark job as failed', { + jobId, + error: toError(markFailedError).message, + }) + } + } + })() + } + + return { outcome: 'queued', jobId, executionId, retainExecutionClaim: true } +} diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts new file mode 100644 index 00000000000..d48d84ae722 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -0,0 +1,807 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId, isValidUuid } from '@sim/utils/id' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { validateCallChain } from '@/lib/execution/call-chain' +import { processInputFileFields } from '@/lib/execution/files' +import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { MAX_MCP_WORKFLOW_RESPONSE_BYTES } from '@/lib/mcp/constants' +import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution' +import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' +import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' +import { + claimExecutionId, + type ExecutionIdClaim, + hasDurableExecutionOwner, + releaseExecutionIdClaim, +} from '@/lib/workflows/executor/execution-id-claim' +import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' +import { + loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, +} from '@/lib/workflows/persistence/utils' +import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' +import { workflowHasResponseBlock } from '@/lib/workflows/utils' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import { normalizeName } from '@/executor/constants' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import type { ExecutionMetadata } from '@/executor/execution/types' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + classifyExecutionError, + hasExecutionResult, + type StructuredExecutionError, +} from '@/executor/utils/errors' +import { Serializer } from '@/serializer' +import type { CoreTriggerType } from '@/stores/logs/filters/types' + +const logger = createLogger('WorkflowExecuteService') + +const EXECUTION_ID_CLAIM_ATTEMPTS = 3 + +type WorkflowRecord = typeof workflowTable.$inferSelect + +/** + * Sync workflow execution as a callable service — the orchestration the v1 + * execute route holds inline (call-chain guard, execution-id claim, + * LoggingSession, preprocessing/billing, deployed-state load, file-field + * processing, timeout-bound core execution, output compaction), composed from + * the same libs, for the caller class that runs DEPLOYED state with no draft or + * override controls: the v2 execute route and in-process internal callers + * (MCP bridge). The HTTP endpoints are syntactic sugar over this function. + */ +export interface ExecuteWorkflowServiceParams { + workflowId: string + /** Authenticated user driving actor resolution in preprocessing. */ + userId: string + input: unknown + /** Server-derived caller class — never a wire field. */ + triggerType: CoreTriggerType + requestId: string + /** Caller-supplied idempotent execution id; a reused id fails with `conflict`. */ + executionId?: string + callChain?: string[] + useAuthenticatedUserAsActor?: boolean + /** Pre-fetched workflow row (already authorized by the caller). */ + workflowRecord?: WorkflowRecord + upstreamBillingAttribution?: BillingAttributionSnapshot + /** Pin execution to a specific deployment version (MCP bridge). */ + deploymentVersionId?: string + includeFileBase64?: boolean + base64MaxBytes?: number + selectedOutputs?: string[] + /** MCP behavior: 413-style failure instead of large-value refs in output. */ + rejectLargeInlineOutput?: boolean + /** Which rate-limit bucket preprocessing debits. */ + rateLimitCounter?: 'sync' | 'async' + /** Outer request signal; aborting cancels the run (client disconnect). */ + abortSignal?: AbortSignal + /** + * `sync` (default): run to completion and return the result resource. + * `async`: enqueue and return the queue receipt. + * `stream`: return an SSE Response (agent-stream protocol negotiated from + * `requestHeaders`). + */ + mode?: 'sync' | 'async' | 'stream' + /** Original request headers — stream-protocol negotiation only. */ + requestHeaders?: Headers + includeThinking?: boolean + includeToolCalls?: boolean +} + +export interface ExecuteWorkflowServiceFailure { + kind: 'precheck' | 'conflict' | 'input' | 'aborted' | 'output_too_large' | 'infra' + message: string + statusCode: number + code?: string + retryAfterMs?: number + /** Present when an execution identity was already minted (conflict/ambiguous cases). */ + executionId?: string +} + +export interface ExecuteWorkflowServiceRun { + ok: true + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + /** Why a `cancelled`/`failed` terminal state occurred, when signal-driven. */ + aborted: 'client' | 'timeout' | null + output: NormalizedBlockOutput | undefined + error: StructuredExecutionError | null + hasResponseBlock: boolean + startedAt?: string + endedAt?: string + durationMs?: number +} + +export interface ExecuteWorkflowServiceQueued { + ok: true + queued: true + executionId: string + jobId: string +} + +export interface ExecuteWorkflowServiceStream { + ok: true + stream: Response + executionId: string +} + +export type ExecuteWorkflowServiceResult = + | ExecuteWorkflowServiceRun + | ExecuteWorkflowServiceQueued + | ExecuteWorkflowServiceStream + | { ok: false; failure: ExecuteWorkflowServiceFailure } + +function failure(f: ExecuteWorkflowServiceFailure): ExecuteWorkflowServiceResult { + return { ok: false, failure: f } +} + +async function compactServiceOutput( + value: T, + context: { + workspaceId: string + workflowId: string + executionId: string + userId: string + rejectLargeInlineOutput: boolean + } +): Promise { + const compacted = await compactExecutionPayload(value, { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + userId: context.userId, + preserveUserFileBase64: true, + preserveRoot: !context.rejectLargeInlineOutput, + rejectLargeValues: context.rejectLargeInlineOutput, + rejectLargeValueLabel: 'Workflow execution response', + thresholdBytes: context.rejectLargeInlineOutput ? MAX_MCP_WORKFLOW_RESPONSE_BYTES : undefined, + requireDurable: true, + }) + + if (context.rejectLargeInlineOutput && containsLargeValueRef(compacted)) { + throw new PayloadSizeLimitError({ + label: 'Workflow execution response', + maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, + observedBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES + 1, + }) + } + + return compacted +} + +export async function executeWorkflowService( + params: ExecuteWorkflowServiceParams +): Promise { + const { + workflowId, + userId, + input, + triggerType, + requestId, + callChain, + useAuthenticatedUserAsActor = false, + workflowRecord, + upstreamBillingAttribution, + deploymentVersionId, + includeFileBase64 = true, + base64MaxBytes, + selectedOutputs = [], + rejectLargeInlineOutput = false, + rateLimitCounter = 'sync', + abortSignal, + mode = 'sync', + requestHeaders, + includeThinking = false, + includeToolCalls = false, + } = params + + let reqLogger = logger.withMetadata({ requestId, workflowId, userId }) + + if (callChain) { + const chainError = validateCallChain(callChain) + if (chainError) { + return failure({ kind: 'precheck', message: chainError, statusCode: 409 }) + } + } + + const callerProvidedExecutionId = Boolean(params.executionId) + let executionId = params.executionId ?? generateId() + reqLogger = reqLogger.withMetadata({ executionId }) + + let executionIdClaim: ExecutionIdClaim | null = null + let executionIdClaimCommitted = false + + try { + try { + for (let attempt = 1; attempt <= EXECUTION_ID_CLAIM_ATTEMPTS; attempt++) { + executionIdClaim = await claimExecutionId(executionId) + if (executionIdClaim || callerProvidedExecutionId) break + if (attempt < EXECUTION_ID_CLAIM_ATTEMPTS) { + executionId = generateId() + reqLogger = reqLogger.withMetadata({ executionId }) + } + } + } catch (error) { + reqLogger.error('Failed to claim workflow execution ID', { + error: getErrorMessage(error), + }) + return failure({ + kind: 'infra', + message: 'Workflow execution identity is temporarily unavailable', + statusCode: 503, + }) + } + + if (!executionIdClaim) { + if (callerProvidedExecutionId) { + return failure({ + kind: 'conflict', + message: 'Execution ID has already been used', + statusCode: 409, + code: 'EXECUTION_ID_CONFLICT', + executionId, + }) + } + reqLogger.error('Failed to allocate a unique server execution ID') + return failure({ + kind: 'infra', + message: 'Unable to allocate workflow execution identity', + statusCode: 503, + }) + } + + const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId) + + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType, + executionId, + requestId, + checkDeployment: true, + rateLimitCounter, + loggingSession, + useAuthenticatedUserAsActor, + workflowRecord, + billingAttribution: upstreamBillingAttribution, + }) + + if (!preprocessResult.success) { + const preprocessError = preprocessResult.error + return failure({ + kind: 'precheck', + message: preprocessError.message, + statusCode: preprocessError.statusCode, + code: preprocessError.code, + retryAfterMs: preprocessError.retryAfterMs, + }) + } + + // Preprocessing reserved an admission slot (released when the LoggingSession + // finalizes). Any path that exits before execution starts must release it + // here, or the slot leaks until its TTL and wrongly throttles later runs. + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + const actorUserId = preprocessResult.actorUserId + const workflow = preprocessResult.workflowRecord + const billingAttribution = preprocessResult.billingAttribution + const workspaceId = workflow.workspaceId + if (!workspaceId) { + await releaseExecutionSlot(executionId) + return failure({ + kind: 'infra', + message: 'Invalid execution context returned by preprocessing', + statusCode: 500, + }) + } + reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) + + if (mode === 'async') { + const enqueue = await enqueueWorkflowExecution({ + requestId, + workflowId, + userId: actorUserId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + callChain, + }) + executionIdClaimCommitted = enqueue.retainExecutionClaim + if (enqueue.outcome === 'rejected') { + return failure({ + kind: 'infra', + message: 'Failed to queue async execution', + statusCode: 500, + }) + } + if (enqueue.outcome === 'ambiguous') { + return failure({ + kind: 'infra', + message: 'Async execution queue acceptance could not be confirmed', + statusCode: 503, + code: 'ASYNC_ENQUEUE_AMBIGUOUS', + executionId, + }) + } + return { ok: true, queued: true, executionId, jobId: enqueue.jobId } + } + + let processedInput = input + let workflowVariables: Record = {} + let workflowBlocks: Record = {} + try { + const workflowData = deploymentVersionId + ? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId) + : await loadDeployedWorkflowState(workflowId, workspaceId) + + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + if (workflowData) { + workflowBlocks = workflowData.blocks + workflowVariables = + ('variables' in workflowData + ? (workflowData.variables as Record | undefined) + : undefined) ?? + (workflow.variables as Record | null) ?? + {} + + // Custom blocks resolve only inside the org overlay; wrap this pre-execution + // serialize (used for input file-field discovery) the same way the core does. + const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId) + const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () => + new Serializer().serializeWorkflow( + workflowData.blocks, + workflowData.edges, + workflowData.loops || {}, + workflowData.parallels || {}, + false + ) + ) + + processedInput = await processInputFileFields( + input, + serializedWorkflow.blocks, + { workspaceId, workflowId, executionId }, + requestId, + actorUserId + ) + } else { + workflowVariables = (workflow.variables as Record | null) ?? {} + } + } catch (fileError) { + reqLogger.error('Failed to process input file fields', { error: fileError }) + executionIdClaimCommitted = await loggingSession.safeStart({ + userId: actorUserId, + billingAttribution, + workspaceId, + variables: {}, + }) + await loggingSession.safeCompleteWithError({ + error: { + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, + stackTrace: fileError instanceof Error ? fileError.stack : undefined, + }, + traceSpans: [], + }) + return failure({ + kind: 'input', + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, + statusCode: 400, + }) + } + + if (mode === 'stream') { + const resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) + const streamWorkflow = { + id: workflow.id, + userId: actorUserId, + workspaceId, + isDeployed: workflow.isDeployed, + variables: workflowVariables, + } + const headers = requestHeaders ?? new Headers() + const agentEvents = shouldEmitAgentStreamEvents({ + includeThinking, + includeToolCalls, + requestHeaders: headers, + }) + + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: resolvedSelectedOutputs, + isSecureMode: false, + workflowTriggerType: 'api', + includeFileBase64, + base64MaxBytes, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking, + includeToolCalls, + }, + executionId, + largeValueExecutionIds: [executionId], + largeValueKeys: [], + fileKeys: [], + workspaceId, + workflowId, + userId: actorUserId, + allowLargeValueWorkflowScope: false, + requestSignal: abortSignal, + requestHeaders: headers, + executeFn: async ({ onStream, onBlockComplete, abortSignal: streamAbortSignal }) => + executeWorkflow( + streamWorkflow, + requestId, + processedInput, + actorUserId, + { + enabled: true, + selectedOutputs: resolvedSelectedOutputs, + isSecureMode: false, + workflowTriggerType: 'api', + onStream, + onBlockComplete, + skipLoggingComplete: true, + includeFileBase64, + base64MaxBytes, + abortSignal: streamAbortSignal, + executionMode: 'stream', + billingAttribution, + largeValueKeys: [], + fileKeys: [], + includeThinking, + includeToolCalls, + agentEvents, + }, + executionId + ), + }) + + executionIdClaimCommitted = true + return { + ok: true, + executionId, + stream: new Response(stream, { + status: 200, + headers: { + ...SSE_HEADERS, + // Echo the negotiated stream protocol (same as the chat and v1 routes). + ...agentStreamProtocolResponseHeaders({ requestHeaders: headers }), + }, + }), + } + } + + const metadata: ExecutionMetadata = { + requestId, + executionId, + workflowId, + workspaceId, + userId: actorUserId, + billingAttribution, + workflowUserId: workflow.userId, + triggerType, + useDraftState: false, + startTime: new Date().toISOString(), + isClientSession: false, + enforceCredentialAccess: useAuthenticatedUserAsActor, + largeValueExecutionIds: [executionId], + largeValueKeys: [], + fileKeys: [], + allowLargeValueWorkflowScope: false, + callChain, + executionMode: 'sync', + } + + const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync) + let requestAborted = false + const abortFromRequest = () => { + requestAborted = true + timeoutController.abort() + } + if (abortSignal?.aborted) { + abortFromRequest() + } else { + abortSignal?.addEventListener('abort', abortFromRequest) + } + const isRequestAborted = () => requestAborted || Boolean(abortSignal?.aborted) + + const compactionContext = { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + rejectLargeInlineOutput, + } + + try { + const snapshot = new ExecutionSnapshot( + metadata, + workflow, + processedInput, + workflowVariables, + selectedOutputs + ) + + const result = await executeWorkflowCore({ + snapshot, + callbacks: {}, + loggingSession, + includeFileBase64, + base64MaxBytes, + abortSignal: timeoutController.signal, + }) + + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + + if (result.status === 'cancelled' && isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Execution cancelled by client disconnect') + await loggingSession.markAsFailed('Client cancelled request') + return { + ok: true, + executionId, + workflowId, + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + } + } + + if ( + result.status === 'cancelled' && + timeoutController.isTimedOut() && + timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) + reqLogger.info('Execution timed out', { timeoutMs: timeoutController.timeoutMs }) + await loggingSession.markAsFailed(timeoutErrorMessage) + const compactTimeoutOutput = await compactServiceOutput(result.output, compactionContext) + return { + ok: true, + executionId, + workflowId, + status: 'failed', + aborted: 'timeout', + output: compactTimeoutOutput, + error: { message: timeoutErrorMessage, code: 'TIMEOUT' }, + hasResponseBlock: false, + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, + } + } + + const outputWithBase64 = + includeFileBase64 && !rejectLargeInlineOutput + ? ((await hydrateUserFilesWithBase64(result.output, { + requestId, + workspaceId, + workflowId, + executionId, + largeValueExecutionIds: [executionId], + largeValueKeys: result.metadata?.largeValueKeys ?? [], + fileKeys: result.metadata?.fileKeys ?? [], + allowLargeValueWorkflowScope: false, + userId: actorUserId, + maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, + })) as NormalizedBlockOutput) + : result.output + + const compactOutput = await compactServiceOutput(outputWithBase64, compactionContext) + + const status: ExecuteWorkflowServiceRun['status'] = + result.status === 'paused' + ? 'paused' + : result.status === 'cancelled' + ? 'cancelled' + : result.success + ? 'completed' + : 'failed' + + return { + ok: true, + executionId, + workflowId, + status, + aborted: null, + output: compactOutput, + error: + status === 'failed' || (status === 'cancelled' && result.error) + ? classifyExecutionError(result.error ? new Error(result.error) : undefined, result) + : null, + hasResponseBlock: workflowHasResponseBlock(result), + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, + } + } catch (error: unknown) { + const errorMessage = getErrorMessage(error, 'Unknown error') + + if (isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Execution aborted after client disconnect') + return { + ok: true, + executionId, + workflowId, + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + } + } + + if ( + error instanceof PayloadSizeLimitError && + rejectLargeInlineOutput && + error.label === 'Workflow execution response' + ) { + return failure({ + kind: 'output_too_large', + message: 'Workflow execution response exceeds maximum size', + statusCode: 413, + code: 'workflow_response_too_large', + executionId, + }) + } + + reqLogger.error(`Execution failed: ${errorMessage}`) + + const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + let compactErrorOutput: NormalizedBlockOutput | undefined + if (executionResult && Object.hasOwn(executionResult, 'output')) { + try { + compactErrorOutput = await compactServiceOutput(executionResult.output, compactionContext) + } catch (compactError) { + if ( + compactError instanceof PayloadSizeLimitError && + rejectLargeInlineOutput && + compactError.label === 'Workflow execution response' + ) { + return failure({ + kind: 'output_too_large', + message: 'Workflow execution response exceeds maximum size', + statusCode: 413, + code: 'workflow_response_too_large', + executionId, + }) + } + throw compactError + } + } + + return { + ok: true, + executionId, + workflowId, + status: 'failed', + aborted: null, + output: compactErrorOutput, + error: classifyExecutionError(error, executionResult), + hasResponseBlock: false, + startedAt: executionResult?.metadata?.startTime, + endedAt: executionResult?.metadata?.endTime, + durationMs: executionResult?.metadata?.duration, + } + } finally { + abortSignal?.removeEventListener('abort', abortFromRequest) + timeoutController.cleanup() + } + } catch (error) { + reqLogger.error('Failed to start workflow execution', { error: toError(error).message }) + if (executionId) await releaseExecutionSlot(executionId) + return failure({ + kind: 'infra', + message: toError(error).message || 'Failed to start workflow execution', + statusCode: 500, + }) + } finally { + if (executionIdClaim && !executionIdClaimCommitted) { + try { + executionIdClaimCommitted = await hasDurableExecutionOwner(executionId) + } catch (error) { + executionIdClaimCommitted = true + reqLogger.warn('Unable to verify execution ID ownership; retaining claim', { + error: toError(error).message, + executionId, + }) + } + } + + if (executionIdClaim && !executionIdClaimCommitted) { + try { + await releaseExecutionIdClaim(executionIdClaim) + } catch (error) { + reqLogger.warn('Failed to release pre-start execution ID claim', { + error: toError(error).message, + executionId, + }) + } + } + } +} + +/** + * Resolves caller-facing `selectedOutputs` refs (`BlockName.path` or + * `.path`) to internal `_` ids — same normalization the + * v1 streaming path applies. + */ +export function resolveOutputIds( + selectedOutputs: string[] | undefined, + blocks: Record +): string[] | undefined { + if (!selectedOutputs || selectedOutputs.length === 0) { + return selectedOutputs + } + + return selectedOutputs.map((outputId) => { + const underscoreIndex = outputId.indexOf('_') + const dotIndex = outputId.indexOf('.') + if (underscoreIndex > 0) { + const maybeUuid = outputId.substring(0, underscoreIndex) + if (isValidUuid(maybeUuid)) { + return outputId + } + } + + if (dotIndex > 0) { + const maybeUuid = outputId.substring(0, dotIndex) + if (isValidUuid(maybeUuid)) { + return `${outputId.substring(0, dotIndex)}_${outputId.substring(dotIndex + 1)}` + } + } + + if (isValidUuid(outputId)) { + return outputId + } + + if (dotIndex === -1) { + logger.warn(`Invalid output ID format (missing dot): ${outputId}`) + return outputId + } + + const blockName = outputId.substring(0, dotIndex) + const path = outputId.substring(dotIndex + 1) + + const normalizedBlockName = normalizeName(blockName) + const block = Object.values(blocks).find((candidate) => { + const record = candidate as { name?: string } + return normalizeName(record.name || '') === normalizedBlockName + }) + + if (!block) { + logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`) + return outputId + } + + const resolvedId = `${(block as { id: string }).id}_${path}` + return resolvedId + }) +} diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts new file mode 100644 index 00000000000..d608dd3d825 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -0,0 +1,202 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' +import { + collectFunctionalBlockOutputs, + type FunctionalExecutionDataSource, +} from '@/lib/logs/execution/functional-outputs' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' +import type { PausePoint } from '@/executor/types' + +/** + * Reads a single execution's status resource — the log row, the paused-state + * overlay, and (when requested) materialized outputs. Extracted so the v1 and + * v2 status routes render the identical resource from one read path. + * Auth is the caller's responsibility. Returns `null` when no log row exists. + */ + +type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + +interface ExecutionDataShape extends FunctionalExecutionDataSource { + finalOutput?: { error?: string } & Record + error?: { message?: string } | string + completionFailure?: string +} + +function resolvePath(value: unknown, path: string[]): unknown { + let current: unknown = value + for (const segment of path) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +function pickSelectedOutputs( + selectedOutputs: string[], + blockOutputs: Map +): Record { + const out: Record = {} + for (const selector of selectedOutputs) { + const [head, ...rest] = selector.split('.') + if (!head) continue + if (!blockOutputs.has(head)) continue + const blockValue = blockOutputs.get(head) + out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest) + } + return out +} + +function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null { + const active = points.filter((p) => p.resumeStatus === 'paused') + if (active.length === 0) return null + return active.reduce((best, current) => { + if (!best) return current + if (!current.resumeAt) return best + if (!best.resumeAt) return current + return current.resumeAt < best.resumeAt ? current : best + }, null) +} + +function normalizePausePoints(raw: unknown): PausePoint[] { + if (!raw) return [] + if (Array.isArray(raw)) return raw as PausePoint[] + if (typeof raw === 'object') return Object.values(raw as Record) + return [] +} + +function extractError(executionData: unknown): string | null { + if (!executionData || typeof executionData !== 'object') return null + const data = executionData as ExecutionDataShape + if (typeof data.error === 'string') return data.error + if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') { + return data.error.message + } + if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error + if (typeof data.completionFailure === 'string') return data.completionFailure + return null +} + +export interface GetWorkflowExecutionStatusInput { + workflowId: string + executionId: string + includeOutput: boolean + selectedOutputs: string[] +} + +export async function getWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput +): Promise { + const { workflowId, executionId, includeOutput, selectedOutputs } = input + + const [logRow] = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + if (!logRow) return null + + const [pausedRow] = await db + .select({ + id: pausedExecutions.id, + status: pausedExecutions.status, + pausePoints: pausedExecutions.pausePoints, + metadata: pausedExecutions.metadata, + resumedCount: pausedExecutions.resumedCount, + pausedAt: pausedExecutions.pausedAt, + nextResumeAt: pausedExecutions.nextResumeAt, + }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1) + + const isCurrentlyPaused = + !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') + + let status: WorkflowExecutionStatusResponse['status'] + if (isCurrentlyPaused) { + status = 'paused' + } else { + status = logRow.status as LogStatus + } + + let paused: WorkflowExecutionStatusResponse['paused'] = null + if (isCurrentlyPaused && pausedRow) { + const points = normalizePausePoints(pausedRow.pausePoints) + const earliest = pickEarliestPausePoint(points) + const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata) + paused = { + pausedAt: pausedRow.pausedAt.toISOString(), + resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null, + pauseKind: earliest?.pauseKind ?? null, + blockedOnBlockId: earliest?.blockId ?? null, + automaticResumeWaitingReason: + automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null, + pausedExecutionId: pausedRow.id, + pausePointCount: points.length, + resumedCount: pausedRow.resumedCount, + } + } + + const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null + + // Heavy execution data may live in object storage; resolve the pointer + // before reading error / finalOutput / traceSpans (no-op for inline rows). + const executionData = (await materializeExecutionData( + logRow.executionData as Record | null, + { + workspaceId: logRow.workspaceId, + workflowId: logRow.workflowId, + executionId: logRow.executionId, + } + )) as ExecutionDataShape | undefined + + const error = status === 'failed' ? extractError(executionData) : null + + const finalOutput = + includeOutput && status === 'completed' && executionData + ? (executionData.finalOutput ?? null) + : null + + const blockOutputs = + selectedOutputs.length > 0 + ? pickSelectedOutputs(selectedOutputs, collectFunctionalBlockOutputs(executionData)) + : null + + return { + executionId: logRow.executionId, + // Column is `set null` on workflow delete; the caller's param is the fallback. + workflowId: logRow.workflowId ?? workflowId, + status, + trigger: logRow.trigger, + level: logRow.level, + startedAt: logRow.startedAt.toISOString(), + endedAt: logRow.endedAt?.toISOString() ?? null, + totalDurationMs: logRow.totalDurationMs ?? null, + paused, + cost, + error, + finalOutput, + blockOutputs, + } +} diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts new file mode 100644 index 00000000000..d6e26c6bff4 --- /dev/null +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -0,0 +1,119 @@ +import type { Edge } from 'reactflow' +import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' + +/** + * Server-only assembly of the public workflow-export payload, shared by the v1 + * and v2 export routes so both surfaces emit byte-identical envelopes. + * + * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits + * the raw state for backup/restore, this runs the payload through + * `sanitizeForExport`, which nulls three classes of sub-block value: + * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}` + * reference, which is preserved so the import resolves it in the target + * workspace; + * - `oauth-input` credentials; + * - **workspace-scoped bindings** — selector fields and id-keyed fields that + * point at rows that do not exist in another workspace, cleared rather than + * carried across as dangling ids. + * + * The last class means an export is **not** a byte-for-byte clone even when + * re-imported into the same workspace: those bindings come back empty and must + * be re-selected. This matches the in-app export. + * + * Workflow **variables** are emitted as stored: they are plaintext workflow + * configuration readable by anyone with workspace read (the same permission the + * export routes require); secrets belong in environment variables, which travel + * as unresolved `{{ENV_VAR}}` references. + */ + +/** The subset of the workflow record the export payload reads. */ +export interface ExportableWorkflowRecord { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + variables: unknown +} + +type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] + +/** + * Projects a persisted ReactFlow edge onto the wire shape declared by the + * response contract. Field-by-field rather than a spread because ReactFlow's + * `Edge` is looser than the contract in three places: handles are `null` when + * unset (the contract and the importer both model absent as `undefined`), + * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker` + * object. Non-serializable values in those slots are dropped rather than + * emitted as `{}`. + */ +function toExportedEdge(edge: Edge): ExportedEdge { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + type: edge.type, + animated: edge.animated, + style: edge.style as Record | undefined, + data: edge.data, + label: typeof edge.label === 'string' ? edge.label : undefined, + labelStyle: edge.labelStyle as Record | undefined, + labelShowBg: edge.labelShowBg, + labelBgStyle: edge.labelBgStyle as Record | undefined, + labelBgPadding: edge.labelBgPadding, + labelBgBorderRadius: edge.labelBgBorderRadius, + markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined, + markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined, + } +} + +/** + * Loads the workflow's normalized state, sanitizes it, and assembles the + * portable export envelope. Returns `null` when the workflow has no persisted + * normalized state (the caller renders its own 404). + */ +export async function buildWorkflowExportPayload( + workflowData: ExportableWorkflowRecord +): Promise { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowData.id) + if (!normalizedData) return null + + const sanitized = sanitizeForExport({ + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: workflowData.name, + description: workflowData.description ?? undefined, + }, + variables: parseWorkflowVariables(workflowData.variables), + }) + + return { + version: '1.0', + exportedAt: sanitized.exportedAt, + workflow: { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + workspaceId: workflowData.workspaceId, + folderId: workflowData.folderId, + }, + state: { + ...sanitized.state, + edges: sanitized.state.edges.map(toExportedEdge), + metadata: { + ...sanitized.state.metadata, + name: workflowData.name, + description: workflowData.description ?? undefined, + exportedAt: sanitized.exportedAt, + }, + }, + } +} diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts new file mode 100644 index 00000000000..53dede6d709 --- /dev/null +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -0,0 +1,369 @@ +import { db } from '@sim/db' +import { workflow, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { + assertFolderInWorkspace, + assertFolderMutable, + FolderLockedError, + FolderNotFoundError, +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' +import { and, eq, isNull } from 'drizzle-orm' +import { + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + V1_IMPORT_NAME_MAX_LENGTH, +} from '@/lib/api/contracts/v1/workflows' +import { workflowStateSchema } from '@/lib/api/contracts/workflows' +import { serializeZodIssues } from '@/lib/api/server' +import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('WorkflowImportOperation') + +/** + * Server-only import orchestration shared by the v1 and v2 import routes: both + * surfaces must land byte-identical data for the same payload, so the whole + * pipeline — payload parse, state validation, normalization, transactional + * persistence with rollback, custom-tool extraction — lives here and the routes + * only authenticate and render their own envelopes. + */ + +/** + * Workflow JSON is a bounded document — a few hundred blocks at the outside. + * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) + * keeps a hostile caller from buffering a large body before validation runs. + */ +export const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024 + +const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow' + +const TRUNCATION_SUFFIX = '...' + +export interface ImportWorkflowParams { + workspaceId: string + folderId?: string + /** Explicit name override; wins over payload metadata. */ + name?: string + /** Explicit description override; wins over payload metadata. */ + description?: string + /** Export envelope, bare state, or a JSON string of either. */ + workflow: string | Record + userId: string + requestId: string +} + +export interface ImportedWorkflow { + id: string + name: string + description: string | null + workspaceId: string + folderId: string | null + createdAt: Date + updatedAt: Date +} + +export type ImportWorkflowResult = + | { success: true; workflow: ImportedWorkflow } + | { success: false; status: number; error: string; details?: unknown } + +/** + * Caps a payload-derived string at `maxLength` *including* the ellipsis. + * `truncate` appends its suffix after slicing, so passing the limit straight + * through would yield `maxLength + 3` characters and overshoot the very bound + * this is enforcing. + */ +function capLength(value: string, maxLength: number): string { + return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX) +} + +/** + * Reads a dot-delimited path off a parsed payload and returns it only when it + * is a non-empty string, so blank metadata falls through to the next candidate. + */ +function readString(source: unknown, path: string): string | undefined { + let current: unknown = source + for (const segment of path.split('.')) { + if (!current || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return typeof current === 'string' && current.trim() ? current.trim() : undefined +} + +/** + * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so + * a caller can pipe an export response body straight into import. + * `parseWorkflowJson` already tolerates this shape when reading the graph; + * mirroring it here keeps metadata resolution from silently falling back to the + * default name for the same payload. + */ +function unwrapResponseEnvelope(payload: unknown): unknown { + if (!payload || typeof payload !== 'object') return payload + const inner = (payload as Record).data + if (!inner || typeof inner !== 'object') return payload + const candidate = inner as Record + return candidate.state || candidate.version || candidate.workflow ? candidate : payload +} + +/** + * Resolves the imported workflow's name and description, preferring explicit + * request overrides and then the payload's own metadata. Accepts every shape + * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) + * and a bare state (`metadata.*`). + * + * Candidate order deliberately matches `extractWorkflowName` — the resolver the + * in-app importer has always used — so the same payload yields the same name on + * both surfaces. The in-app version additionally falls back to the uploaded + * filename, which has no analogue here; that is the only intended difference. + * + * Payload-derived values are capped at the same bounds the contract applies to + * the explicit overrides, otherwise the declared `maxLength` would not be the + * effective one — a caller could store an unbounded name simply by embedding it + * in the payload instead of passing it as a field. + */ +function resolveImportedMetadata( + rawPayload: unknown, + overrideName?: string, + overrideDescription?: string +): { name: string; description: string } { + const payload = unwrapResponseEnvelope(rawPayload) + + const name = + overrideName || + capLength( + readString(payload, 'state.metadata.name') || + readString(payload, 'workflow.name') || + readString(payload, 'metadata.name') || + DEFAULT_IMPORTED_WORKFLOW_NAME, + V1_IMPORT_NAME_MAX_LENGTH + ) + + const description = + overrideDescription ?? + capLength( + readString(payload, 'state.metadata.description') ?? + readString(payload, 'workflow.description') ?? + readString(payload, 'metadata.description') ?? + '', + V1_IMPORT_DESCRIPTION_MAX_LENGTH + ) + + return { name, description } +} + +/** + * Creates a new workflow in the target workspace from an export payload + * produced by the export endpoints. Block, edge, loop and parallel ids are + * regenerated on import, so the same payload can be imported repeatedly and + * alongside its source workflow without collisions. + * + * The caller must have already authenticated and authorized write access to + * `workspaceId`; this performs only resource-level checks (workspace exists, + * folder ownership/lock state). + */ +export async function importWorkflowIntoWorkspace( + params: ImportWorkflowParams +): Promise { + const { workspaceId, folderId, userId, requestId } = params + + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceData) { + return { success: false, status: 404, error: 'Workspace not found' } + } + + /** + * Ownership before lock state: `assertFolderMutable` walks the folder's + * ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace + * (423) from a nonexistent one (404). + */ + try { + if (folderId) { + await assertFolderInWorkspace(folderId, workspaceId) + } + await assertFolderMutable(folderId ?? null) + } catch (error) { + if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) { + return { success: false, status: error.status, error: error.message } + } + throw error + } + + const rawWorkflow = params.workflow + const workflowContent = + typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow) + + const { data: parsedState, errors } = parseWorkflowJson(workflowContent) + if (!parsedState || errors.length > 0) { + return { success: false, status: 400, error: `Invalid workflow: ${errors.join(', ')}` } + } + + /** + * Variables are normalized before validation, not after: older exports + * carry them as an array, which is a shape `workflowStateSchema` rightly + * rejects but the importer has always accepted. Normalizing first keeps + * that tolerance while still validating what actually gets persisted. + */ + const variables = normalizeImportedVariables(parsedState.variables) + + /** + * `parseWorkflowJson` only checks that blocks/edges are structurally + * present. The normalized tables are read back through + * {@link workflowStateSchema}, and the client parses that response + * strictly — so a block field with the wrong type would persist happily here + * and then throw on every subsequent load, leaving a workflow nothing can + * open. Gate on the same schema the canonical `PUT /api/workflows/[id]/state` + * path enforces. + */ + const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables }) + if (!stateValidation.success) { + const issue = stateValidation.error.issues[0] + const path = issue?.path.join('.') + return { + success: false, + status: 400, + error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`, + details: serializeZodIssues(stateValidation.error), + } + } + + /** + * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via + * the one shared implementation — the two import surfaces must land + * byte-identical data for the same payload. + */ + const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState) + if (warnings.length > 0) { + logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings }) + } + + const workflowState: WorkflowState = { ...parsedState, ...preparedState } + + let parsedPayload: unknown = rawWorkflow + if (typeof rawWorkflow === 'string') { + try { + parsedPayload = JSON.parse(rawWorkflow) + } catch { + parsedPayload = undefined + } + } + + const { name, description } = resolveImportedMetadata( + parsedPayload, + params.name, + params.description + ) + + const created = await performCreateWorkflow({ + name, + description, + workspaceId, + folderId, + deduplicate: true, + userId, + requestId, + }) + + if (!created.success || !created.workflow) { + const status = + created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500 + return { success: false, status, error: created.error ?? 'Failed to create workflow' } + } + + const workflowId = created.workflow.id + + /** + * The graph and the variables are written in one transaction so an import + * can never half-land, and any failure deletes the shell row created above + * — a caller that receives an error must not be left with a partially + * imported workflow in their workspace. + */ + try { + await db.transaction(async (tx) => { + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to save workflow state') + } + + if (Object.keys(variables).length > 0) { + await tx + .update(workflow) + .set({ variables, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + } + }) + } catch (error) { + logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + /** + * The rollback runs under the same conditions that just failed the write, + * so it can fail too. Losing it must not turn into an unlogged orphan: + * the caller still gets a 500, but the id is recorded loudly enough to + * clean up. + */ + try { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + } catch (rollbackError) { + logger.error( + `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, + { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') } + ) + } + return { success: false, status: 500, error: 'Failed to save workflow state' } + } + + /** + * Matches the canonical state-write path: agent blocks may carry inline + * custom-tool definitions that must exist as workspace rows to be + * resolvable at execution. Failures are logged, not fatal — the workflow + * itself imported successfully. + */ + try { + const { saved, errors: toolErrors } = await extractAndPersistCustomTools( + workflowState, + workspaceId, + userId + ) + if (saved > 0 || toolErrors.length > 0) { + logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, { + workflowId, + errors: toolErrors, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to persist custom tools from import`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + + logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { + name: created.workflow.name, + blocksCount: Object.keys(workflowState.blocks).length, + }) + + return { + success: true, + workflow: { + id: workflowId, + name: created.workflow.name, + description: created.workflow.description ?? null, + workspaceId, + folderId: created.workflow.folderId ?? null, + createdAt: created.workflow.createdAt, + updatedAt: created.workflow.updatedAt, + }, + } +} diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 5ab57a53b0d..d4b19e2380d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -8,6 +8,7 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' @@ -28,7 +29,6 @@ import { notifySocketDeploymentChanged, processWorkflowDeploymentOutboxEvent, } from '@/lib/workflows/deployment-outbox' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getWorkflowDeploymentStatus, prepareWorkflowDeployment, diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts deleted file mode 100644 index e4903974aa4..00000000000 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { folder as folderTable } from '@sim/db/schema' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' -import type { FolderMutationErrorCode } from '@/lib/folders/status' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' - -/** - * Workflow-bound entry points into the generic folder engine in `lib/folders/lifecycle.ts`. - * - * The engine is resourceType-driven and owns the actual writes; these wrappers exist so the - * workflow callers that predate it (the folders API, the copilot workflow tools, the - * resource-restore orchestrator) keep a single, workflow-shaped signature. Everything that - * differs for workflows — the last-workflow delete guard, archiving through the workflow - * lifecycle so deployments and webhooks tear down, and restoring schedules/webhooks/chats — - * is declared as data on the `workflow` entry of `FOLDER_RESOURCES`, not branched on here. - */ - -export interface PerformCreateFolderParams { - userId: string - workspaceId: string - name: string - id?: string - parentId?: string | null - sortOrder?: number -} - -export interface PerformCreateFolderResult { - success: boolean - error?: string - errorCode?: OrchestrationErrorCode - folder?: typeof folderTable.$inferSelect -} - -export interface PerformUpdateFolderParams { - folderId: string - workspaceId: string - userId: string - name?: string - locked?: boolean - parentId?: string | null - sortOrder?: number -} - -export interface PerformUpdateFolderResult extends PerformCreateFolderResult {} - -export interface PerformDeleteFolderParams { - folderId: string - workspaceId: string - userId: string - folderName?: string -} - -export interface PerformDeleteFolderResult { - success: boolean - error?: string - errorCode?: FolderMutationErrorCode - deletedItems?: { folders: number; workflows?: number } -} - -export interface PerformRestoreFolderParams extends PerformDeleteFolderParams { - /** - * Folder tree to restore into. Defaults to `'workflow'` so every existing caller — and the - * copilot tool contract — is unchanged; Recently Deleted and the restore tool pass the - * knowledge-base or table tree explicitly. - */ - resourceType?: FolderResourceType -} - -export interface PerformRestoreFolderResult { - success: boolean - error?: string - errorCode?: OrchestrationErrorCode - restoredItems?: { folders: number; workflows?: number } -} - -export function performCreateFolder( - params: PerformCreateFolderParams -): Promise { - return createFolder({ ...params, resourceType: 'workflow' }) -} - -export function performUpdateFolder( - params: PerformUpdateFolderParams -): Promise { - return updateFolder({ ...params, resourceType: 'workflow' }) -} - -export function performDeleteFolder( - params: PerformDeleteFolderParams -): Promise { - return deleteFolder({ ...params, resourceType: 'workflow' }) -} - -export function performRestoreFolder( - params: PerformRestoreFolderParams -): Promise { - return restoreFolder({ ...params, resourceType: params.resourceType ?? 'workflow' }) -} diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index b47ab417e3f..dc8d99a1d9f 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -9,12 +9,6 @@ export { performFullUndeploy, performRevertToVersion, } from './deploy' -export { - performCreateFolder, - performDeleteFolder, - performRestoreFolder, - performUpdateFolder, -} from './folder-lifecycle' export { performCreateWorkflow, performDeleteWorkflow, diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts deleted file mode 100644 index 70c715ffb2c..00000000000 --- a/apps/sim/lib/workflows/orchestration/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | 'internal' - -/** - * Maps an orchestration error code to its HTTP status. Shared by every route - * surface (UI, v1, tool routes) so deployment errors map identically. - */ -export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { - if (code === 'validation') return 400 - if (code === 'not_found') return 404 - if (code === 'conflict') return 409 - return 500 -} diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 9ccee7d5171..07588af4460 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -6,11 +6,11 @@ import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 584d96cc566..4b72877d086 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -937,7 +937,21 @@ export async function getWorkflowDeploymentVersion( return row ?? null } -export async function listWorkflowVersions(workflowId: string): Promise<{ +export interface ListWorkflowVersionsOptions { + /** Caps the rows read. Omitted reads every version. */ + limit?: number + /** + * Keyset bound for the `version DESC` ordering: returns only versions + * strictly below this number, i.e. the page *after* it. Paired with `limit` + * this keeps a paginated caller off a full-table read. + */ + afterVersion?: number +} + +export async function listWorkflowVersions( + workflowId: string, + options: ListWorkflowVersionsOptions = {} +): Promise<{ versions: Array<{ id: string version: number @@ -952,22 +966,29 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ }> { const { user } = await import('@sim/db') + const versionConditions = [eq(workflowDeploymentVersion.workflowId, workflowId)] + if (options.afterVersion !== undefined) { + versionConditions.push(lt(workflowDeploymentVersion.version, options.afterVersion)) + } + + const versionQuery = db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + createdBy: workflowDeploymentVersion.createdBy, + deployedByName: user.name, + }) + .from(workflowDeploymentVersion) + .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) + .where(and(...versionConditions)) + .orderBy(desc(workflowDeploymentVersion.version)) + const [rows, [currentOperation]] = await Promise.all([ - db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedByName: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - .orderBy(desc(workflowDeploymentVersion.version)), + options.limit !== undefined ? versionQuery.limit(options.limit) : versionQuery, /** * Only the workflow's current (latest-generation) operation carries a * status marker: a failed or in-flight attempt is live information until diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 4746cc0d5f7..daf82b2d285 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, type Column, desc, eq, ne } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2SkillSortBy } from '@/lib/api/contracts/v2/skills' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' import { getEditableSkillIds } from '@/lib/skills/access' import { @@ -32,6 +35,32 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski } } +type SkillRow = typeof skill.$inferSelect + +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so skills sharing a timestamp still + * come back in a stable order. + */ +const SKILL_SORTS = { + name: [skill.name, skill.id], + createdAt: [skill.createdAt, skill.id], + updatedAt: [skill.updatedAt, skill.id], +} satisfies Record + +/** The sort key {@link SKILL_SORTS} orders on, for one row. */ +function skillSortKey(row: SkillRow, sortBy: V2SkillSortBy): [string | number, string] { + if (sortBy === 'name') return [row.name, row.id] + return [(sortBy === 'createdAt' ? row.createdAt : row.updatedAt).getTime(), row.id] +} + +function compareSkills(a: SkillRow, b: SkillRow, sortBy: V2SkillSortBy): number { + const [aKey, aId] = skillSortKey(a, sortBy) + const [bKey, bId] = skillSortKey(b, sortBy) + if (aKey !== bKey) return aKey < bKey ? -1 : 1 + return aId < bId ? -1 : aId > bId ? 1 : 0 +} + /** * List skills for a workspace, ordered by createdAt desc. Built-in template * skills are prepended (they live in code, not the DB) so they appear wherever @@ -40,23 +69,53 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * Pass `includeBuiltins: false` to return only user-created skills. The * mothership uses this for the workspace skill inventory it sees, which lists * only user-created skills and never the code-only templates. + * + * `search` and `sort` serve the public list. The DB half of both runs in the + * query; the built-ins are a small code constant with no row to order, so they + * are filtered and merged in memory — the one place a v2 list cannot push its + * sort all the way down. Passing `sort` also re-orders the built-ins into the + * requested order instead of pinning them first, so the public list is sorted + * as documented; callers that omit it keep the historical builtins-first order. + * + * The merged ordering compares names with JS string order rather than the + * database collation. Only the handful of ASCII built-in names are placed by + * it, so the two agree in practice. */ -export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { +export async function listSkills(params: { + workspaceId: string + includeBuiltins?: boolean + /** Case-insensitive substring match on the skill name. */ + search?: string + sort?: { sortBy: V2SkillSortBy; sortOrder: V2SortOrder } +}): Promise { + const sortBy = params.sort?.sortBy ?? 'createdAt' + const sortOrder = params.sort?.sortOrder ?? 'desc' + const dbRows = await db .select() .from(skill) - .where(eq(skill.workspaceId, params.workspaceId)) - .orderBy(desc(skill.createdAt)) + .where(and(eq(skill.workspaceId, params.workspaceId), searchFilter(skill.name, params.search))) + .orderBy(...listOrderBy(SKILL_SORTS[sortBy], sortOrder)) if (params.includeBuiltins === false) { return dbRows } + /** + * Restricting `dbNames` to the searched rows is safe: a DB skill only shadows + * a built-in by sharing its name, and a name that matches the search on the + * built-in matches it on the DB row too. + */ const dbNames = new Set(dbRows.map((r) => r.name.toLowerCase())) - const builtins = BUILTIN_SKILLS.filter((b) => !dbNames.has(b.name.toLowerCase())).map((b) => - builtinSkillRow(params.workspaceId, b) - ) - return [...builtins, ...dbRows] + const term = params.search?.toLowerCase() + const builtins = BUILTIN_SKILLS.filter( + (b) => !dbNames.has(b.name.toLowerCase()) && (!term || b.name.toLowerCase().includes(term)) + ).map((b) => builtinSkillRow(params.workspaceId, b)) + + if (!params.sort) return [...builtins, ...dbRows] + + const direction = sortOrder === 'asc' ? 1 : -1 + return [...builtins, ...dbRows].sort((a, b) => direction * compareSkills(a, b, sortBy)) } /** A skill row tagged with whether the caller can edit it (always false on builtins). */ diff --git a/apps/sim/lib/workspace-files/orchestration/content.ts b/apps/sim/lib/workspace-files/orchestration/content.ts new file mode 100644 index 00000000000..f9703370d8e --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/content.ts @@ -0,0 +1,101 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' + +const logger = createLogger('WorkspaceFileContentOrchestration') + +/** Ceiling on a single content replace, independent of the workspace quota. */ +export const MAX_WORKSPACE_FILE_CONTENT_BYTES = 50 * 1024 * 1024 + +/** JSON-body ceiling with room for a 50 MiB file's base64 expansion and envelope. */ +export const MAX_WORKSPACE_FILE_INLINE_BODY_BYTES = 70 * 1024 * 1024 + +export interface PerformUpdateWorkspaceFileContentParams { + workspaceId: string + fileId: string + userId: string + content: string + encoding: 'utf-8' | 'base64' + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpdateWorkspaceFileContentResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + file?: WorkspaceFileRecord +} + +/** + * Replaces a workspace file's bytes. + * + * Failures are classified rather than message-matched: the manager throws a + * classified `not_found`, the storage ledger throws `StorageLimitExceededError` + * (a `payload_too_large` {@link OrchestrationError}), and both reach here through + * `asOrchestrationError`, which walks the `cause` chain past drizzle's + * transaction wrapper. + */ +export async function performUpdateWorkspaceFileContent( + params: PerformUpdateWorkspaceFileContentParams +): Promise { + const { workspaceId, fileId, userId, content, encoding, actorName, actorEmail, request } = params + + const buffer = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf-8') + + if (buffer.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + return { + success: false, + error: `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit`, + errorCode: 'payload_too_large', + } + } + + try { + const file = await updateWorkspaceFileContent(workspaceId, fileId, userId, buffer) + + logger.info('Updated workspace file content', { workspaceId, fileId, size: buffer.length }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Updated content of file "${file.name}"`, + metadata: { contentSize: buffer.length }, + request, + }) + + return { success: true, file } + } catch (error) { + const classified = asOrchestrationError(error) + if (classified) { + logger.warn('Workspace file content update rejected', { + workspaceId, + fileId, + errorCode: classified.code, + }) + return { success: false, error: classified.message, errorCode: classified.code } + } + if (error instanceof ContentVersionConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + logger.error('Failed to update workspace file content', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/create.test.ts b/apps/sim/lib/workspace-files/orchestration/create.test.ts new file mode 100644 index 00000000000..1e093ccac65 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/create.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCaptureServerEvent, mockRecordAudit, mockUploadWorkspaceFile } = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockRecordAudit: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + FileConflictError: class FileConflictError extends Error { + constructor(name: string) { + super(`A file named "${name}" already exists in this workspace`) + this.name = 'FileConflictError' + } + }, + uploadWorkspaceFile: mockUploadWorkspaceFile, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { FileConflictError } from '@/lib/uploads/contexts/workspace' +import { + MAX_WORKSPACE_FILE_CONTENT_BYTES, + performCreateWorkspaceFile, +} from '@/lib/workspace-files/orchestration' + +const WORKSPACE_ID = 'workspace-1' +const USER_ID = 'user-1' +const CREATED_FILE = { + id: 'wf_created', + workspaceId: WORKSPACE_ID, + name: 'untitled.md', + key: 'workspace/workspace-1/untitled.md', + path: '/api/files/serve/untitled.md', + url: '/api/files/serve/untitled.md', + size: 0, + type: 'text/markdown', + uploadedBy: USER_ID, + folderId: null, + folderPath: null, + deletedAt: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-04T00:00:00.000Z'), + context: 'workspace' as const, +} + +describe('performCreateWorkspaceFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadWorkspaceFile.mockResolvedValue(CREATED_FILE) + }) + + it('creates an empty file with exact-name conflict semantics and returns its canonical record', async () => { + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'test' } }) + + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + actorName: 'Test User', + actorEmail: 'test@sim.ai', + name: 'untitled.md', + contentType: 'text/markdown', + folderId: null, + request, + }) + + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WORKSPACE_ID, + USER_ID, + expect.objectContaining({ length: 0 }), + 'untitled.md', + 'text/markdown', + { folderId: null, exactName: true } + ) + expect(result).toEqual({ success: true, file: CREATED_FILE }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: USER_ID, + actorName: 'Test User', + actorEmail: 'test@sim.ai', + workspaceId: WORKSPACE_ID, + resourceId: CREATED_FILE.id, + resourceName: CREATED_FILE.name, + metadata: { fileSize: 0, fileType: 'text/markdown' }, + request, + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + USER_ID, + 'file_uploaded', + { workspace_id: WORKSPACE_ID, file_type: 'text/markdown' }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('preserves initialized content, folder, and content type', async () => { + const content = Buffer.from('# Ready\n') + const file = { + ...CREATED_FILE, + name: 'ready.md', + folderId: 'folder-1', + folderPath: 'Docs', + size: content.length, + } + mockUploadWorkspaceFile.mockResolvedValue(file) + + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'ready.md', + contentType: 'text/markdown; charset=utf-8', + folderId: 'folder-1', + content, + exactName: false, + }) + + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WORKSPACE_ID, + USER_ID, + content, + 'ready.md', + 'text/markdown; charset=utf-8', + { folderId: 'folder-1', exactName: false } + ) + expect(result).toEqual({ success: true, file }) + }) + + it('rejects decoded content above the content-update limit before storage I/O', async () => { + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'too-large.md', + contentType: 'text/markdown', + content: Buffer.alloc(MAX_WORKSPACE_FILE_CONTENT_BYTES + 1), + }) + + expect(result).toEqual({ + success: false, + error: 'File size exceeds 50MB limit', + errorCode: 'payload_too_large', + }) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('classifies an exact-name collision as conflict and records no audit', async () => { + mockUploadWorkspaceFile.mockRejectedValue(new FileConflictError('untitled.md')) + + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'untitled.md', + contentType: 'text/markdown', + }) + + expect(result).toEqual({ + success: false, + error: 'A file named "untitled.md" already exists in this workspace', + errorCode: 'conflict', + }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('preserves classified folder failures and keeps unexpected faults internal', async () => { + mockUploadWorkspaceFile.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const missingFolder = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'untitled.md', + contentType: 'text/markdown', + folderId: 'missing', + }) + + expect(missingFolder).toEqual({ + success: false, + error: 'Target folder not found', + errorCode: 'not_found', + }) + + mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('connection terminated')) + + const unexpected = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'untitled.md', + contentType: 'text/markdown', + }) + + expect(unexpected).toEqual({ + success: false, + error: 'connection terminated', + errorCode: 'internal', + }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/create.ts b/apps/sim/lib/workspace-files/orchestration/create.ts new file mode 100644 index 00000000000..ab1992791bf --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/create.ts @@ -0,0 +1,123 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { + FileConflictError, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration/content' + +const logger = createLogger('WorkspaceFileCreateOrchestration') + +export interface PerformCreateWorkspaceFileParams { + workspaceId: string + userId: string + name: string + contentType: string + folderId?: string | null + folderPath?: string + content?: Buffer + exactName?: boolean + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformCreateWorkspaceFileResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + file?: WorkspaceFileRecord +} + +/** + * Creates a workspace file from server-held bytes and returns its canonical record. + * + * Exact-name mode keeps this operation suitable for public create surfaces: a + * live sibling with the requested name is a conflict instead of silently + * producing a suffixed copy. Uploads remain the storage and accounting + * primitive, including for an empty buffer. + */ +export async function performCreateWorkspaceFile( + params: PerformCreateWorkspaceFileParams +): Promise { + const { + workspaceId, + userId, + name, + contentType, + folderId, + folderPath, + content = Buffer.alloc(0), + exactName = true, + actorName, + actorEmail, + request, + } = params + + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + return { + success: false, + error: `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit`, + errorCode: 'payload_too_large', + } + } + + try { + const file = await uploadWorkspaceFile(workspaceId, userId, content, name, contentType, { + folderId, + folderPath, + exactName, + }) + + logger.info('Created workspace file', { + workspaceId, + fileId: file.id, + folderId: file.folderId, + size: file.size, + }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}"`, + metadata: { fileSize: file.size, fileType: file.type }, + request, + }) + + captureServerEvent( + userId, + 'file_uploaded', + { workspace_id: workspaceId, file_type: file.type }, + { groups: { workspace: workspaceId } } + ) + + return { success: true, file } + } catch (error) { + logger.error('Failed to create workspace file', { error, workspaceId, folderId, folderPath }) + + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } + + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts new file mode 100644 index 00000000000..019264ed8d1 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -0,0 +1,296 @@ +/** + * @vitest-environment node + * + * Failure classification. Every `perform*` here is consumed by a public v2 route + * that maps `errorCode` straight to an HTTP status, so a manager failure that + * arrives unclassified silently becomes a 500 for what is really a caller-fixable + * 400 or 404. These pin the mapping rather than the happy paths. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMoveWorkspaceFileItems, + mockUpdateWorkspaceFileFolder, + mockCreateWorkspaceFileFolder, + mockRestoreWorkspaceFileFolder, + mockRenameWorkspaceFile, + mockRestoreWorkspaceFile, + mockBulkArchive, + mockCreateWorkspaceFileFolderAtPath, + mockRelocateWorkspaceFileFolderByPath, + mockDeleteWorkspaceFileFolderByPath, +} = vi.hoisted(() => ({ + mockMoveWorkspaceFileItems: vi.fn(), + mockUpdateWorkspaceFileFolder: vi.fn(), + mockCreateWorkspaceFileFolder: vi.fn(), + mockRestoreWorkspaceFileFolder: vi.fn(), + mockRenameWorkspaceFile: vi.fn(), + mockRestoreWorkspaceFile: vi.fn(), + mockBulkArchive: vi.fn(), + mockCreateWorkspaceFileFolderAtPath: vi.fn(), + mockRelocateWorkspaceFileFolderByPath: vi.fn(), + mockDeleteWorkspaceFileFolderByPath: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + moveWorkspaceFileItems: mockMoveWorkspaceFileItems, + updateWorkspaceFileFolder: mockUpdateWorkspaceFileFolder, + createWorkspaceFileFolder: mockCreateWorkspaceFileFolder, + restoreWorkspaceFileFolder: mockRestoreWorkspaceFileFolder, + renameWorkspaceFile: mockRenameWorkspaceFile, + restoreWorkspaceFile: mockRestoreWorkspaceFile, + bulkArchiveWorkspaceFileItems: mockBulkArchive, + createWorkspaceFileFolderAtPath: mockCreateWorkspaceFileFolderAtPath, + relocateWorkspaceFileFolderByPath: mockRelocateWorkspaceFileFolderByPath, + deleteWorkspaceFileFolderByPath: mockDeleteWorkspaceFileFolderByPath, + moveRenameWorkspaceFile: vi.fn(), + FileConflictError: class FileConflictError extends Error {}, + WorkspaceFileFolderConflictError: class WorkspaceFileFolderConflictError extends Error {}, + WorkspaceFileMoveConflictError: class WorkspaceFileMoveConflictError extends Error {}, + WorkspaceFileItemsNotFoundError: class WorkspaceFileItemsNotFoundError extends Error {}, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: new Proxy({}, { get: (_t, k) => String(k) }), + AuditResourceType: new Proxy({}, { get: (_t, k) => String(k) }), +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performCreateWorkspaceFileFolder, + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, + performMoveWorkspaceFileItems, + performRelocateWorkspaceFileFolderByPath, + performRenameWorkspaceFile, + performRestoreWorkspaceFile, + performRestoreWorkspaceFileFolder, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' + +const WS = 'workspace-1' +const USER = 'user-1' + +describe('workspace file orchestration error classification', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps a missing move target to not_found, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + fileIds: ['wf_1'], + targetFolderId: 'fold_missing', + }) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('not_found') + expect(result.error).toBe('Target folder not found') + }) + + it('maps a self-descendant move to validation, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + folderIds: ['fold_1'], + targetFolderId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a folder reparent cycle to validation, not internal', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing folder on update to not_found', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Folder not found') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_missing', + userId: USER, + name: 'Q2', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing parent on create to not_found', async () => { + mockCreateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performCreateWorkspaceFileFolder({ + workspaceId: WS, + userId: USER, + name: 'Q1', + parentId: 'fold_missing', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps restoring into an archived workspace to validation', async () => { + mockRestoreWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') + ) + + const result = await performRestoreWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing file on rename to not_found', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing file on restore to not_found', async () => { + mockRestoreWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + + const result = await performRestoreWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('classifies through a wrapper error chain, as drizzle produces inside a transaction', async () => { + const wrapped = new Error('update "folder" set ... failed', { + cause: new OrchestrationError('validation', 'Folder cannot be its own parent'), + }) + mockUpdateWorkspaceFileFolder.mockRejectedValue(wrapped) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_1', + }) + + expect(result.errorCode).toBe('validation') + expect(result.error).toBe('Folder cannot be its own parent') + }) + + it('leaves a genuinely unexpected fault as internal', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new Error('connection terminated unexpectedly')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_1', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('internal') + }) + + it('delegates path folder mutations to the existing file manager orchestration', async () => { + const folder = { + id: 'internal-folder-id', + workspaceId: WS, + userId: USER, + name: 'Reports', + parentId: null, + path: 'Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + } + mockCreateWorkspaceFileFolderAtPath.mockResolvedValue({ folder, path: '/Reports' }) + mockRelocateWorkspaceFileFolderByPath.mockResolvedValue({ folder, path: '/Archive' }) + mockDeleteWorkspaceFileFolderByPath.mockResolvedValue({ folders: 1, files: 2 }) + + const created = await performCreateWorkspaceFileFolderAtPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + }) + const relocated = await performRelocateWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + destinationPath: '/Archive', + }) + const deleted = await performDeleteWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Archive', + recursive: true, + }) + + expect(created).toMatchObject({ success: true, path: '/Reports' }) + expect(relocated).toMatchObject({ success: true, path: '/Archive' }) + expect(deleted).toEqual({ success: true, deletedItems: { folders: 1, files: 2 } }) + expect(mockDeleteWorkspaceFileFolderByPath).toHaveBeenCalledWith({ + workspaceId: WS, + userId: USER, + path: '/Archive', + recursive: true, + }) + }) + + it('classifies a non-empty non-recursive folder delete as a conflict', async () => { + mockDeleteWorkspaceFileFolderByPath.mockRejectedValue( + new OrchestrationError('conflict', 'Folder is not empty') + ) + + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + recursive: false, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder is not empty', + errorCode: 'conflict', + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 4abfd980bcf..489ca8af9b3 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,13 +1,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { FolderPathError } from '@/lib/folders/paths' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, + createWorkspaceFileFolderAtPath, + deleteWorkspaceFileFolderByPath, FileConflictError, moveRenameWorkspaceFile, moveWorkspaceFileItems, + relocateWorkspaceFileFolderByPath, renameWorkspaceFile, restoreWorkspaceFile, restoreWorkspaceFileFolder, @@ -22,32 +27,23 @@ import { const logger = createLogger('WorkspaceFileFolderLifecycle') -export type WorkspaceFilesOrchestrationErrorCode = - | 'validation' - | 'not_found' - | 'conflict' - | 'internal' - -export function workspaceFilesOrchestrationStatus( - errorCode: WorkspaceFilesOrchestrationErrorCode | undefined -): number { - if (errorCode === 'validation') return 400 - if (errorCode === 'conflict') return 409 - if (errorCode === 'not_found') return 404 - return 500 -} - export interface PerformDeleteWorkspaceFileItemsParams { workspaceId: string userId: string fileIds?: string[] folderIds?: string[] + /** + * Optional originating request, forwarded to the audit log so the deletion + * entry captures client IP / user agent. Omitted by in-app callers that have + * no HTTP request in scope. + */ + request?: { headers: { get(name: string): string | null } } } export interface PerformDeleteWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode deletedItems?: WorkspaceFileArchiveResult } @@ -57,12 +53,13 @@ export interface PerformMoveWorkspaceFileItemsParams { fileIds?: string[] folderIds?: string[] targetFolderId?: string | null + targetFolderPath?: string } export interface PerformMoveWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode movedItems?: { files: number; folders: number } } @@ -76,7 +73,7 @@ export interface PerformRenameWorkspaceFileParams { export interface PerformRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -89,7 +86,7 @@ export interface PerformRestoreWorkspaceFileParams { export interface PerformRestoreWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode } export interface PerformCreateWorkspaceFileFolderParams { @@ -102,7 +99,7 @@ export interface PerformCreateWorkspaceFileFolderParams { export interface PerformCreateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -118,7 +115,7 @@ export interface PerformUpdateWorkspaceFileFolderParams { export interface PerformUpdateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -131,15 +128,121 @@ export interface PerformRestoreWorkspaceFileFolderParams { export interface PerformRestoreWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord restoredItems?: WorkspaceFileArchiveResult } +export interface PerformFileFolderPathMutationResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + folder?: WorkspaceFileFolderRecord + path?: string +} + +export interface PerformDeleteFileFolderByPathResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + deletedItems?: WorkspaceFileArchiveResult +} + +function fileFolderPathError(error: unknown): { + error: string + errorCode: OrchestrationErrorCode +} { + if (error instanceof FolderPathError) { + return { error: error.message, errorCode: 'validation' } + } + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { error: toError(error).message, errorCode: 'conflict' } + } + const classified = asOrchestrationError(error) + if (classified) return { error: classified.message, errorCode: classified.code } + return { error: toError(error).message, errorCode: 'internal' } +} + +export async function performCreateWorkspaceFileFolderAtPath(params: { + workspaceId: string + userId: string + path: string +}): Promise { + try { + const result = await createWorkspaceFileFolderAtPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceName: result.folder.name, + description: `Created file folder "${result.folder.name}"`, + metadata: { path: result.path }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, folder: { ...result.folder, path: result.path }, path: result.path } + } catch (error) { + logger.error('Failed to create workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + +export async function performRelocateWorkspaceFileFolderByPath(params: { + workspaceId: string + userId: string + path: string + destinationPath: string +}): Promise { + try { + const result = await relocateWorkspaceFileFolderByPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceName: result.folder.name, + description: `Moved file folder to "${result.path}"`, + metadata: { sourcePath: params.path, destinationPath: result.path }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, folder: { ...result.folder, path: result.path }, path: result.path } + } catch (error) { + logger.error('Failed to relocate workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + +export async function performDeleteWorkspaceFileFolderByPath(params: { + workspaceId: string + userId: string + path: string + recursive: boolean +}): Promise { + try { + const deletedItems = await deleteWorkspaceFileFolderByPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + description: `Deleted file folder "${params.path}"`, + metadata: { path: params.path, affected: deletedItems }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, deletedItems } + } catch (error) { + logger.error('Failed to delete workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [] } = params + const { workspaceId, userId, fileIds = [], folderIds = [], request } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -174,6 +277,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -192,6 +296,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } @@ -199,6 +304,10 @@ export async function performDeleteWorkspaceFileItems( return { success: true, deletedItems } } catch (error) { logger.error('Failed to delete workspace file items', { error }) + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -206,7 +315,14 @@ export async function performDeleteWorkspaceFileItems( export async function performMoveWorkspaceFileItems( params: PerformMoveWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [], targetFolderId } = params + const { + workspaceId, + userId, + fileIds = [], + folderIds = [], + targetFolderId, + targetFolderPath, + } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -222,6 +338,7 @@ export async function performMoveWorkspaceFileItems( fileIds, folderIds, targetFolderId, + targetFolderPath, }) const movedItems = { files: moved.movedFiles, folders: moved.movedFolders } @@ -230,6 +347,7 @@ export async function performMoveWorkspaceFileItems( fileIds, folderIds, targetFolderId, + targetFolderPath, movedItems, }) @@ -239,8 +357,8 @@ export async function performMoveWorkspaceFileItems( actorId: userId, action: AuditAction.FILE_MOVED, resourceType: AuditResourceType.FILE, - description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { fileIds, targetFolderId }, + description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId || (targetFolderPath && targetFolderPath !== '/') ? ' to folder' : ' to root'}`, + metadata: { fileIds, targetFolderId, targetFolderPath }, }) } @@ -251,8 +369,8 @@ export async function performMoveWorkspaceFileItems( action: AuditAction.FOLDER_MOVED, resourceType: AuditResourceType.FOLDER, resourceId: folderIds.length === 1 ? folderIds[0] : undefined, - description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { folderIds, targetFolderId }, + description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}${targetFolderId || (targetFolderPath && targetFolderPath !== '/') ? ' to folder' : ' to root'}`, + metadata: { folderIds, targetFolderId, targetFolderPath }, }) } @@ -277,6 +395,10 @@ export async function performMoveWorkspaceFileItems( if (error instanceof WorkspaceFileItemsNotFoundError) { return { success: false, error: error.message, errorCode: 'not_found' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -308,6 +430,10 @@ export async function performRenameWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -323,7 +449,7 @@ export interface PerformMoveRenameWorkspaceFileParams { export interface PerformMoveRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -406,6 +532,10 @@ export async function performRestoreWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -440,6 +570,10 @@ export async function performCreateWorkspaceFileFolder( ) { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -487,6 +621,10 @@ export async function performUpdateWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -528,6 +666,10 @@ export async function performRestoreWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 81c7af23352..1242d5269da 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -1,8 +1,22 @@ +export { + MAX_WORKSPACE_FILE_CONTENT_BYTES, + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + type PerformUpdateWorkspaceFileContentParams, + type PerformUpdateWorkspaceFileContentResult, + performUpdateWorkspaceFileContent, +} from './content' +export { + type PerformCreateWorkspaceFileParams, + type PerformCreateWorkspaceFileResult, + performCreateWorkspaceFile, +} from './create' export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, + type PerformDeleteFileFolderByPathResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, + type PerformFileFolderPathMutationResult, type PerformMoveRenameWorkspaceFileParams, type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, @@ -16,13 +30,22 @@ export { type PerformUpdateWorkspaceFileFolderParams, type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, performDeleteWorkspaceFileItems, performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, + performRelocateWorkspaceFileFolderByPath, performRenameWorkspaceFile, performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, performUpdateWorkspaceFileFolder, - type WorkspaceFilesOrchestrationErrorCode, - workspaceFilesOrchestrationStatus, } from './file-folder-lifecycle' +export { + type PerformGetWorkspaceFileShareParams, + type PerformGetWorkspaceFileShareResult, + type PerformUpsertWorkspaceFileShareParams, + type PerformUpsertWorkspaceFileShareResult, + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from './share' diff --git a/apps/sim/lib/workspace-files/orchestration/share.ts b/apps/sim/lib/workspace-files/orchestration/share.ts new file mode 100644 index 00000000000..350a6d9958a --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/share.ts @@ -0,0 +1,165 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import type { + OrchestrationErrorCode, + OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShareOrchestration') + +export interface PerformGetWorkspaceFileShareParams { + workspaceId: string + fileId: string +} + +export interface PerformGetWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord | null +} + +export interface PerformUpsertWorkspaceFileShareParams { + workspaceId: string + fileId: string + userId: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + /** + * Caller-reserved share token. Only the session UI supplies one, so it can + * show the public link before the share is saved; the public API never does, + * because a caller-chosen token is both guessable and able to collide with an + * existing row's unique index. Omitted means the manager generates one. + */ + token?: string + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpsertWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord +} + +export async function performGetWorkspaceFileShare( + params: PerformGetWorkspaceFileShareParams +): Promise { + const { workspaceId, fileId } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + const share = await getShareForResource('file', fileId) + return { success: true, share } + } catch (error) { + logger.error('Failed to fetch workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +/** + * Enables or disables a file's public share. + * + * Both the access-control gate and the effective-authType resolution live here + * rather than in a route, so the session surface and the public API cannot + * diverge on either. Disabling is deliberately never gated — a user must be able + * to un-share after the org policy is turned on. + * + * Note that disabling is not revoking: {@link upsertFileShare} preserves the + * token and the stored password / allow-list, so re-enabling resurrects the + * identical URL. + */ +export async function performUpsertWorkspaceFileShare( + params: PerformUpsertWorkspaceFileShareParams +): Promise { + const { + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + actorName, + actorEmail, + request, + } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + if (isActive) { + /** + * Validate the auth type that will ACTUALLY be persisted. `upsertFileShare` + * falls back to the existing share's authType when none is passed, so a bare + * re-enable must be checked against that stored mode — not `'public'` — or a + * now-disallowed password/email/sso share could be silently reactivated. + */ + const existingShare = await getShareForResource('file', fileId) + const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(userId, workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) { + logger.warn('Public file sharing disabled for workspace', { workspaceId, fileId }) + return { success: false, error: error.message, errorCode: 'forbidden' } + } + throw error + } + } + + const share = await upsertFileShare({ + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + }) + + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, + request, + }) + + return { success: true, share } + } catch (error) { + if (error instanceof ShareValidationError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + logger.error('Failed to update workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index d3909616345..ce37b78ddd0 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -316,6 +316,17 @@ const nextConfig: NextConfig = { }, ], }, + { + source: '/api/v2/workflows/:id/execute', + headers: [ + { key: 'Cross-Origin-Embedder-Policy', value: 'unsafe-none' }, + { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' }, + { + key: 'Content-Security-Policy', + value: getWorkflowExecutionCSPPolicy(), + }, + ], + }, { // Exclude Vercel internal resources and static assets from strict COOP, Google Drive Picker // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 3dc7da2be17..83627d42a5a 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -90,6 +90,29 @@ describe('resolveApiCorsPolicy', () => { expect(policy.headers).toContain('X-Execution-Id') }) + it('serves v2 workflow execute with wildcard origin and the stream-protocol header', () => { + const policy = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/execute', 'https://other.example') + ) + expect(policy.origin).toBe('*') + expect(policy.credentials).toBe(false) + expect(policy.headers).toContain('X-Execution-Id') + expect(policy.headers).toContain('X-Sim-Stream-Protocol') + // Async is body-selected on v2 — the mode header is deliberately absent. + expect(policy.headers).not.toContain('X-Execution-Mode') + }) + + it('does not match the v2 execute rule for nested or executions paths', () => { + const nested = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/execute/extra', 'https://other.example') + ) + expect(nested.origin).toBe('https://app.sim.test') + const executions = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/executions/e-1', 'https://other.example') + ) + expect(executions.origin).toBe('https://app.sim.test') + }) + it('does not match the workflow execute rule for nested paths', () => { const policy = resolveApiCorsPolicy( makeRequest('/api/workflows/workflow-123/execute/extra', 'https://other.example') @@ -98,7 +121,7 @@ describe('resolveApiCorsPolicy', () => { }) it('returns default policy with APP_URL and credentials for other API routes', () => { - const policy = resolveApiCorsPolicy(makeRequest('/api/files/upload')) + const policy = resolveApiCorsPolicy(makeRequest('/api/files/uploads')) expect(policy).toEqual({ origin: 'https://app.sim.test', credentials: true, @@ -113,7 +136,8 @@ describe('resolveApiCorsPolicy', () => { '/api/mcp/copilot', '/api/chat/abc', '/api/workflows/wf/execute', - '/api/files/upload', + '/api/v2/workflows/wf/execute', + '/api/files/uploads', ] for (const path of paths) { const policy = resolveApiCorsPolicy(makeRequest(path)) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 73d03e9b797..394151d3a51 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -23,6 +23,9 @@ const DEFAULT_API_ALLOWED_HEADERS = const WORKFLOW_EXECUTE_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Execution-Id' +/** v2 execute: async is body-selected (no X-Execution-Mode) and streaming negotiates X-Sim-Stream-Protocol. */ +const WORKFLOW_EXECUTE_V2_HEADERS = `${WORKFLOW_EXECUTE_HEADERS}, X-Sim-Stream-Protocol` + /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) @@ -82,6 +85,18 @@ const CORS_RULES: readonly CorsRule[] = [ headers: WORKFLOW_EXECUTE_HEADERS, }), }, + { + // Mirrors the v1 rule: public execute endpoints are wildcard-origin and + // credential-free — the default credentialed policy would both block + // browser API-key calls and open a cookie-bearing CSRF surface. + match: (p) => /^\/api\/v2\/workflows\/[^/]+\/execute$/.test(p), + policy: () => ({ + origin: '*', + credentials: false, + methods: 'POST,OPTIONS', + headers: WORKFLOW_EXECUTE_V2_HEADERS, + }), + }, ] /** Single source of truth for /api/* CORS — resolved at request time, not baked at build. */ diff --git a/apps/sim/stores/table/import-tray/store.ts b/apps/sim/stores/table/import-tray/store.ts index 174485acb61..9b547b5f6f4 100644 --- a/apps/sim/stores/table/import-tray/store.ts +++ b/apps/sim/stores/table/import-tray/store.ts @@ -2,12 +2,12 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' /** - * An in-flight client upload, shown optimistically before its server import row exists or the - * table list has refreshed. Keyed by `uploadId`: a `pending_*` id (creating a new table, no row - * yet) or the target tableId (append/replace into an existing table). + * An in-flight client upload, shown after its signed upload session is created but before the + * table list has refreshed. `uploadId` is the import id across upload and processing. */ export interface ImportUpload { uploadId: string + tableId?: string workspaceId: string title: string /** Byte-based upload percent from the client XHR. */ diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3d20a4faaaf..2cdbae4f8fa 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1504,6 +1504,27 @@ export async function executeTool( // tool registry never pulls in the executor/db dependency graph (a static or // dynamic executor import in the tool descriptor itself would break the client // build — and with it `getTool('workflow_executor')`). + // Workflow-as-agent-tool runs in-process through WorkflowBlockHandler — + // the same invocation boundary canvas child workflows use. Replaces the + // historical HTTP hop to /api/workflows/{id}/execute (double admission + // slot + duplicate top-level log row); billing/observability now match the + // canvas workflow block. + if (normalizedToolId === 'workflow_executor') { + logger.info(`[${requestId}] Running workflow tool ${toolId} in-process`) + const { runWorkflowTool } = await import('@/executor/handlers/workflow/workflow-tool-runner') + const result = await runWorkflowTool(contextParams) + const endTime = new Date() + return { + ...result, + output: postProcessToolOutput(normalizedToolId, result.output ?? {}), + timing: { + startTime: startTimeISO, + endTime: endTime.toISOString(), + duration: endTime.getTime() - startTime.getTime(), + }, + } + } + if (normalizedToolId === 'deployed_block_executor') { logger.info(`[${requestId}] Running custom block tool ${toolId}`) const { runCustomBlockTool } = await import( diff --git a/helm/sim/tests/networkpolicy_test.yaml b/helm/sim/tests/networkpolicy_test.yaml index 817b829b3b1..9f062ed3a25 100644 --- a/helm/sim/tests/networkpolicy_test.yaml +++ b/helm/sim/tests/networkpolicy_test.yaml @@ -212,4 +212,3 @@ tests: except: - "169.254.169.254/32" - "169.254.170.2/32" - diff --git a/package.json b/package.json index 888d925fd52..d1ad2b78427 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "check": "turbo run format:check", "check:boundaries": "bun run scripts/check-monorepo-boundaries.ts", "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", + "check:openapi": "bun run scripts/check-openapi-specs.ts", "check:cron-parity": "bun run scripts/check-cron-parity.ts", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", diff --git a/packages/db/migrations/0282_real_kang.sql b/packages/db/migrations/0282_real_kang.sql new file mode 100644 index 00000000000..c4ee77d7e2a --- /dev/null +++ b/packages/db/migrations/0282_real_kang.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_files" ADD COLUMN "size_bytes" bigint; \ No newline at end of file diff --git a/packages/db/migrations/meta/0282_snapshot.json b/packages/db/migrations/meta/0282_snapshot.json new file mode 100644 index 00000000000..12720d408e8 --- /dev/null +++ b/packages/db/migrations/meta/0282_snapshot.json @@ -0,0 +1,18405 @@ +{ + "id": "1b640a15-944f-413a-9a2f-fc08f67856bc", + "prevId": "8ffb620a-92ff-49d1-95be-a429b4c04e52", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f0fe5d692cf..e965b58fe13 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1968,6 +1968,13 @@ "when": 1785776917545, "tag": "0281_fixed_madame_web", "breakpoints": true + }, + { + "idx": 282, + "version": "7", + "when": 1785813019807, + "tag": "0282_real_kang", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 37876e913e7..45f12034bd8 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1914,7 +1914,10 @@ export const workspaceFiles = pgTable( */ displayName: text('display_name'), contentType: text('content_type').notNull(), + // contract-pending(after #6188 is fully deployed and sizeBytes is backfilled): drop size — new code dual-writes and reads sizeBytes first size: integer('size').notNull(), + /** Exact byte size for files above PostgreSQL's int4 ceiling; legacy rows fall back to `size`. */ + sizeBytes: bigint('size_bytes', { mode: 'number' }), deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 455c90f66c8..c6612ef276f 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -12,6 +12,8 @@ export function createMockSql() { toSQL: () => ({ sql: strings.join('?'), params: values }), /** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */ as: (alias: string) => ({ ...fragment, alias }), + /** Mirrors drizzle's `sql``…`.mapWith(Number)` result decoder. */ + mapWith: (_decoder: unknown) => fragment, } return fragment } diff --git a/packages/testing/src/mocks/folders-lifecycle.mock.ts b/packages/testing/src/mocks/folders-lifecycle.mock.ts deleted file mode 100644 index 52a3a281144..00000000000 --- a/packages/testing/src/mocks/folders-lifecycle.mock.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { vi } from 'vitest' - -/** - * Controllable mock functions for `@/lib/folders/lifecycle` — the generic, - * resourceType-driven folder engine behind every `/api/folders` route. - * All defaults are bare `vi.fn()` — configure per-test as needed. - * - * @example - * ```ts - * import { foldersLifecycleMockFns } from '@sim/testing' - * - * foldersLifecycleMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder }) - * ``` - */ -export const foldersLifecycleMockFns = { - mockCreateFolder: vi.fn(), - mockUpdateFolder: vi.fn(), - mockDeleteFolder: vi.fn(), - mockRestoreFolder: vi.fn(), -} - -/** - * Static mock module for `@/lib/folders/lifecycle`. - * - * @example - * ```ts - * vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock) - * ``` - */ -export const foldersLifecycleMock = { - createFolder: foldersLifecycleMockFns.mockCreateFolder, - updateFolder: foldersLifecycleMockFns.mockUpdateFolder, - deleteFolder: foldersLifecycleMockFns.mockDeleteFolder, - restoreFolder: foldersLifecycleMockFns.mockRestoreFolder, -} diff --git a/packages/testing/src/mocks/folders-orchestration.mock.ts b/packages/testing/src/mocks/folders-orchestration.mock.ts new file mode 100644 index 00000000000..eec11efad4f --- /dev/null +++ b/packages/testing/src/mocks/folders-orchestration.mock.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/folders/orchestration` — the generic, + * resourceType-driven folder engine behind every `/api/folders` route. + * All defaults are bare `vi.fn()` — configure per-test as needed. + * + * @example + * ```ts + * import { foldersOrchestrationMockFns } from '@sim/testing' + * + * foldersOrchestrationMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder }) + * ``` + */ +export const foldersOrchestrationMockFns = { + mockCreateFolder: vi.fn(), + mockUpdateFolder: vi.fn(), + mockDeleteFolder: vi.fn(), + mockRestoreFolder: vi.fn(), +} + +/** + * Static mock module for `@/lib/folders/orchestration`. + * + * @example + * ```ts + * vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) + * ``` + */ +export const foldersOrchestrationMock = { + createFolder: foldersOrchestrationMockFns.mockCreateFolder, + updateFolder: foldersOrchestrationMockFns.mockUpdateFolder, + deleteFolder: foldersOrchestrationMockFns.mockDeleteFolder, + restoreFolder: foldersOrchestrationMockFns.mockRestoreFolder, +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 7ef622c4d61..f5d7f1c0d7d 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -96,8 +96,10 @@ export { mockNextFetchResponse, setupGlobalFetchMock, } from './fetch.mock' -// Generic folder engine mocks (for @/lib/folders/lifecycle) -export { foldersLifecycleMock, foldersLifecycleMockFns } from './folders-lifecycle.mock' +export { + foldersOrchestrationMock, + foldersOrchestrationMockFns, +} from './folders-orchestration.mock' // Hybrid auth mocks export { hybridAuthMock, hybridAuthMockFns } from './hybrid-auth.mock' // Input validation mocks diff --git a/packages/testing/src/mocks/storage-service.mock.ts b/packages/testing/src/mocks/storage-service.mock.ts index effa9666f11..a61c5a381fd 100644 --- a/packages/testing/src/mocks/storage-service.mock.ts +++ b/packages/testing/src/mocks/storage-service.mock.ts @@ -20,7 +20,6 @@ export const storageServiceMockFns = { mockDeleteFile: vi.fn(), mockHeadObject: vi.fn(), mockGeneratePresignedUploadUrl: vi.fn(), - mockGenerateBatchPresignedUploadUrls: vi.fn(), mockGeneratePresignedDownloadUrl: vi.fn(), mockHasCloudStorage: vi.fn(() => false), mockGetS3InfoForKey: vi.fn(), @@ -40,7 +39,6 @@ export const storageServiceMock = { deleteFile: storageServiceMockFns.mockDeleteFile, headObject: storageServiceMockFns.mockHeadObject, generatePresignedUploadUrl: storageServiceMockFns.mockGeneratePresignedUploadUrl, - generateBatchPresignedUploadUrls: storageServiceMockFns.mockGenerateBatchPresignedUploadUrls, generatePresignedDownloadUrl: storageServiceMockFns.mockGeneratePresignedDownloadUrl, hasCloudStorage: storageServiceMockFns.mockHasCloudStorage, getS3InfoForKey: storageServiceMockFns.mockGetS3InfoForKey, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9cc418490c6..001621c4f3a 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: 1008, - zodRoutes: 1008, + totalRoutes: 1091, + zodRoutes: 1091, nonZodRoutes: 0, } as const @@ -24,7 +24,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookLocalSchemaConstructors: 0, clientHookRawFetches: 0, clientSameOriginApiFetches: 0, - doubleCasts: 9, + doubleCasts: 8, rawJsonReads: 5, untypedResponses: 0, annotationsMissingReason: 0, diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts new file mode 100644 index 00000000000..5fa5ad826a5 --- /dev/null +++ b/scripts/check-openapi-specs.ts @@ -0,0 +1,511 @@ +#!/usr/bin/env bun +/** + * Validates the hand-authored OpenAPI specs in `apps/docs/` against each other + * and against the runtime Zod contracts in `apps/sim/lib/api/contracts/`. + * + * The Zod contracts are the runtime source of truth for *success* request and + * response shapes, but the specs additionally carry what Zod never defines — + * error envelopes, status codes, prose, and examples — so the specs cannot be + * generated and must instead be checked: + * + * 1. Spec integrity (every file): all `$ref`s resolve, operationIds are + * present and unique, every operation documents a success response, no + * orphaned component schemas. + * 2. v2 conventions (every `/api/v2/` operation): 401 and 429 are documented, + * and every documented 4xx/5xx resolves to the canonical error envelope + * `{ error: { code, message } }`. + * 3. Contract cross-check: every contract exported from + * `lib/api/contracts/v2/*` must be documented, every documented `/api/v2/` + * operation must have a contract, and for each pair the query params, + * body fields, and response fields are diffed via `z.toJSONSchema`. + * 4. Examples: documented request/response examples are parsed with the + * matching contract's actual Zod schemas — a doc example that the runtime + * would reject fails the build. + */ + +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +const ROOT = path.resolve(import.meta.dir, '..') +const DOCS_DIR = path.join(ROOT, 'apps/docs') +const V2_CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') + +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', +] + +/** Extra non-v2 contracts that are documented in the core spec. */ +const EXTRA_CONTRACT_MODULES = [path.join(ROOT, 'apps/sim/lib/api/contracts/usage-limits.ts')] + +type Json = Record +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']) + +const errors: string[] = [] +const fail = (spec: string, msg: string) => errors.push(`${spec}: ${msg}`) + +interface ContractLike { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + response?: { mode: string; schema?: z.ZodType } +} + +function isContract(value: unknown): value is ContractLike { + return ( + !!value && + typeof value === 'object' && + typeof (value as ContractLike).method === 'string' && + typeof (value as ContractLike).path === 'string' && + typeof (value as ContractLike).response === 'object' + ) +} + +/** `[tableId]` (contract) → `{tableId}` (OpenAPI). */ +const contractKey = (c: ContractLike) => + `${c.method.toUpperCase()} ${c.path.replace(/\[([^\]]+)\]/g, '{$1}')}` + +async function loadContracts(): Promise> { + const registry = new Map() + const files = readdirSync(V2_CONTRACTS_DIR) + .filter((f) => f.endsWith('.ts') && f !== 'shared.ts') + .map((f) => path.join(V2_CONTRACTS_DIR, f)) + for (const file of [...files, ...EXTRA_CONTRACT_MODULES]) { + const mod = (await import(file)) as Record + for (const [name, value] of Object.entries(mod)) { + if (!isContract(value)) continue + const key = contractKey(value) + const existing = registry.get(key) + if (existing) { + // A route may expose narrowing variants of one operation (e.g. the + // batch-create alias) — keep the first, they share the wire. + continue + } + registry.set(key, { name, contract: value }) + } + } + return registry +} + +function resolveRef(ref: string, spec: Json): unknown { + let current: unknown = spec + for (const part of ref.replace('#/', '').split('/')) { + if (!current || typeof current !== 'object') return undefined + current = (current as Json)[part] + } + return current +} + +/** Follow at most one level of `$ref` chains until a concrete node. */ +function deref(node: unknown, spec: Json): unknown { + let current = node + for (let i = 0; i < 8; i++) { + if (current && typeof current === 'object' && typeof (current as Json).$ref === 'string') { + current = resolveRef((current as Json).$ref as string, spec) + } else { + return current + } + } + return current +} + +function walkRefs(node: unknown, visit: (ref: string) => void): void { + if (Array.isArray(node)) { + for (const item of node) walkRefs(item, visit) + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') visit(value) + else walkRefs(value, visit) + } + } +} + +/** + * Top-level property names of a documented JSON schema, unioning `oneOf` / + * `anyOf` / `allOf` variants. Returns `null` when the schema is opaque + * (no `properties` anywhere), in which case comparison is skipped. + */ +function docPropertyNames(schema: unknown, spec: Json): Set | null { + const node = deref(schema, spec) + if (!node || typeof node !== 'object') return null + const record = node as Json + const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined + if (variants) { + const names = new Set() + let sawAny = false + for (const variant of variants) { + const sub = docPropertyNames(variant, spec) + if (sub) { + sawAny = true + for (const n of sub) names.add(n) + } + } + return sawAny ? names : null + } + if (record.properties && typeof record.properties === 'object') { + return new Set(Object.keys(record.properties as Json)) + } + return null +} + +function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json | null { + try { + return z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json + } catch { + return null + } +} + +interface Operation { + specFile: string + path: string + method: string + op: Json + spec: Json +} + +function collectOperations(specFile: string, spec: Json): Operation[] { + const ops: Operation[] = [] + for (const [p, methods] of Object.entries((spec.paths as Json) ?? {})) { + if (!methods || typeof methods !== 'object') continue + for (const [method, op] of Object.entries(methods as Json)) { + if (!HTTP_METHODS.has(method)) continue + ops.push({ specFile, path: p, method, op: op as Json, spec }) + } + } + return ops +} + +function checkIntegrity(specFile: string, spec: Json, ops: Operation[]): void { + walkRefs(spec, (ref) => { + if (resolveRef(ref, spec) === undefined) fail(specFile, `unresolved $ref ${ref}`) + }) + + const seenIds = new Set() + for (const { path: p, method, op } of ops) { + const label = `${method.toUpperCase()} ${p}` + const id = op.operationId + if (typeof id !== 'string' || !id) { + fail(specFile, `${label}: missing operationId`) + } else if (seenIds.has(id)) { + fail(specFile, `${label}: duplicate operationId "${id}"`) + } else { + seenIds.add(id) + } + const responses = (op.responses as Json) ?? {} + if (!Object.keys(responses).some((code) => code.startsWith('2'))) { + fail(specFile, `${label}: no documented 2xx response`) + } + } + + const schemas = ((spec.components as Json)?.schemas as Json) ?? {} + const blobWithout = (name: string) => + JSON.stringify({ + ...spec, + components: { ...(spec.components as Json), schemas: { ...schemas, [name]: null } }, + }) + for (const name of Object.keys(schemas)) { + if (!blobWithout(name).includes(`"#/components/schemas/${name}"`)) { + fail(specFile, `orphaned component schema "${name}" (unreferenced)`) + } + } +} + +function checkV2Conventions(operation: Operation): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + const responses = (op.responses as Json) ?? {} + + for (const code of ['401', '429']) { + if (!(code in responses)) fail(specFile, `${label}: v2 operation missing ${code} response`) + } + + for (const [code, response] of Object.entries(responses)) { + if (!/^[45]/.test(code)) continue + const resolved = deref(response, spec) as Json | undefined + const schema = deref( + ((resolved?.content as Json)?.['application/json'] as Json)?.schema, + spec + ) as Json | undefined + // A bodyless error (e.g. a bare 413) documents intent without a schema. + if (!schema) continue + const errorProp = deref((schema.properties as Json)?.error, spec) as Json | undefined + const inner = errorProp?.properties as Json | undefined + if (!inner || !('code' in inner) || !('message' in inner)) { + fail( + specFile, + `${label}: ${code} response is not the canonical v2 error envelope { error: { code, message } }` + ) + } + } +} + +function checkQueryParams(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + if (!contract.query) return + const zodSchema = toJsonSchema(contract.query, 'input') + if (!zodSchema?.properties) return + + const docParams = new Map() + for (const raw of (op.parameters as unknown[]) ?? []) { + const param = deref(raw, spec) as Json | undefined + if (param?.in === 'query' && typeof param.name === 'string') docParams.set(param.name, param) + } + + const zodProps = Object.keys(zodSchema.properties as Json) + const zodRequired = new Set((zodSchema.required as string[]) ?? []) + for (const prop of zodProps) { + const doc = docParams.get(prop) + if (!doc) { + fail(specFile, `${label}: query param "${prop}" (${name}) is not documented`) + } else if (Boolean(doc.required) !== zodRequired.has(prop)) { + fail( + specFile, + `${label}: query param "${prop}" required mismatch (contract ${zodRequired.has(prop) ? 'required' : 'optional'}, docs ${doc.required ? 'required' : 'optional'})` + ) + } + } + for (const docName of docParams.keys()) { + if (!zodProps.includes(docName)) { + fail(specFile, `${label}: documented query param "${docName}" does not exist on ${name}`) + } + } +} + +/** Property subschema lookup, searching `oneOf`/`anyOf`/`allOf` variants. */ +function propertyNode(schema: unknown, root: Json, prop: string): unknown { + const node = deref(schema, root) + if (!node || typeof node !== 'object') return undefined + const record = node as Json + const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined + if (variants) { + for (const variant of variants) { + const found = propertyNode(variant, root, prop) + if (found !== undefined) return found + } + return undefined + } + return (record.properties as Json | undefined)?.[prop] +} + +/** Deref + step through array wrappers so item objects compare directly. */ +function unwrapArrays(node: unknown, root: Json): unknown { + let current = deref(node, root) + for (let i = 0; i < 3; i++) { + const record = current as Json | null + if (record && typeof record === 'object' && record.type === 'array' && record.items) { + current = deref(record.items, root) + } else { + break + } + } + return current +} + +interface DiffContext { + specFile: string + label: string + name: string + where: 'body' | 'response' +} + +/** + * Recursively diffs property-name sets between the Zod-derived JSON schema and + * the documented one, descending through matching object properties and array + * items. Comparison happens only where BOTH sides expose a property set — an + * opaque side (records, `additionalProperties`, prose-only docs) ends the + * descent instead of producing false positives. The Zod root doubles as the + * `$defs` resolution context for recursive schemas. + */ +function diffSchemaFields( + zodNode: unknown, + zodRoot: Json, + docNode: unknown, + docRoot: Json, + ctx: DiffContext, + prefix: string, + depth: number +): void { + if (depth > 4) return + const zodObj = unwrapArrays(zodNode, zodRoot) + const docObj = unwrapArrays(docNode, docRoot) + const zodNames = docPropertyNames(zodObj, zodRoot) + const docNames = docPropertyNames(docObj, docRoot) + if (!zodNames || !docNames) return + const fieldPath = (n: string) => (prefix ? `${prefix}.${n}` : n) + /** + * A `.passthrough()` contract deliberately under-declares its fields, so the + * docs are allowed to document more than the Zod side names. + */ + const extra = (zodObj as Json).additionalProperties + const zodIsPassthrough = + extra === true || (!!extra && typeof extra === 'object' && Object.keys(extra).length === 0) + for (const n of zodNames) { + if (!docNames.has(n)) { + fail( + ctx.specFile, + `${ctx.label}: ${ctx.where} field "${fieldPath(n)}" (${ctx.name}) not documented` + ) + } + } + for (const n of docNames) { + if (!zodNames.has(n) && !zodIsPassthrough) { + fail( + ctx.specFile, + `${ctx.label}: documented ${ctx.where} field "${fieldPath(n)}" does not exist on ${ctx.name}` + ) + } + } + for (const n of zodNames) { + if (!docNames.has(n)) continue + diffSchemaFields( + propertyNode(zodObj, zodRoot, n), + zodRoot, + propertyNode(docObj, docRoot, n), + docRoot, + ctx, + fieldPath(n), + depth + 1 + ) + } +} + +function checkBodyAndResponse(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + + const docBodySchema = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && docBodySchema?.schema) { + const zodRoot = toJsonSchema(contract.body, 'input') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docBodySchema.schema, + spec, + { specFile, label, name, where: 'body' }, + '', + 0 + ) + } + } + + if (contract.response?.mode === 'json' && contract.response.schema) { + const responses = (op.responses as Json) ?? {} + const successCode = Object.keys(responses).find((code) => code.startsWith('2')) + const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined + const docSchema = ((docResponse?.content as Json)?.['application/json'] as Json)?.schema + if (docSchema) { + const zodRoot = toJsonSchema(contract.response.schema, 'output') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docSchema, + spec, + { specFile, label, name, where: 'response' }, + '', + 0 + ) + } + } + } +} + +function checkExamples(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + + const bodyContent = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && bodyContent) { + const candidates: Array<[string, unknown]> = [] + if (bodyContent.example !== undefined) candidates.push(['example', bodyContent.example]) + for (const [exName, ex] of Object.entries((bodyContent.examples as Json) ?? {})) { + candidates.push([exName, (ex as Json).value]) + } + for (const [exName, value] of candidates) { + const parsed = contract.body.safeParse(value) + if (!parsed.success) { + fail( + specFile, + `${label}: request example "${exName}" rejected by ${name}: ${parsed.error.issues[0]?.message}` + ) + } + } + } + + if (contract.response?.mode === 'json' && contract.response.schema) { + const responses = (op.responses as Json) ?? {} + const successCode = Object.keys(responses).find((code) => code.startsWith('2')) + const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined + const content = (docResponse?.content as Json)?.['application/json'] as Json | undefined + if (content?.example !== undefined) { + const parsed = contract.response.schema.safeParse(content.example) + if (!parsed.success) { + const issue = parsed.error.issues[0] + fail( + specFile, + `${label}: response example rejected by ${name} at ${issue?.path.join('.') || ''}: ${issue?.message}` + ) + } + } + } +} + +const registry = await loadContracts() +const documentedKeys = new Set() + +for (const specFile of SPEC_FILES) { + const spec = JSON.parse(readFileSync(path.join(DOCS_DIR, specFile), 'utf8')) as Json + const ops = collectOperations(specFile, spec) + checkIntegrity(specFile, spec, ops) + + for (const operation of ops) { + const key = `${operation.method.toUpperCase()} ${operation.path}` + documentedKeys.add(key) + const isV2 = operation.path.startsWith('/api/v2/') + if (isV2) checkV2Conventions(operation) + + const entry = registry.get(key) + if (!entry) { + // The core spec's execution/HITL surface predates the contract registry; + // only the v2 surface requires a contract for every documented operation. + if (isV2) fail(specFile, `${key}: documented but no contract exports this route`) + continue + } + checkQueryParams(operation, entry.contract, entry.name) + checkBodyAndResponse(operation, entry.contract, entry.name) + checkExamples(operation, entry.contract, entry.name) + } +} + +for (const [key, { name }] of registry) { + if (!key.includes('/api/v2/')) continue + if (!documentedKeys.has(key)) { + errors.push(`registry: ${name} (${key}) is not documented in any OpenAPI spec`) + } +} + +if (errors.length > 0) { + console.error( + `OpenAPI spec validation failed (${errors.length} issue${errors.length === 1 ? '' : 's'}):` + ) + for (const message of errors) console.error(` - ${message}`) + process.exit(1) +} +console.log( + `OpenAPI spec validation passed: ${SPEC_FILES.length} specs, ${documentedKeys.size} operations, ${registry.size} contracts cross-checked.` +)