From bfec5b8b307ae95ac5fa06a4b8f941420b642d29 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 19:51:51 -0700 Subject: [PATCH 1/3] feat(api): complete the v2 workflows resource with versions and CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds version listing/detail plus create, update, and delete to the v2 workflows surface, which previously covered only execution and deployment. - GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first - GET /api/v2/workflows/[id]/versions/[version] — version + pinned state - POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id] All six delegate to the existing orchestration and persistence helpers; no new domain logic. --- apps/docs/openapi-v2-workflows.json | 765 +++++++++++++++++- apps/sim/app/api/v1/middleware.ts | 2 + .../app/api/v2/workflows/[id]/route.test.ts | 312 +++++++ apps/sim/app/api/v2/workflows/[id]/route.ts | 280 +++++-- .../[id]/versions/[version]/route.test.ts | 154 ++++ .../[id]/versions/[version]/route.ts | 74 ++ .../v2/workflows/[id]/versions/route.test.ts | 176 ++++ .../api/v2/workflows/[id]/versions/route.ts | 99 +++ apps/sim/app/api/v2/workflows/route.test.ts | 202 +++++ apps/sim/app/api/v2/workflows/route.ts | 77 +- apps/sim/lib/api/contracts/deployments.ts | 2 +- apps/sim/lib/api/contracts/v2/workflows.ts | 144 ++++ scripts/check-api-validation-contracts.ts | 4 +- 13 files changed, 2190 insertions(+), 101 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/route.ts create mode 100644 apps/sim/app/api/v2/workflows/route.test.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 95b85a369f2..90a9869da23 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workflows", - "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "description": "Version 2 of the Sim REST API for managing workflows (create, list, inspect, update, delete), their deployment versions, and deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -23,7 +23,7 @@ "tags": [ { "name": "Workflows", - "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + "description": "Create, list, inspect, update, and delete workflows, enumerate their deployment versions, and manage deployments (deploy, undeploy, rollback) on the v2 API." } ], "security": [ @@ -163,30 +163,546 @@ "$ref": "#/components/responses/InternalError" } } + }, + "post": { + "operationId": "createWorkflowV2", + "summary": "Create Workflow", + "description": "Create an empty workflow in a workspace. The workflow is created with a default start block and no deployment, so it must be edited and deployed before it can be executed. Names must be unique within the target folder — a collision is reported as 409 rather than silently renamed.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Customer Support Agent\"\n }'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowBody" + }, + "examples": { + "minimal": { + "summary": "At the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Customer Support Agent" + } + }, + "inFolder": { + "summary": "Inside a folder, with a description", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowListItem" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-06-29T21:30:00.000Z", + "updatedAt": "2026-06-29T21:30:00.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A workflow with the same name already exists in the target folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateWorkflowV2", + "summary": "Update Workflow", + "description": "Rename a workflow, change its description, or move it between folders. Omitted fields keep their stored values, and at least one field must be supplied. Editing the workflow's graph is not part of this endpoint — use import/export for that. Returns 404 when the workflow does not exist or you do not have write access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Customer Support Agent v2\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "name": "Customer Support Agent v2" + } + }, + "moveToRoot": { + "summary": "Move out of its folder to the workspace root", + "value": { + "folderId": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowListItem" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent v2", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-06-29T21:30:00.000Z", + "updatedAt": "2026-06-30T08:12:00.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "A workflow with the target name already exists in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteWorkflowV2", + "summary": "Delete Workflow", + "description": "Archive a workflow. The workflow moves to Recently Deleted rather than being dropped, so its execution logs stay attributable, and it stops being returned by the list and detail endpoints. The last remaining workflow in a workspace cannot be deleted (400).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The workflow was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeleteWorkflowResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, - "/api/v2/workflows/{id}": { + "/api/v2/workflows/{id}/versions": { "get": { - "operationId": "getWorkflow", - "summary": "Get Workflow", - "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "operationId": "listWorkflowVersionsV2", + "summary": "List Workflow Versions", + "description": "List a workflow's deployment versions, newest first. Every successful deploy appends a version; these are the version numbers `POST /api/v2/workflows/{id}/rollback` accepts. Results are cursor-paginated — follow `nextCursor` and stop when it is `null`.", "tags": ["Workflows"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ { "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of versions to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "The requested workflow.", + "description": "A page of deployment versions, newest first.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Deployment versions for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowVersion" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": "active" + }, + { + "id": "b70e2c81-4d93-4a17-8f52-93a1c7e0d6b8", + "version": 2, + "name": null, + "description": null, + "isActive": false, + "createdAt": "2026-05-02T09:04:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/versions/{version}": { + "get": { + "operationId": "getWorkflowVersionV2", + "summary": "Get Workflow Version", + "description": "Fetch one deployment version and the workflow state it pins. Use this to inspect or diff a version before activating it with `POST /api/v2/workflows/{id}/rollback`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions/{version}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "$ref": "#/components/parameters/VersionNumber" + } + ], + "responses": { + "200": { + "description": "The requested deployment version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -205,43 +721,32 @@ "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/WorkflowDetail" + "$ref": "#/components/schemas/WorkflowVersionDetail" } } }, "example": { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "variables": { - "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { - "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", - "name": "supportEmail", - "type": "string", - "value": "support@example.com" - } - }, - "inputs": [ - { - "name": "ticketBody", - "type": "string", - "description": "The raw text of the incoming support ticket." - } - ], - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + } } } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, @@ -1268,6 +1773,17 @@ "type": "string", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } + }, + "VersionNumber": { + "name": "version", + "in": "path", + "required": true, + "description": "The deployment version number, as returned by the version list.", + "schema": { + "type": "integer", + "minimum": 1, + "example": 3 + } } }, "headers": { @@ -1763,6 +2279,185 @@ "type": "number" } } + }, + "CreateWorkflowBody": { + "type": "object", + "description": "Request body for creating a workflow.", + "required": ["workspaceId", "name"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the workflow in. Requires write access.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name. Must be unique within the target folder.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "minLength": 1, + "nullable": true, + "description": "Folder to create the workflow in. Omit or send `null` to create it at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + }, + "UpdateWorkflowBody": { + "type": "object", + "description": "Request body for updating a workflow's metadata. Omitted fields keep their stored values; at least one field is required.", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New workflow name. Must be unique within the destination folder.", + "example": "Customer Support Agent v2" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "New description. Send `null` to clear it.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "minLength": 1, + "nullable": true, + "description": "Destination folder. Send `null` to move the workflow to the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + }, + "DeleteWorkflowResult": { + "type": "object", + "description": "Acknowledgement that a workflow was archived.", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The archived workflow's identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "deleted": { + "type": "boolean", + "enum": [true], + "description": "Always `true` on a successful archive." + } + } + }, + "WorkflowVersion": { + "type": "object", + "description": "A deployment version of a workflow, as returned by the version list.", + "required": ["id", "version", "isActive", "createdAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the deployment version record.", + "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24" + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number. Pass this to the rollback endpoint.", + "example": 3 + }, + "name": { + "type": "string", + "nullable": true, + "description": "Optional label given to the version at deploy time. `null` when unset.", + "example": "Adds escalation branch" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional release note for the version. `null` when unset.", + "example": "Routes P1 tickets straight to on-call" + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is the one currently serving executions.", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the version was created.", + "example": "2026-06-12T10:30:00.000Z" + }, + "deployedBy": { + "type": "string", + "nullable": true, + "description": "Display name of the user who deployed the version. `null` when the deployer is no longer resolvable.", + "example": "Ada Lovelace" + }, + "latestOperationStatus": { + "type": "string", + "nullable": true, + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Lifecycle status of the workflow's current deploy attempt, present only on the version that attempt targets. `null` on every other version — a superseded attempt is history, not live state.", + "example": "active" + } + } + }, + "WorkflowVersionDetail": { + "type": "object", + "description": "A deployment version together with the workflow state it pins.", + "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the deployment version record.", + "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24" + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number. Pass this to the rollback endpoint.", + "example": 3 + }, + "name": { + "type": "string", + "nullable": true, + "description": "Optional label given to the version at deploy time. `null` when unset.", + "example": "Adds escalation branch" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional release note for the version. `null` when unset.", + "example": "Routes P1 tickets straight to on-call" + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is the one currently serving executions.", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the version was created.", + "example": "2026-06-12T10:30:00.000Z" + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "The deployed workflow graph snapshot (blocks, edges, loops, parallels). This is the state that executes while the version is active, and the state a rollback restores." + } + } } }, "responses": { diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 5fb64caf1df..2f994150296 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -30,6 +30,8 @@ export type ApiEndpoint = | 'workflow-detail' | 'workflow-deploy' | 'workflow-rollback' + | 'workflow-versions' + | 'workflow-version-detail' | 'workflow-export' | 'workflow-import' | 'audit-logs' diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts new file mode 100644 index 00000000000..96a5694d8d0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -0,0 +1,312 @@ +/** + * @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, + 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(), + 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/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) + mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ name: 'Support Agent v2' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('423s the denial when the workflow is locked rather than failing with a 500', async () => { + mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('423s when the destination folder is locked', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPatch({ folderId: 'fld-1' }) + expect(res.status).toBe(423) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('409s when the target name is taken in the destination folder', async () => { + mockPerformUpdateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Support Agent v2" already exists in this folder', + errorCode: 'conflict', + }) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('updates the workflow and carries the untouched deployment counters through', async () => { + const res = await callPatch({ name: 'Support Agent v2' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: { + id: 'wf-1', + name: 'Support Agent v2', + description: 'Handles tickets', + folderId: null, + workspaceId: 'workspace-1', + isDeployed: true, + deployedAt: '2024-01-03T00:00:00.000Z', + runCount: 12, + lastRunAt: '2024-01-04T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-05T00:00:00.000Z', + }, + }) + expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + userId: 'user-1', + workspaceId: 'workspace-1', + currentName: 'Support Agent', + currentFolderId: null, + name: 'Support Agent v2', + }) + ) + }) +}) + +describe('DELETE /api/v2/workflows/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is already archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('423s the denial when the workflow is locked rather than failing with a 500', async () => { + mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) + const res = await callDelete() + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('400s when it is the last workflow in the workspace', async () => { + mockPerformDeleteWorkflow.mockResolvedValue({ + success: false, + error: 'Cannot delete the only workflow in the workspace', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('only workflow') + }) + + it('archives the workflow and acknowledges the delete', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } }) + expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 7698187bb7a..5a6b456fab3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,86 +1,242 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +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, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { + type V2WorkflowDetail, + type V2WorkflowListItem, + v2DeleteWorkflowContract, + v2GetWorkflowContract, + v2UpdateWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowDetailAPI') export const revalidate = 0 -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) +interface RouteContext { + params: Promise<{ id: string }> +} - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +/** GET /api/v2/workflows/[id] — Fetch one workflow with its variables and trigger inputs. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) - const userId = rateLimit.userId! + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const userId = rateLimit.userId! - const parsed = await parseRequest(v2GetWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const blockRows = await db - .select({ - id: workflowBlocks.id, - type: workflowBlocks.type, - subBlocks: workflowBlocks.subBlocks, - }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, id)) - - const blocksRecord = Object.fromEntries( - blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) - ) - const inputs = extractInputFieldsFromBlocks(blocksRecord) - - const detail: V2WorkflowDetail = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - folderId: workflowData.folderId, - workspaceId: workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - variables: (workflowData.variables as Record | null) ?? {}, - inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), - } - - return v2Data(detail, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflow details fetch error`, { - error: getErrorMessage(error, 'Unknown error'), + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { name, description, folderId } = parsed.data.body + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) + + const result = await performUpdateWorkflow({ + workflowId: id, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') + } + + const updated = result.workflow + /** + * Deployment and run counters are untouched by a metadata update, so they + * come from the record read above rather than a second query. + */ + const item: V2WorkflowListItem = { + id: updated.id, + name: updated.name, + description: updated.description, + folderId: updated.folderId, + workspaceId: updated.workspaceId ?? workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + } + + return v2Data(item, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return v2Error('LOCKED', error.message) + } + + logger.error(`[${requestId}] Workflow update error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/workflows/[id] — Archive a workflow into Recently Deleted. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + await assertWorkflowMutable(id) + + const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to delete workflow') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Workflow delete error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') } -) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts new file mode 100644 index 00000000000..72e3811cb6e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + * + * Public v2 deployment-version detail: the 404 mask on an access failure, the + * coerced numeric version param, and the pinned workflow state it serves. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockGetWorkflowDeploymentVersion, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockGetWorkflowDeploymentVersion: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } + +const DEPLOYED_STATE = { blocks: {}, edges: [], loops: {}, parallels: {} } + +const VERSION_ROW = { + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: new Date('2024-01-03T00:00:00Z'), + state: DEPLOYED_STATE, +} + +const routeContext = (version = '3') => ({ params: Promise.resolve({ id: 'wf-1', version }) }) +const callGet = (version = '3') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions/${version}`), + routeContext(version) + ) + +describe('GET /api/v2/workflows/[id]/versions/[version]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockGetWorkflowDeploymentVersion.mockResolvedValue(VERSION_ROW) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('400s on a non-numeric version', async () => { + const res = await callGet('latest') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('404s when the version does not exist on this workflow', async () => { + mockGetWorkflowDeploymentVersion.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('Deployment version not found') + }) + + it('returns the version with the workflow state it pins', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: { + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: '2024-01-03T00:00:00.000Z', + state: DEPLOYED_STATE, + }, + }) + expect(mockGetWorkflowDeploymentVersion).toHaveBeenCalledWith('wf-1', 3) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts new file mode 100644 index 00000000000..d8096bf5ea5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowVersionDetail, + v2GetWorkflowVersionContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowVersionDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version + * and the workflow state it pins. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-version-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkflowVersionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id, version } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const row = await getWorkflowDeploymentVersion(id, version) + if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') + + const detail: V2WorkflowVersionDetail = { + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + state: row.state as V2WorkflowVersionDetail['state'], + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow version fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts new file mode 100644 index 00000000000..9fc3d7158c3 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -0,0 +1,176 @@ +/** + * @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 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) + mockListWorkflowVersions.mockResolvedValue({ + versions: [ + buildVersion(3, { + isActive: true, + name: 'Escalation branch', + latestOperationStatus: 'active', + }), + buildVersion(2), + buildVersion(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 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') + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1') + }) + + 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..de0447c948c --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -0,0 +1,99 @@ +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') + + const { versions: rows } = await listWorkflowVersions(id) + + const cursorData = cursor ? decodeCursor(cursor) : null + const remaining = cursorData ? rows.filter((row) => row.version < cursorData.version) : rows + + const hasMore = remaining.length > limit + const page = remaining.slice(0, limit) + + const data: V2WorkflowVersion[] = page.map((row) => ({ + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + deployedBy: row.deployedByName, + // The shared helper widens the operation-status pg enum to `string`. + latestOperationStatus: + row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + })) + + const nextCursor = + hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow versions fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts new file mode 100644 index 00000000000..9640812cb25 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -0,0 +1,202 @@ +/** + * @vitest-environment node + * + * Public v2 workflow creation: the handler order (rate limit → gate → parse → + * workspace access → lib), the 423 a locked destination folder 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, + mockPerformCreateWorkflow, + mockAssertFolderMutable, + FolderLockedErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformCreateWorkflow: vi.fn(), + mockAssertFolderMutable: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mockPerformCreateWorkflow, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: FolderLockedErrorMock, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { POST } from '@/app/api/v2/workflows/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 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) + mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPost(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s when name is missing', async () => { + const res = await callPost({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s on an unknown body field', async () => { + const res = await callPost({ ...VALID_BODY, sortOrder: 3 }) + expect(res.status).toBe(400) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('requires write access on the target workspace', async () => { + await callPost(VALID_BODY) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'write' + ) + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('423s when the destination folder is locked', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('409s when the name is already taken in the target folder', async () => { + mockPerformCreateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Support Agent" already exists in this folder', + errorCode: 'conflict', + }) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the workflow and returns 201 with the public shape', async () => { + const res = await callPost(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body).toEqual({ + data: { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: null, + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + }) + expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: undefined, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index ffe19c9ebf1..c03f3783c80 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,20 +1,28 @@ 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, asc, eq, gt, isNull, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { + type V2WorkflowListItem, + v2CreateWorkflowContract, + v2ListWorkflowsContract, +} from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, v2CursorList, + v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -145,3 +153,70 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return v2Error('INTERNAL_ERROR', 'Internal server error') } }) + +/** POST /api/v2/workflows — Create an empty workflow in a workspace. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateWorkflowContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, folderId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + await assertFolderMutable(folderId ?? null) + + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') + } + + const created = result.workflow + const item: V2WorkflowListItem = { + id: created.id, + name: created.name, + description: created.description ?? null, + folderId: created.folderId ?? null, + workspaceId: created.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + } + + return v2Data(item, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Workflow create error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index db8e82adb6b..1d87bfe4edb 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -8,7 +8,7 @@ import { } from '@/lib/workflows/deployment-lifecycle' import type { WorkflowState } from '@/stores/workflows/workflow/types' -const deployedWorkflowStateSchema = z.custom( +export const deployedWorkflowStateSchema = z.custom( (value) => typeof value === 'object' && value !== null, 'Expected workflow state' ) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index b721f347b7c..5981a15c5df 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1,4 +1,10 @@ import { z } from 'zod' +import { + deployedWorkflowStateSchema, + deploymentVersionParamsSchema, + deploymentVersionSchema, +} from '@/lib/api/contracts/deployments' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1DeployWorkflowDataSchema, @@ -24,6 +30,11 @@ import { * 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. */ export const v2WorkflowListItemSchema = z.object({ @@ -90,6 +101,139 @@ export const v2GetWorkflowContract = defineRouteContract({ }, }) +/** + * Create body. `workspaceId` is required — personal (workspace-less) workflows + * are not creatable on any surface. Name collisions inside the target folder + * are a 409 rather than being silently deduplicated: a public caller that asked + * for a name should learn it was taken, not discover "My Agent (2)" later. + */ +export const v2CreateWorkflowBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + /** Explicit `null` (or omission) creates the workflow at the workspace root. */ + folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + }) + .strict() +export type V2CreateWorkflowBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowBodySchema = z + .object({ + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.folderId === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or folderId is required', + }) + } + }) +export type V2UpdateWorkflowBody = z.input + +/** + * Delete acknowledgement. Deletion archives the workflow (it lands in Recently + * Deleted) rather than dropping its rows, so runs and logs stay attributable. + */ +export const v2DeleteWorkflowDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2DeleteWorkflowData = z.output + +export const v2CreateWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows', + body: v2CreateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2UpdateWorkflowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + body: v2UpdateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2DeleteWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowDataSchema), + }, +}) + +/** + * A deployment version as the public surface sees it: the internal row minus + * `createdBy`, which is a raw user id with no public resolution path — + * `deployedBy` already carries the human-readable name. + */ +export const v2WorkflowVersionSchema = deploymentVersionSchema.omit({ createdBy: true }) +export type V2WorkflowVersion = z.output + +/** + * Version listing is cursor-paginated: a workflow accrues one version per + * deploy and nothing prunes them, so the set is unbounded. The cursor is keyed + * on the version number, which is dense and strictly descending. + */ +export const v2ListWorkflowVersionsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) +export type V2ListWorkflowVersionsQuery = z.output + +/** + * A single version plus the workflow state it pins. `state` is the deployed + * graph snapshot — the same portable blob the internal deployment reader + * serves — and is the thing a caller diffs before rolling back to it. + */ +export const v2WorkflowVersionDetailSchema = z.object({ + id: z.string(), + version: z.number().int().positive(), + name: z.string().nullable(), + description: z.string().nullable(), + isActive: z.boolean(), + createdAt: z.string(), + state: deployedWorkflowStateSchema, +}) +export type V2WorkflowVersionDetail = z.output + +export const v2ListWorkflowVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + params: workflowIdParamsSchema, + query: v2ListWorkflowVersionsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowVersionSchema), + }, +}) + +export const v2GetWorkflowVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + params: deploymentVersionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowVersionDetailSchema), + }, +}) + export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c52c7f59a55..f8a7a617938 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1046, - zodRoutes: 1046, + totalRoutes: 1048, + zodRoutes: 1048, nonZodRoutes: 0, } as const From c86d2fcefdf75c096b97a2a4da349b3e796c733e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 20:05:23 -0700 Subject: [PATCH 2/3] fix(api): check folder containment before lock state; reject malformed version cursors assertFolderMutable walks a folder's ancestor chain without filtering on workspace, so inspecting it before containment let a caller tell a locked folder in someone else's workspace (423) from a nonexistent one (400). Create and update now assert containment first, matching the ordering import-workflow.ts already uses. A version cursor that decodes to JSON without a numeric version filtered every row out and returned an empty page with nextCursor null, which reads as a clean end-of-list. Malformed cursors are now a 400. --- .../app/api/v2/workflows/[id]/route.test.ts | 43 ++++++++++++++++++ apps/sim/app/api/v2/workflows/[id]/route.ts | 10 +++++ .../v2/workflows/[id]/versions/route.test.ts | 17 +++++++ .../api/v2/workflows/[id]/versions/route.ts | 14 +++++- apps/sim/app/api/v2/workflows/route.test.ts | 44 +++++++++++++++++++ apps/sim/app/api/v2/workflows/route.ts | 15 ++++++- 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 96a5694d8d0..432027d8cc9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -17,8 +17,10 @@ const { mockPerformDeleteWorkflow, mockAssertWorkflowMutable, mockAssertFolderMutable, + mockAssertFolderInWorkspace, WorkflowLockedErrorMock, FolderLockedErrorMock, + FolderNotFoundErrorMock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), @@ -27,12 +29,16 @@ const { mockPerformDeleteWorkflow: vi.fn(), mockAssertWorkflowMutable: vi.fn(), mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), WorkflowLockedErrorMock: class WorkflowLockedError extends Error { status = 423 }, FolderLockedErrorMock: class FolderLockedError extends Error { status = 423 }, + FolderNotFoundErrorMock: class FolderNotFoundError extends Error { + status = 400 + }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -49,8 +55,10 @@ vi.mock('@sim/platform-authz/workflow', () => ({ getActiveWorkflowRecord: mockGetActiveWorkflowRecord, assertWorkflowMutable: mockAssertWorkflowMutable, assertFolderMutable: mockAssertFolderMutable, + assertFolderInWorkspace: mockAssertFolderInWorkspace, WorkflowLockedError: WorkflowLockedErrorMock, FolderLockedError: FolderLockedErrorMock, + FolderNotFoundError: FolderNotFoundErrorMock, })) vi.mock('@/lib/workflows/input-format', () => ({ @@ -139,6 +147,7 @@ describe('PATCH /api/v2/workflows/[id]', () => { mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) mockAssertWorkflowMutable.mockResolvedValue(undefined) mockAssertFolderMutable.mockResolvedValue(undefined) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) }) @@ -196,6 +205,40 @@ describe('PATCH /api/v2/workflows/[id]', () => { expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() }) + it('400s a folder outside the workspace without ever reading its lock state', async () => { + mockAssertFolderInWorkspace.mockRejectedValue( + new FolderNotFoundErrorMock('Target folder not found') + ) + const res = await callPatch({ folderId: 'fld-other-workspace' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + // Containment runs first, so a locked foreign folder cannot be told apart + // from a nonexistent one by its status code. + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('checks folder containment against the workflow workspace before mutability', async () => { + const order: string[] = [] + mockAssertFolderInWorkspace.mockImplementation(async () => { + order.push('containment') + }) + mockAssertFolderMutable.mockImplementation(async () => { + order.push('mutability') + }) + + await callPatch({ folderId: 'fld-1' }) + + expect(order).toEqual(['containment', 'mutability']) + expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + }) + + it('skips the containment check on a rename that does not move the workflow', async () => { + await callPatch({ name: 'Support Agent v2' }) + expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + }) + it('409s when the target name is taken in the destination folder', async () => { mockPerformUpdateWorkflow.mockResolvedValue({ success: false, diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 5a6b456fab3..c5ed7aeaac5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -2,9 +2,11 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { + assertFolderInWorkspace, assertFolderMutable, assertWorkflowMutable, FolderLockedError, + FolderNotFoundError, getActiveWorkflowRecord, WorkflowLockedError, } from '@sim/platform-authz/workflow' @@ -140,6 +142,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout ) if (access) return v2Error('NOT_FOUND', 'Workflow not found') + /** + * Ownership before lock state: `assertFolderMutable` walks the folder's + * ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace + * (423) from one that simply does not exist (400). + */ + if (folderId) await assertFolderInWorkspace(folderId, workflowData.workspaceId) await assertWorkflowMutable(id) if (folderId !== undefined) await assertFolderMutable(folderId) @@ -180,6 +189,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout return v2Data(item, { rateLimit }) } catch (error) { + if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { return v2Error('LOCKED', error.message) } 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 index 9fc3d7158c3..6b94382a5fa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -160,6 +160,23 @@ describe('GET /api/v2/workflows/[id]/versions', () => { expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1') }) + 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() diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index de0447c948c..db984e4c4bd 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -64,10 +64,20 @@ export const GET = withRouteHandler( 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') + } + const { versions: rows } = await listWorkflowVersions(id) - const cursorData = cursor ? decodeCursor(cursor) : null - const remaining = cursorData ? rows.filter((row) => row.version < cursorData.version) : rows + const remaining = after ? rows.filter((row) => row.version < after.version) : rows const hasMore = remaining.length > limit const page = remaining.slice(0, limit) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 9640812cb25..40706ec6972 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -13,15 +13,21 @@ const { mockResolveWorkspaceAccess, mockPerformCreateWorkflow, mockAssertFolderMutable, + mockAssertFolderInWorkspace, FolderLockedErrorMock, + FolderNotFoundErrorMock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockPerformCreateWorkflow: vi.fn(), mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), FolderLockedErrorMock: class FolderLockedError extends Error { status = 423 }, + FolderNotFoundErrorMock: class FolderNotFoundError extends Error { + status = 400 + }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -35,7 +41,9 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@sim/platform-authz/workflow', () => ({ assertFolderMutable: mockAssertFolderMutable, + assertFolderInWorkspace: mockAssertFolderInWorkspace, FolderLockedError: FolderLockedErrorMock, + FolderNotFoundError: FolderNotFoundErrorMock, })) vi.mock('@/app/api/v2/lib/gate', () => ({ @@ -98,6 +106,7 @@ describe('POST /api/v2/workflows', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockAssertFolderMutable.mockResolvedValue(undefined) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) }) @@ -157,6 +166,41 @@ describe('POST /api/v2/workflows', () => { expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) + it('400s a folder outside the workspace without ever reading its lock state', async () => { + mockAssertFolderInWorkspace.mockRejectedValue( + new FolderNotFoundErrorMock('Target folder not found') + ) + const res = await callPost({ ...VALID_BODY, folderId: 'fld-other-workspace' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + // Containment runs first, so a locked foreign folder cannot be told apart + // from a nonexistent one by its status code. + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('checks folder containment before mutability', async () => { + const order: string[] = [] + mockAssertFolderInWorkspace.mockImplementation(async () => { + order.push('containment') + }) + mockAssertFolderMutable.mockImplementation(async () => { + order.push('mutability') + }) + + await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + + expect(order).toEqual(['containment', 'mutability']) + expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + }) + + it('skips the containment check when no folder is supplied', async () => { + await callPost(VALID_BODY) + expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) + }) + it('409s when the name is already taken in the target folder', async () => { mockPerformCreateWorkflow.mockResolvedValue({ success: false, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index c03f3783c80..c343f837093 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,7 +1,12 @@ 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 { + assertFolderInWorkspace, + assertFolderMutable, + FolderLockedError, + FolderNotFoundError, +} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' @@ -180,6 +185,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) + /** + * Ownership before lock state: `assertFolderMutable` walks the folder's + * ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace + * (423) from one that simply does not exist (400). + */ + if (folderId) await assertFolderInWorkspace(folderId, workspaceId) await assertFolderMutable(folderId ?? null) const result = await performCreateWorkflow({ @@ -212,6 +224,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return v2Data(item, { rateLimit, status: 201 }) } catch (error) { + if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) logger.error(`[${requestId}] Workflow create error`, { From a745600237ff1a7b6eb57e512a450a6d64620da5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:11:17 -0700 Subject: [PATCH 3/3] refactor(api): page workflow versions in the persistence helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listWorkflowVersions read every version row and the route filtered and sliced the result in memory, so the response was bounded but the query was not. It now takes optional limit/afterVersion, turning the cursor into a real keyset query; the route asks for limit + 1 and only trims the has-more probe. Both params are optional, so the internal, v1 admin, and copilot callers are unchanged. Also restores the untouched GET handler in [id]/route.ts to its original formatting — collapsing its signature had re-indented the whole body and buried the actual additions in whitespace churn. --- apps/sim/app/api/v2/workflows/[id]/route.ts | 117 +++++++++--------- .../v2/workflows/[id]/versions/route.test.ts | 52 ++++++-- .../api/v2/workflows/[id]/versions/route.ts | 12 +- apps/sim/lib/workflows/persistence/utils.ts | 53 +++++--- 4 files changed, 143 insertions(+), 91 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index c5ed7aeaac5..a3b22e05dc0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -43,71 +43,72 @@ interface RouteContext { params: Promise<{ id: string }> } -/** GET /api/v2/workflows/[id] — Fetch one workflow with its variables and trigger inputs. */ -export const GET = 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) +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - 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') + const userId = rateLimit.userId! - // 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 gate = await v2ApiGateError(userId) + if (gate) return gate - const blockRows = await db - .select({ - id: workflowBlocks.id, - type: workflowBlocks.type, - subBlocks: workflowBlocks.subBlocks, + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, id)) - - const blocksRecord = Object.fromEntries( - blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) - ) - const inputs = extractInputFieldsFromBlocks(blocksRecord) - - const detail: V2WorkflowDetail = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - folderId: workflowData.folderId, - workspaceId: workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - variables: (workflowData.variables as Record | null) ?? {}, - inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') } - - 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) => { 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 index 6b94382a5fa..53025c2d07d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -74,6 +74,12 @@ function buildVersion(version: number, overrides: Record = {}) } } +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( @@ -87,17 +93,21 @@ describe('GET /api/v2/workflows/[id]/versions', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockListWorkflowVersions.mockResolvedValue({ - versions: [ - buildVersion(3, { - isActive: true, - name: 'Escalation branch', - latestOperationStatus: 'active', - }), - buildVersion(2), - buildVersion(1), - ], - }) + /** + * 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 () => { @@ -157,7 +167,25 @@ describe('GET /api/v2/workflows/[id]/versions', () => { latestOperationStatus: 'active', }) expect(body.data[0]).not.toHaveProperty('createdBy') - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1') + // 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 () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index db984e4c4bd..82c26667795 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -75,12 +75,14 @@ export const GET = withRouteHandler( return v2Error('BAD_REQUEST', 'Invalid cursor') } - const { versions: rows } = await listWorkflowVersions(id) - - const remaining = after ? rows.filter((row) => row.version < after.version) : rows + // 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 = remaining.length > limit - const page = remaining.slice(0, limit) + const hasMore = rows.length > limit + const page = rows.slice(0, limit) const data: V2WorkflowVersion[] = page.map((row) => ({ id: row.id, 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