From 6266b527550499676a9d530eb6596a66de3db22f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 19:48:00 -0700 Subject: [PATCH 1/4] feat(api): add workflow export and import endpoints to the public v1 API Adds GET /api/v1/workflows/[id]/export and POST /api/v1/workflows/import. The export envelope is accepted verbatim by import, so workflows round-trip between workspaces over the public API. Unlike the admin export, the public export is secret-sanitized: stored credentials and password fields are redacted while {{ENV_VAR}} references and block positions are preserved. Import regenerates block, edge, loop and parallel ids and de-duplicates the workflow name against the target folder. Also moves parseWorkflowVariables out of the admin types module into lib/workflows/variables/parse.ts so the public route does not import from the admin namespace. --- .../(generated)/workflows/meta.json | 2 + apps/docs/openapi.json | 371 ++++++++++++++++++ .../api/v1/admin/folders/[id]/export/route.ts | 7 +- apps/sim/app/api/v1/admin/types.ts | 47 --- .../v1/admin/workflows/[id]/export/route.ts | 7 +- .../api/v1/admin/workflows/export/route.ts | 7 +- .../v1/admin/workspaces/[id]/export/route.ts | 10 +- apps/sim/app/api/v1/middleware.ts | 2 + .../v1/workflows/[id]/export/route.test.ts | 224 +++++++++++ .../app/api/v1/workflows/[id]/export/route.ts | 170 ++++++++ .../app/api/v1/workflows/import/route.test.ts | 364 +++++++++++++++++ apps/sim/app/api/v1/workflows/import/route.ts | 277 +++++++++++++ apps/sim/lib/api/contracts/v1/workflows.ts | 132 ++++++- apps/sim/lib/workflows/variables/parse.ts | 55 +++ scripts/check-api-validation-contracts.ts | 4 +- 15 files changed, 1608 insertions(+), 71 deletions(-) create mode 100644 apps/sim/app/api/v1/workflows/[id]/export/route.test.ts create mode 100644 apps/sim/app/api/v1/workflows/[id]/export/route.ts create mode 100644 apps/sim/app/api/v1/workflows/import/route.test.ts create mode 100644 apps/sim/app/api/v1/workflows/import/route.ts create mode 100644 apps/sim/lib/workflows/variables/parse.ts diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 491129e3cdf..8e2caa1abe8 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -5,6 +5,8 @@ "cancelExecution", "listWorkflows", "getWorkflow", + "exportWorkflow", + "importWorkflow", "deployWorkflow", "undeployWorkflow", "rollbackWorkflow", diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 49d6e7ed91b..8c8a5b4919a 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -1035,6 +1035,143 @@ } } }, + "/api/v1/workflows/import": { + "post": { + "operationId": "importWorkflow", + "summary": "Import Workflow", + "description": "Create a new workflow in a workspace from an export payload produced by GET /api/v1/workflows/{id}/export. Block, edge, loop and parallel identifiers are regenerated, so the same payload can be imported repeatedly and alongside its source workflow. The workflow name is de-duplicated against the target folder. Requires write permission on the target workspace.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/workflows/import\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64\", \"workflow\": {\"version\": \"1.0\", \"state\": {\"blocks\": {}, \"edges\": []}}}'" + } + ], + "requestBody": { + "required": true, + "description": "The target workspace and the workflow payload to import.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "workflow"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to import the workflow into. Requires write permission.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "folderId": { + "type": "string", + "minLength": 1, + "description": "Optional folder to place the imported workflow in. Defaults to the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Optional override for the imported workflow's name. Defaults to the name carried in the payload, then to \"Imported Workflow\".", + "example": "Customer Support Agent (copy)" + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Optional override for the imported workflow's description. Defaults to the description carried in the payload.", + "example": "Imported from the staging workspace" + }, + "workflow": { + "description": "The workflow to import. Accepts the export envelope returned by GET /api/v1/workflows/{id}/export, a bare workflow state object (`{ blocks, edges, ... }`), or a JSON string of either.", + "oneOf": [ + { + "$ref": "#/components/schemas/WorkflowExport" + }, + { + "type": "object", + "additionalProperties": true, + "description": "A bare workflow state object." + }, + { + "type": "string", + "minLength": 1, + "description": "A JSON string of either accepted object form." + } + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The workflow was imported.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The newly created workflow.", + "$ref": "#/components/schemas/ImportedWorkflow" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "7d2e9f14-3a6b-4c8d-b1e5-9f0a2c7d4e83", + "name": "Customer Support Agent (1)", + "description": "Routes incoming support tickets", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderId": null, + "createdAt": "2026-06-20T14:15:22Z", + "updatedAt": "2026-06-20T14:15:22Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "description": "The request body exceeds the 10 MB import limit.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Request body exceeds the maximum allowed size" + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, "/api/v1/workflows/{id}": { "get": { "operationId": "getWorkflow", @@ -1104,6 +1241,92 @@ } } }, + "/api/v1/workflows/{id}/export": { + "get": { + "operationId": "exportWorkflow", + "summary": "Export Workflow", + "description": "Export a workflow as a portable JSON envelope that POST /api/v1/workflows/import accepts verbatim. Stored credentials and secret field values are redacted from the payload, while `{{ENV_VAR}}` references and block positions are preserved. Requires read permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v1/workflows/{id}/export\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "responses": { + "200": { + "description": "The workflow export envelope.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The workflow export envelope.", + "$ref": "#/components/schemas/WorkflowExport" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "version": "1.0", + "exportedAt": "2026-06-20T14:15:22Z", + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderId": null + }, + "state": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {}, + "metadata": { + "name": "Customer Support Agent", + "description": "Routes incoming support tickets", + "exportedAt": "2026-06-20T14:15:22Z" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, "/api/v1/workflows/{id}/deploy": { "post": { "operationId": "deployWorkflow", @@ -7299,6 +7522,154 @@ } } } + }, + "WorkflowExportState": { + "type": "object", + "description": "The workflow graph. Block, edge, loop and parallel identifiers are regenerated on import, so a payload can be imported repeatedly without colliding with its source workflow.", + "properties": { + "blocks": { + "type": "object", + "description": "Map of block id to block definition, including canvas position and sub-block values.", + "additionalProperties": true, + "example": {} + }, + "edges": { + "type": "array", + "description": "Connections between blocks.", + "items": { + "type": "object", + "additionalProperties": true + }, + "example": [] + }, + "loops": { + "type": "object", + "description": "Loop container definitions, keyed by loop id.", + "additionalProperties": true, + "example": {} + }, + "parallels": { + "type": "object", + "description": "Parallel container definitions, keyed by parallel id.", + "additionalProperties": true, + "example": {} + }, + "metadata": { + "type": "object", + "description": "The source workflow's name, description and export timestamp.", + "properties": { + "name": { + "type": "string", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "example": "Routes incoming support tickets" + }, + "exportedAt": { + "type": "string", + "format": "date-time", + "example": "2026-06-20T14:15:22Z" + } + } + }, + "variables": { + "type": "object", + "description": "Workflow-level variables, keyed by variable id.", + "additionalProperties": true, + "example": {} + } + } + }, + "WorkflowExport": { + "type": "object", + "description": "A portable workflow export envelope. Pass this object straight back to POST /api/v1/workflows/import to recreate the workflow. Stored credentials and secret field values are redacted; `{{ENV_VAR}}` references are preserved.", + "properties": { + "version": { + "type": "string", + "description": "Export format version.", + "enum": ["1.0"], + "example": "1.0" + }, + "exportedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the export was generated.", + "example": "2026-06-20T14:15:22Z" + }, + "workflow": { + "type": "object", + "description": "Identity of the exported workflow at the time of export.", + "properties": { + "id": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "example": "Routes incoming support tickets" + }, + "workspaceId": { + "type": "string", + "nullable": true, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "folderId": { + "type": "string", + "nullable": true, + "example": null + } + } + }, + "state": { + "$ref": "#/components/schemas/WorkflowExportState" + } + } + }, + "ImportedWorkflow": { + "type": "object", + "description": "The workflow created by an import.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the newly created workflow.", + "example": "7d2e9f14-3a6b-4c8d-b1e5-9f0a2c7d4e83" + }, + "name": { + "type": "string", + "description": "The workflow name after de-duplication against the target folder.", + "example": "Customer Support Agent (1)" + }, + "description": { + "type": "string", + "nullable": true, + "example": "Routes incoming support tickets" + }, + "workspaceId": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "folderId": { + "type": "string", + "nullable": true, + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-06-20T14:15:22Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-06-20T14:15:22Z" + } + } } }, "responses": { diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 6591ee39e19..ddec95e57d3 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -21,6 +21,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -28,11 +29,7 @@ import { notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import { - type FolderExportPayload, - parseWorkflowVariables, - type WorkflowExportState, -} from '@/app/api/v1/admin/types' +import type { FolderExportPayload, WorkflowExportState } from '@/app/api/v1/admin/types' const logger = createLogger('AdminFolderExportAPI') diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 4754de31054..77e5d7f6366 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -327,53 +327,6 @@ export interface WorkspaceImportResponse { // Utility Functions // ============================================================================= -/** - * Parse workflow variables from database JSON format to Record format. - * Handles both legacy Array and current Record formats. - */ -export function parseWorkflowVariables( - dbVariables: DbWorkflow['variables'] -): Record | undefined { - if (!dbVariables) return undefined - - try { - const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables - - // Handle legacy Array format by converting to Record - if (Array.isArray(varsObj)) { - const result: Record = {} - for (const v of varsObj) { - result[v.id] = { - id: v.id, - name: v.name, - type: v.type, - value: v.value, - } - } - return result - } - - // Already Record format - normalize and return - if (typeof varsObj === 'object' && varsObj !== null) { - const result: Record = {} - for (const [key, v] of Object.entries(varsObj)) { - const variable = v as { id: string; name: string; type: VariableType; value: unknown } - result[key] = { - id: variable.id, - name: variable.name, - type: variable.type, - value: variable.value, - } - } - return result - } - } catch { - // pass - } - - return undefined -} - /** * Extract workflow metadata from various export formats. * Handles both full export payload and raw state formats. diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts index f5207ada0a7..cf321d1aeb3 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts @@ -14,17 +14,14 @@ import { adminV1ExportWorkflowContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import { - parseWorkflowVariables, - type WorkflowExportPayload, - type WorkflowExportState, -} from '@/app/api/v1/admin/types' +import type { WorkflowExportPayload, WorkflowExportState } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowExportAPI') diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index f3f90f968cd..ed5779f52bf 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -26,17 +26,14 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - parseWorkflowVariables, - type WorkflowExportPayload, - type WorkflowExportState, -} from '@/app/api/v1/admin/types' +import type { WorkflowExportPayload, WorkflowExportState } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowsExportAPI') diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index e8913c74e0f..06204ab2898 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -22,6 +22,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -29,11 +30,10 @@ import { notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import { - type FolderExportPayload, - parseWorkflowVariables, - type WorkflowExportState, - type WorkspaceExportPayload, +import type { + FolderExportPayload, + WorkflowExportState, + WorkspaceExportPayload, } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkspaceExportAPI') diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 61ec99a5abd..c9f757d91df 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -22,6 +22,8 @@ export type V1Endpoint = | 'workflow-detail' | 'workflow-deploy' | 'workflow-rollback' + | 'workflow-export' + | 'workflow-import' | 'audit-logs' | 'tables' | 'table-detail' diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.test.ts new file mode 100644 index 00000000000..01097f8b22b --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + * + * Tests for GET /api/v1/workflows/[id]/export — verifies auth, workspace + * permission enforcement (masked as 404), payload shape, secret sanitization, + * and edge-handle normalization. + */ + +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockValidateWorkspaceAccess, + mockLoadWorkflowFromNormalizedTables, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), + mockLoadWorkflowFromNormalizedTables: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + createRateLimitResponse: vi.fn(() => + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ), + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mockLoadWorkflowFromNormalizedTables, +})) + +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn().mockResolvedValue({}), + createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { WORKFLOW_EXPORTED: 'workflow.exported' }, + AuditResourceType: { WORKFLOW: 'workflow' }, +})) + +/** + * Overrides the global registry mock (whose blocks declare no subBlocks) so + * `sanitizeForExport` has a `password: true` field to actually redact. + */ +vi.mock('@/blocks/registry', () => ({ + getBlock: vi.fn(() => ({ + name: 'Starter', + description: 'Mock block', + icon: () => null, + subBlocks: [ + { id: 'apiKey', type: 'short-input', password: true }, + { id: 'endpoint', type: 'short-input' }, + { id: 'secretFromEnv', type: 'short-input', password: true }, + ], + outputs: {}, + })), + getAllBlocks: vi.fn(() => []), + getLatestBlock: vi.fn(() => undefined), + getBlockByToolName: vi.fn(() => undefined), +})) + +import { GET } from '@/app/api/v1/workflows/[id]/export/route' + +const WORKFLOW_ID = 'wf-1' + +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + description: 'Does a thing', + workspaceId: 'ws-1', + folderId: 'folder-1', + variables: { + 'var-1': { id: 'var-1', name: 'apiHost', type: 'string', value: 'https://example.com' }, + }, +} + +const NORMALIZED_STATE = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: 'sk-super-secret' }, + endpoint: { id: 'endpoint', type: 'short-input', value: 'https://api.example.com' }, + secretFromEnv: { id: 'secretFromEnv', type: 'short-input', value: '{{MY_SECRET}}' }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [ + { + id: 'edge-1', + source: 'block-1', + target: 'block-2', + sourceHandle: null, + targetHandle: 'target', + }, + ], + loops: {}, + parallels: {}, + isFromNormalizedTables: true, +} + +function makeContext(id = WORKFLOW_ID) { + return { params: Promise.resolve({ id }) } +} + +function makeRequest() { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/export` + ) +} + +describe('GET /api/v1/workflows/[id]/export', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockLoadWorkflowFromNormalizedTables.mockResolvedValue(NORMALIZED_STATE) + }) + + it('returns 401 when the API key is rejected', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: false }) + + const response = await GET(makeRequest(), makeContext()) + + expect(response.status).toBe(401) + }) + + it('returns 404 when the workflow does not exist', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null) + + const response = await GET(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + }) + + it('masks a permission failure as 404 so callers cannot probe existence', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await GET(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Workflow not found' }) + }) + + it('returns 404 when the workflow row exists but its state does not', async () => { + mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) + + const response = await GET(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + }) + + it('returns the export envelope with workflow metadata and state', async () => { + const response = await GET(makeRequest(), makeContext()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.version).toBe('1.0') + expect(typeof body.data.exportedAt).toBe('string') + expect(body.data.workflow).toEqual({ + id: WORKFLOW_ID, + name: 'My Workflow', + description: 'Does a thing', + workspaceId: 'ws-1', + folderId: 'folder-1', + }) + expect(body.data.state.metadata).toMatchObject({ + name: 'My Workflow', + description: 'Does a thing', + }) + expect(Object.keys(body.data.state.blocks)).toEqual(['block-1']) + }) + + it('strips secret sub-block values but preserves env-var references', async () => { + const response = await GET(makeRequest(), makeContext()) + const body = await response.json() + + const subBlocks = body.data.state.blocks['block-1'].subBlocks + expect(JSON.stringify(body)).not.toContain('sk-super-secret') + expect(subBlocks.apiKey.value).toBeNull() + expect(subBlocks.endpoint.value).toBe('https://api.example.com') + expect(subBlocks.secretFromEnv.value).toBe('{{MY_SECRET}}') + }) + + it('normalizes null edge handles to omitted values', async () => { + const response = await GET(makeRequest(), makeContext()) + const body = await response.json() + + const [edge] = body.data.state.edges + expect(edge.sourceHandle).toBeUndefined() + expect(edge.targetHandle).toBe('target') + }) + + it('records a workflow-exported audit entry', async () => { + await GET(makeRequest(), makeContext()) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.exported', + resourceId: WORKFLOW_ID, + workspaceId: 'ws-1', + actorId: 'user-1', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts new file mode 100644 index 00000000000..cec8b54c503 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -0,0 +1,170 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import type { Edge } from 'reactflow' +import { + type V1WorkflowExportPayload, + v1ExportWorkflowContract, +} from '@/lib/api/contracts/v1/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' + +const logger = createLogger('V1WorkflowExportAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] + +/** + * Projects a persisted ReactFlow edge onto the wire shape declared by the + * response contract. Field-by-field rather than a spread because ReactFlow's + * `Edge` is looser than the contract in three places: handles are `null` when + * unset (the contract and the importer both model absent as `undefined`), + * `label` is a `ReactNode`, and the marker fields accept an `EdgeMarker` + * object. Non-serializable values in those slots are dropped rather than + * emitted as `{}`. + */ +function toExportedEdge(edge: Edge): ExportedEdge { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + type: edge.type, + animated: edge.animated, + style: edge.style as Record | undefined, + data: edge.data, + label: typeof edge.label === 'string' ? edge.label : undefined, + labelStyle: edge.labelStyle as Record | undefined, + labelShowBg: edge.labelShowBg, + labelBgStyle: edge.labelBgStyle as Record | undefined, + labelBgPadding: edge.labelBgPadding, + labelBgBorderRadius: edge.labelBgBorderRadius, + markerStart: typeof edge.markerStart === 'string' ? edge.markerStart : undefined, + markerEnd: typeof edge.markerEnd === 'string' ? edge.markerEnd : undefined, + } +} + +/** + * GET /api/v1/workflows/[id]/export + * + * Exports a workflow as a portable JSON envelope that + * `POST /api/v1/workflows/import` accepts verbatim. + * + * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits + * the raw state for backup/restore, this surface runs the payload through + * `sanitizeForExport`: stored credentials and secret sub-block values are + * stripped while `{{ENV_VAR}}` references and block positions are preserved. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-export') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1ExportWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (accessError) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const normalizedData = await loadWorkflowFromNormalizedTables(id) + if (!normalizedData) { + return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) + } + + const sanitized = sanitizeForExport({ + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: workflowData.name, + description: workflowData.description ?? undefined, + }, + variables: parseWorkflowVariables(workflowData.variables), + }) + + const payload: V1WorkflowExportPayload = { + version: '1.0', + exportedAt: sanitized.exportedAt, + workflow: { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + workspaceId: workflowData.workspaceId, + folderId: workflowData.folderId, + }, + state: { + ...sanitized.state, + edges: sanitized.state.edges.map(toExportedEdge), + metadata: { + ...sanitized.state.metadata, + name: workflowData.name, + description: workflowData.description ?? undefined, + exportedAt: sanitized.exportedAt, + }, + }, + } + + recordAudit({ + workspaceId: workflowData.workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowData.id, + resourceName: workflowData.name, + description: `Exported workflow "${workflowData.name}" via the API`, + metadata: { + workspaceId: workflowData.workspaceId, + folderId: workflowData.folderId || undefined, + blocksCount: Object.keys(payload.state.blocks).length, + edgesCount: payload.state.edges.length, + }, + request, + }) + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse({ data: payload }, limits, rateLimit) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow export error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts new file mode 100644 index 00000000000..e4aa0c90554 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -0,0 +1,364 @@ +/** + * @vitest-environment node + * + * Tests for POST /api/v1/workflows/import — verifies auth, write-permission + * enforcement, payload-shape tolerance (export envelope / bare state / JSON + * string), metadata resolution, variable persistence, and rollback when + * persisting the imported state fails. + */ + +import { createMockRequest } from '@sim/testing' +import { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockValidateWorkspaceAccess, + mockPerformCreateWorkflow, + mockSaveWorkflowToNormalizedTables, + mockParseWorkflowJson, + mockAssertFolderMutable, + mockDbDelete, + mockDbUpdate, + mockWorkspaceRows, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), + mockPerformCreateWorkflow: vi.fn(), + mockSaveWorkflowToNormalizedTables: vi.fn(), + mockParseWorkflowJson: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockDbDelete: vi.fn(), + mockDbUpdate: vi.fn(), + mockWorkspaceRows: { value: [{ id: 'ws-1' }] as Array<{ id: string }> }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + createRateLimitResponse: vi.fn(() => + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ), + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mockPerformCreateWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, +})) + +vi.mock('@/lib/workflows/operations/import-export', () => ({ + parseWorkflowJson: mockParseWorkflowJson, +})) + +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn().mockResolvedValue({}), + createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })), +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => mockWorkspaceRows.value), + })), + })), + })), + delete: mockDbDelete, + update: mockDbUpdate, + }, +})) + +import { POST } from '@/app/api/v1/workflows/import/route' + +const WORKSPACE_ID = 'ws-1' +const CREATED_AT = new Date('2026-07-01T00:00:00Z') + +const PARSED_STATE = { + blocks: { 'block-1': { id: 'block-1', type: 'starter', position: { x: 0, y: 0 } } }, + edges: [], + loops: {}, + parallels: {}, + variables: undefined as unknown, +} + +const EXPORT_ENVELOPE = { + version: '1.0', + exportedAt: '2026-07-01T00:00:00.000Z', + workflow: { + id: 'wf-source', + name: 'Payload Name', + description: 'Payload description', + workspaceId: 'ws-other', + folderId: null, + }, + state: { blocks: PARSED_STATE.blocks, edges: [], loops: {}, parallels: {} }, +} + +function makeRequest(body: unknown) { + return createMockRequest('POST', body, {}, 'http://localhost:3000/api/v1/workflows/import') +} + +function validBody(overrides: Record = {}) { + return { workspaceId: WORKSPACE_ID, workflow: EXPORT_ENVELOPE, ...overrides } +} + +describe('POST /api/v1/workflows/import', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkspaceRows.value = [{ id: WORKSPACE_ID }] + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockParseWorkflowJson.mockReturnValue({ data: { ...PARSED_STATE }, errors: [] }) + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + mockPerformCreateWorkflow.mockResolvedValue({ + success: true, + workflow: { + id: 'wf-new', + name: 'Payload Name', + description: 'Payload description', + workspaceId: WORKSPACE_ID, + folderId: null, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }, + }) + mockDbDelete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockDbUpdate.mockReturnValue({ + set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })), + }) + }) + + it('returns 401 when the API key is rejected', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: false }) + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(401) + }) + + it('rejects a body without workspaceId', async () => { + const response = await POST(makeRequest({ workflow: EXPORT_ENVELOPE })) + + expect(response.status).toBe(400) + }) + + it('rejects a body without a workflow payload', async () => { + const response = await POST(makeRequest({ workspaceId: WORKSPACE_ID })) + + expect(response.status).toBe(400) + }) + + it('requires write permission on the target workspace', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(403) + expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'write' + ) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('authorizes before touching the payload', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + await POST(makeRequest(validBody())) + + expect(mockParseWorkflowJson).not.toHaveBeenCalled() + }) + + it('returns 404 when the target workspace does not exist', async () => { + mockWorkspaceRows.value = [] + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(404) + }) + + it('returns 400 with parser errors for an invalid workflow payload', async () => { + mockParseWorkflowJson.mockReturnValue({ + data: null, + errors: ['Missing or invalid field: blocks'], + }) + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid workflow: Missing or invalid field: blocks', + }) + }) + + it('creates the workflow and returns 201 with the resource shape', async () => { + const response = await POST(makeRequest(validBody())) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data).toEqual({ + id: 'wf-new', + name: 'Payload Name', + description: 'Payload description', + workspaceId: WORKSPACE_ID, + folderId: null, + createdAt: CREATED_AT.toISOString(), + updatedAt: CREATED_AT.toISOString(), + }) + expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith('wf-new', expect.anything()) + }) + + it('derives the name from the export envelope and deduplicates it', async () => { + await POST(makeRequest(validBody())) + + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Payload Name', + description: 'Payload description', + workspaceId: WORKSPACE_ID, + deduplicate: true, + userId: 'user-1', + }) + ) + }) + + it('prefers the explicit name and description overrides', async () => { + await POST(makeRequest(validBody({ name: 'Override', description: 'Override desc' }))) + + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Override', description: 'Override desc' }) + ) + }) + + it('falls back to a default name when the payload carries no metadata', async () => { + await POST( + makeRequest({ + workspaceId: WORKSPACE_ID, + workflow: { blocks: PARSED_STATE.blocks, edges: [] }, + }) + ) + + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Imported Workflow', description: '' }) + ) + }) + + it('accepts a JSON string payload', async () => { + const response = await POST( + makeRequest(validBody({ workflow: JSON.stringify(EXPORT_ENVELOPE) })) + ) + + expect(response.status).toBe(201) + expect(mockParseWorkflowJson).toHaveBeenCalledWith(JSON.stringify(EXPORT_ENVELOPE)) + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Payload Name' }) + ) + }) + + it('persists variables from the parsed state', async () => { + mockParseWorkflowJson.mockReturnValue({ + data: { + ...PARSED_STATE, + variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } }, + }, + errors: [], + }) + + await POST(makeRequest(validBody())) + + expect(mockDbUpdate).toHaveBeenCalled() + }) + + it('normalizes legacy array-form variables into a keyed record', async () => { + const setSpy = vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) + mockDbUpdate.mockReturnValue({ set: setSpy }) + mockParseWorkflowJson.mockReturnValue({ + data: { + ...PARSED_STATE, + variables: [{ id: 'var-1', name: 'host', type: 'string', value: 'x' }], + }, + errors: [], + }) + + await POST(makeRequest(validBody())) + + expect(setSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } }, + }) + ) + }) + + it('skips the variables write when the payload has none', async () => { + await POST(makeRequest(validBody())) + + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('deletes the created row and returns 500 when persisting state fails', async () => { + const whereSpy = vi.fn().mockResolvedValue(undefined) + mockDbDelete.mockReturnValue({ where: whereSpy }) + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: false, error: 'boom' }) + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(500) + expect(mockDbDelete).toHaveBeenCalled() + expect(whereSpy).toHaveBeenCalled() + }) + + it('unwraps an export response envelope so its metadata still resolves', async () => { + await POST(makeRequest(validBody({ workflow: { data: EXPORT_ENVELOPE, limits: {} } }))) + + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Payload Name', description: 'Payload description' }) + ) + }) + + /** + * Unreachable in practice — the route always passes `deduplicate: true`, so + * the orchestrator renames instead of conflicting. Covers the defensive + * mapping of the orchestrator's documented result union. + */ + it('maps a conflict result from the orchestrator to 409', async () => { + mockPerformCreateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Payload Name" already exists in this folder', + errorCode: 'conflict', + }) + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(409) + }) + + it('maps an invalid target folder from the orchestrator to 400', async () => { + mockPerformCreateWorkflow.mockResolvedValue({ + success: false, + error: 'Target folder not found', + errorCode: 'validation', + }) + + const response = await POST(makeRequest(validBody({ folderId: 'folder-x' }))) + + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts new file mode 100644 index 00000000000..821181912dd --- /dev/null +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -0,0 +1,277 @@ +import { db } from '@sim/db' +import { workflow, workspace } 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 type { Variable } from '@sim/workflow-types/workflow' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V1ImportWorkflowData, + v1ImportWorkflowContract, +} from '@/lib/api/contracts/v1/workflows' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' + +const logger = createLogger('V1WorkflowImportAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Workflow JSON is a bounded document — a few hundred blocks at the outside. + * Capping well below the platform-wide `DEFAULT_MAX_JSON_BODY_BYTES` (50 MB) + * keeps a hostile caller from buffering a large body before validation runs. + */ +const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024 + +const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow' + +/** + * Reads a dot-delimited path off a parsed payload and returns it only when it + * is a non-empty string, so blank metadata falls through to the next candidate. + */ +function readString(source: unknown, path: string): string | undefined { + let current: unknown = source + for (const segment of path.split('.')) { + if (!current || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return typeof current === 'string' && current.trim() ? current : undefined +} + +/** + * Unwraps the `{ data: ... }` response envelope the export endpoint returns, so + * a caller can pipe an export response body straight into import. + * `parseWorkflowJson` already tolerates this shape when reading the graph; + * mirroring it here keeps metadata resolution from silently falling back to the + * default name for the same payload. + */ +function unwrapResponseEnvelope(payload: unknown): unknown { + if (!payload || typeof payload !== 'object') return payload + const inner = (payload as Record).data + if (!inner || typeof inner !== 'object') return payload + const candidate = inner as Record + return candidate.state || candidate.version || candidate.workflow ? candidate : payload +} + +/** + * Resolves the imported workflow's name and description, preferring explicit + * request overrides and then the payload's own metadata. Accepts every shape + * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) + * and a bare state (`metadata.*`). + */ +function resolveImportedMetadata( + rawPayload: unknown, + overrideName?: string, + overrideDescription?: string +): { name: string; description: string } { + const payload = unwrapResponseEnvelope(rawPayload) + + const name = + overrideName || + readString(payload, 'workflow.name') || + readString(payload, 'state.metadata.name') || + readString(payload, 'metadata.name') || + DEFAULT_IMPORTED_WORKFLOW_NAME + + const description = + overrideDescription ?? + readString(payload, 'workflow.description') ?? + readString(payload, 'state.metadata.description') ?? + readString(payload, 'metadata.description') ?? + '' + + return { name, description } +} + +/** + * Normalizes parsed variables into the persisted `Record` + * shape, keyed by variable id. Accepts both the record form and the legacy + * array form that older exports carry. + */ +function toVariablesRecord(variables: unknown): Record { + const record: Record = {} + if (!variables || typeof variables !== 'object') return record + + const entries: Array<[string | undefined, unknown]> = Array.isArray(variables) + ? variables.map((value) => [undefined, value]) + : Object.entries(variables) + + for (const [key, value] of entries) { + if (!value || typeof value !== 'object') continue + const raw = value as Partial + const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id : (key ?? generateId()) + + record[id] = { + id, + name: typeof raw.name === 'string' ? raw.name : id, + type: raw.type ?? 'string', + value: raw.value, + } + } + + return record +} + +/** + * POST /api/v1/workflows/import + * + * Creates a new workflow in the target workspace from an export payload + * produced by `GET /api/v1/workflows/{id}/export`. Block, edge, loop and + * parallel ids are regenerated on import, so the same payload can be imported + * repeatedly and alongside its source workflow without collisions. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-import') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v1ImportWorkflowContract, + request, + {}, + { + maxBodyBytes: MAX_IMPORT_BODY_BYTES, + validationErrorResponse: (error) => + NextResponse.json( + { + error: getValidationErrorMessage(error, 'Invalid request body'), + details: error.issues, + }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + const { + workspaceId, + folderId, + name: overrideName, + description: overrideDescription, + } = parsed.data.body + + logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { + userId, + folderId, + }) + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (accessError) return accessError + + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceData) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + await assertFolderMutable(folderId ?? null) + + const rawWorkflow = parsed.data.body.workflow + const workflowContent = + typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow) + + const { data: workflowState, errors } = parseWorkflowJson(workflowContent) + if (!workflowState || errors.length > 0) { + return NextResponse.json({ error: `Invalid workflow: ${errors.join(', ')}` }, { status: 400 }) + } + + let parsedPayload: unknown = rawWorkflow + if (typeof rawWorkflow === 'string') { + try { + parsedPayload = JSON.parse(rawWorkflow) + } catch { + parsedPayload = undefined + } + } + + const { name, description } = resolveImportedMetadata( + parsedPayload, + overrideName, + overrideDescription + ) + + const created = await performCreateWorkflow({ + name, + description, + workspaceId, + folderId, + deduplicate: true, + userId, + requestId, + }) + + if (!created.success || !created.workflow) { + const status = + created.errorCode === 'conflict' ? 409 : created.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: created.error }, { status }) + } + + const workflowId = created.workflow.id + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + if (!saveResult.success) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + logger.error(`[${requestId}] Failed to persist imported workflow state`, { + workflowId, + error: saveResult.error, + }) + return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 }) + } + + const variables = toVariablesRecord(workflowState.variables) + if (Object.keys(variables).length > 0) { + await db + .update(workflow) + .set({ variables, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + } + + logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { + name: created.workflow.name, + blocksCount: Object.keys(workflowState.blocks).length, + }) + + const data: V1ImportWorkflowData = { + id: workflowId, + name: created.workflow.name, + description: created.workflow.description || null, + workspaceId, + folderId: created.workflow.folderId ?? null, + createdAt: created.workflow.createdAt.toISOString(), + updatedAt: created.workflow.updatedAt.toISOString(), + } + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse({ data }, limits, rateLimit) + + return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow import error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index d0c1469e189..39838477d7d 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -4,9 +4,9 @@ import { deploymentOperationSummarySchema, deploymentVersionMetadataFieldsSchema, } from '@/lib/api/contracts/deployments' -import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { workflowIdParamsSchema, workflowStateSchema } from '@/lib/api/contracts/workflows' export const v1ListWorkflowsQuerySchema = z.object({ workspaceId: z.string().min(1), @@ -158,3 +158,131 @@ export const v1RollbackWorkflowContract = defineRouteContract({ schema: withV1Limits(v1RollbackWorkflowDataSchema), }, }) + +/** Workflow variable as carried inside an export payload's `state.variables`. */ +const v1WorkflowExportVariableSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), + value: z.unknown(), +}) + +/** + * Workflow state emitted by the public export endpoint. + * + * Structurally mirrors the admin export state (`adminWorkflowExportStateSchema` + * in `@/lib/api/contracts/admin`) so both payloads round-trip through the same + * importer. The surfaces are deliberately kept as separate schemas because the + * data differs: public exports are secret-sanitized via `sanitizeForExport` + * (credentials stripped, `{{ENV_VAR}}` references preserved), while admin + * exports are raw for backup/restore. + */ +export const v1WorkflowExportStateSchema = workflowStateSchema.extend({ + metadata: z + .object({ + name: z.string().optional(), + description: z.string().optional(), + sortOrder: z.number().optional(), + exportedAt: z.string().optional(), + }) + .optional(), + variables: z.record(z.string(), v1WorkflowExportVariableSchema).optional(), +}) + +export const v1WorkflowExportPayloadSchema = z.object({ + version: z.literal('1.0'), + exportedAt: z.string(), + workflow: z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + workspaceId: z.string().nullable(), + folderId: z.string().nullable(), + }), + state: v1WorkflowExportStateSchema, +}) + +export type V1WorkflowExportPayload = z.output + +export const v1ExportWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v1/workflows/[id]/export', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: withV1Limits(v1WorkflowExportPayloadSchema), + }, +}) + +/** + * Upper bound on an imported workflow name, matching the `workflow.name` + * column and the deduplication suffix headroom applied on insert. + */ +const V1_IMPORT_NAME_MAX_LENGTH = 200 + +/** Upper bound on an imported workflow description. */ +const V1_IMPORT_DESCRIPTION_MAX_LENGTH = 2000 + +/** + * Import request body. `workflow` accepts either the envelope emitted by + * {@link v1ExportWorkflowContract} (`{ version, exportedAt, workflow, state }`), + * a bare workflow state (`{ blocks, edges, ... }`), or a JSON string of either + * — the same three forms the admin importer accepts, so payloads are portable + * between the two surfaces. `name` and `description` override whatever the + * payload's own metadata carries. + */ +export const v1ImportWorkflowBodySchema = z.object({ + workspaceId: workspaceIdSchema, + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + name: z + .string() + .min(1, 'name cannot be empty') + .max(V1_IMPORT_NAME_MAX_LENGTH, `name must be at most ${V1_IMPORT_NAME_MAX_LENGTH} characters`) + .optional(), + description: z + .string() + .max( + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + `description must be at most ${V1_IMPORT_DESCRIPTION_MAX_LENGTH} characters` + ) + .optional(), + workflow: z.union( + [ + z.string().min(1, 'workflow cannot be empty'), + z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, { + error: 'workflow cannot be empty', + }), + ], + { error: 'workflow must be a workflow export object or a JSON string' } + ), +}) + +export type V1ImportWorkflowBody = z.input + +/** + * Created workflow, shaped as the subset of the v1 workflow resource that is + * knowable at import time (an imported workflow is never deployed and has no + * run history), so callers can feed it straight back into + * `GET /api/v1/workflows/{id}`. + */ +export const v1ImportWorkflowDataSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + workspaceId: z.string(), + folderId: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V1ImportWorkflowData = z.output + +export const v1ImportWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/workflows/import', + body: v1ImportWorkflowBodySchema, + response: { + mode: 'json', + schema: withV1Limits(v1ImportWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/workflows/variables/parse.ts b/apps/sim/lib/workflows/variables/parse.ts new file mode 100644 index 00000000000..931daa8b621 --- /dev/null +++ b/apps/sim/lib/workflows/variables/parse.ts @@ -0,0 +1,55 @@ +import type { workflow } from '@sim/db/schema' +import type { Variable } from '@sim/workflow-types/workflow' +import type { InferSelectModel } from 'drizzle-orm' + +type DbWorkflowVariables = InferSelectModel['variables'] + +/** + * Parses the persisted `workflow.variables` JSONB column into the canonical + * `Record` shape. + * + * Tolerates the three forms the column has carried over time: a JSON string, a + * legacy `Variable[]` array (re-keyed by variable id), and the current record. + * Returns `undefined` for null/unparseable values so callers can omit the field + * entirely rather than emitting an empty object. + */ +export function parseWorkflowVariables( + dbVariables: DbWorkflowVariables +): Record | undefined { + if (!dbVariables) return undefined + + try { + const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables + + if (Array.isArray(varsObj)) { + const result: Record = {} + for (const v of varsObj) { + result[v.id] = { + id: v.id, + name: v.name, + type: v.type, + value: v.value, + } + } + return result + } + + if (typeof varsObj === 'object' && varsObj !== null) { + const result: Record = {} + for (const [key, v] of Object.entries(varsObj)) { + const variable = v as Variable + result[key] = { + id: variable.id, + name: variable.name, + type: variable.type, + value: variable.value, + } + } + return result + } + } catch { + return undefined + } + + return undefined +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1712cdaa9f5..0751de73108 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: 977, - zodRoutes: 977, + totalRoutes: 979, + zodRoutes: 979, nonZodRoutes: 0, } as const From 5decc016658e1485bd3740b6bd20fe9798d02afa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 19:55:41 -0700 Subject: [PATCH 2/4] fix(api): make workflow import atomic and clarify what export redacts Writes the imported graph and its variables in a single transaction and deletes the shell workflow row on any failure, so a caller that receives an error is never left with a partially imported workflow. Previously a throw from the variables update returned 500 while leaving the workflow behind with an empty variables map. Also narrows the export route's sanitization claim: workflow variables are emitted as stored, matching GET /api/v1/workflows/[id] and the in-app export. They are plaintext configuration readable at the same permission level this route requires; secrets belong in environment variables, which travel as unresolved references. --- apps/docs/openapi.json | 6 +-- .../app/api/v1/workflows/[id]/export/route.ts | 12 ++++- .../app/api/v1/workflows/import/route.test.ts | 45 ++++++++++++++++++- apps/sim/app/api/v1/workflows/import/route.ts | 39 ++++++++++------ 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 8c8a5b4919a..330e6386b18 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -1245,7 +1245,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a workflow as a portable JSON envelope that POST /api/v1/workflows/import accepts verbatim. Stored credentials and secret field values are redacted from the payload, while `{{ENV_VAR}}` references and block positions are preserved. Requires read permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "description": "Export a workflow as a portable JSON envelope that POST /api/v1/workflows/import accepts verbatim. Credential and password field values stored on blocks are redacted from the payload, while `{{ENV_VAR}}` references and block positions are preserved. Workflow variables are returned as stored \u2014 they are plaintext configuration readable by anyone with workspace read, and secrets belong in environment variables, which travel as unresolved references. Requires read permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", "tags": ["Workflows"], "x-codeSamples": [ { @@ -7575,7 +7575,7 @@ }, "variables": { "type": "object", - "description": "Workflow-level variables, keyed by variable id.", + "description": "Workflow-level variables, keyed by variable id. Emitted as stored \u2014 treat as plaintext configuration, not a secret store.", "additionalProperties": true, "example": {} } @@ -7583,7 +7583,7 @@ }, "WorkflowExport": { "type": "object", - "description": "A portable workflow export envelope. Pass this object straight back to POST /api/v1/workflows/import to recreate the workflow. Stored credentials and secret field values are redacted; `{{ENV_VAR}}` references are preserved.", + "description": "A portable workflow export envelope. Pass this object straight back to POST /api/v1/workflows/import to recreate the workflow. Credential and password field values stored on blocks are redacted; `{{ENV_VAR}}` references and workflow variables are preserved as stored.", "properties": { "version": { "type": "string", diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index cec8b54c503..02b1a3ceaf9 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -67,8 +67,16 @@ function toExportedEdge(edge: Edge): ExportedEdge { * * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits * the raw state for backup/restore, this surface runs the payload through - * `sanitizeForExport`: stored credentials and secret sub-block values are - * stripped while `{{ENV_VAR}}` references and block positions are preserved. + * `sanitizeForExport`: block sub-block values declared `password: true` or + * `oauth-input` are nulled, while `{{ENV_VAR}}` references and block positions + * are preserved. + * + * Workflow **variables** are emitted as stored, matching `GET + * /api/v1/workflows/[id]` and the in-app export. Variables are plaintext + * workflow configuration readable by anyone with workspace read (the same + * permission this route requires); secrets belong in environment variables, + * which travel as unresolved `{{ENV_VAR}}` references. Redacting them here + * would break import round-trips without narrowing access. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index e4aa0c90554..70ef32df8f3 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -75,7 +75,7 @@ vi.mock('@sim/db', () => ({ })), })), delete: mockDbDelete, - update: mockDbUpdate, + transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn({ update: mockDbUpdate })), }, })) @@ -223,7 +223,11 @@ describe('POST /api/v1/workflows/import', () => { createdAt: CREATED_AT.toISOString(), updatedAt: CREATED_AT.toISOString(), }) - expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith('wf-new', expect.anything()) + expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith( + 'wf-new', + expect.anything(), + expect.anything() + ) }) it('derives the name from the export envelope and deduplicates it', async () => { @@ -313,6 +317,22 @@ describe('POST /api/v1/workflows/import', () => { expect(mockDbUpdate).not.toHaveBeenCalled() }) + it('writes the graph and variables inside a single transaction', async () => { + mockParseWorkflowJson.mockReturnValue({ + data: { + ...PARSED_STATE, + variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } }, + }, + errors: [], + }) + + await POST(makeRequest(validBody())) + + const tx = { update: mockDbUpdate } + expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith('wf-new', expect.anything(), tx) + expect(mockDbUpdate).toHaveBeenCalled() + }) + it('deletes the created row and returns 500 when persisting state fails', async () => { const whereSpy = vi.fn().mockResolvedValue(undefined) mockDbDelete.mockReturnValue({ where: whereSpy }) @@ -325,6 +345,27 @@ describe('POST /api/v1/workflows/import', () => { expect(whereSpy).toHaveBeenCalled() }) + it('rolls back the created workflow when the variables write throws', async () => { + const whereSpy = vi.fn().mockResolvedValue(undefined) + mockDbDelete.mockReturnValue({ where: whereSpy }) + mockDbUpdate.mockImplementation(() => { + throw new Error('variables write failed') + }) + mockParseWorkflowJson.mockReturnValue({ + data: { + ...PARSED_STATE, + variables: { 'var-1': { id: 'var-1', name: 'host', type: 'string', value: 'x' } }, + }, + errors: [], + }) + + const response = await POST(makeRequest(validBody())) + + expect(response.status).toBe(500) + expect(mockDbDelete).toHaveBeenCalled() + expect(whereSpy).toHaveBeenCalled() + }) + it('unwraps an export response envelope so its metadata still resolves', async () => { await POST(makeRequest(validBody({ workflow: { data: EXPORT_ENVELOPE, limits: {} } }))) diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index 821181912dd..af782dc1826 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -229,24 +229,37 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const workflowId = created.workflow.id - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) - if (!saveResult.success) { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - logger.error(`[${requestId}] Failed to persist imported workflow state`, { + const variables = toVariablesRecord(workflowState.variables) + + /** + * The graph and the variables are written in one transaction so an import + * can never half-land, and any failure deletes the shell row created above + * — a caller that receives an error must not be left with a partially + * imported workflow in their workspace. + */ + try { + await db.transaction(async (tx) => { + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to save workflow state') + } + + if (Object.keys(variables).length > 0) { + await tx + .update(workflow) + .set({ variables, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + } + }) + } catch (error) { + logger.error(`[${requestId}] Failed to persist imported workflow, rolling back`, { workflowId, - error: saveResult.error, + error: getErrorMessage(error, 'Unknown error'), }) + await db.delete(workflow).where(eq(workflow.id, workflowId)) return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 }) } - const variables = toVariablesRecord(workflowState.variables) - if (Object.keys(variables).length > 0) { - await db - .update(workflow) - .set({ variables, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) - } - logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { name: created.workflow.name, blocksCount: Object.keys(workflowState.blocks).length, From fd2136834612e7281b8e5ed49b311e8bb2a6a36b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 20:20:57 -0700 Subject: [PATCH 3/4] fix(api): close import defects found in audit and share one write pipeline Security: - Escape block names before interpolating them into a RegExp in updateValueReferences. Names reach it straight from imported workflow JSON and normalizeWorkflowBlockName preserves regex metacharacters, so a name like `a*a*a*a*b` compiled to a catastrophically backtracking pattern. A sub-kilobyte body blocked the event loop for 50s and grew exponentially. Also skip rename-to-itself, which is the entire map on the import path, so the scan no longer runs at all there. - Validate folder ownership before folder lock state, so a locked folder in another workspace can no longer be distinguished from a missing one. Correctness: - Gate the imported graph on workflowStateSchema, the same schema the canonical PUT /api/workflows/[id]/state path enforces. Without it a valid 201 could persist a block field of the wrong type, which then threw on every subsequent read and left a workflow nothing could open. - Guard the compensating delete so a failed rollback logs the orphaned id instead of vanishing into a generic 500. - Validate variable `type` against the enum and build the record on a null-prototype object, so a `__proto__` key no longer silently drops the variable. - Bound payload-derived names and descriptions to the same limits the contract declares for the explicit overrides. - Return the description as stored rather than coercing '' to null, matching GET /api/v1/workflows/[id]. Shared code, so the two write paths cannot drift: - Extract prepareWorkflowStateForPersistence and use it from both PUT /api/workflows/[id]/state and the v1 import route: agent-tool sanitization, block backfill, dangling-edge removal, and loop/parallel recomputation now have one implementation. - Persist inline custom tools on import, which the canonical path already did. - Move variable normalization into lib/workflows/variables and repoint the admin importer at it, removing the last duplicate. Docs: - OpenAPI: oneOf -> anyOf on the import body. WorkflowExport matches any object, so every valid object payload matched two branches and failed validation under any spec-driven validator. Document 423 and the loss of workspace-scoped bindings on export. Tests: prepare-state unit tests and a real export -> import round trip with no mocks of the sanitizer or parser, covering loop/parallel children and the regex-metacharacter payload. --- apps/docs/openapi.json | 21 ++- .../api/v1/admin/workflows/import/route.ts | 46 +---- .../app/api/v1/workflows/[id]/export/route.ts | 19 +- .../app/api/v1/workflows/import/route.test.ts | 42 ++++- apps/sim/app/api/v1/workflows/import/route.ts | 175 +++++++++++++----- .../sim/app/api/workflows/[id]/state/route.ts | 53 +----- apps/sim/lib/api/contracts/v1/workflows.ts | 45 +++-- .../import-export-roundtrip.test.ts | 152 +++++++++++++++ .../persistence/prepare-state.test.ts | 101 ++++++++++ .../workflows/persistence/prepare-state.ts | 71 +++++++ apps/sim/lib/workflows/variables/parse.ts | 59 ++++++ apps/sim/stores/workflows/utils.ts | 19 +- 12 files changed, 645 insertions(+), 158 deletions(-) create mode 100644 apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts create mode 100644 apps/sim/lib/workflows/persistence/prepare-state.test.ts create mode 100644 apps/sim/lib/workflows/persistence/prepare-state.ts diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 330e6386b18..1db7ab8e662 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -1085,13 +1085,14 @@ }, "workflow": { "description": "The workflow to import. Accepts the export envelope returned by GET /api/v1/workflows/{id}/export, a bare workflow state object (`{ blocks, edges, ... }`), or a JSON string of either.", - "oneOf": [ + "anyOf": [ { "$ref": "#/components/schemas/WorkflowExport" }, { "type": "object", "additionalProperties": true, + "minProperties": 1, "description": "A bare workflow state object." }, { @@ -1166,6 +1167,22 @@ } } }, + "423": { + "description": "The target folder is locked and cannot accept new workflows.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Folder is locked" + } + } + } + } + } + }, "429": { "$ref": "#/components/responses/RateLimited" } @@ -1245,7 +1262,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a workflow as a portable JSON envelope that POST /api/v1/workflows/import accepts verbatim. Credential and password field values stored on blocks are redacted from the payload, while `{{ENV_VAR}}` references and block positions are preserved. Workflow variables are returned as stored \u2014 they are plaintext configuration readable by anyone with workspace read, and secrets belong in environment variables, which travel as unresolved references. Requires read permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "description": "Export a workflow as a portable JSON envelope that POST /api/v1/workflows/import accepts verbatim. Credential and password field values stored on blocks are redacted from the payload, while `{{ENV_VAR}}` references and block positions are preserved. Workflow variables are returned as stored \u2014 they are plaintext configuration readable by anyone with workspace read, and secrets belong in environment variables, which travel as unresolved references. Requires read permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it. Workspace-scoped bindings (knowledge bases, workspace files, channels, projects, folders, MCP servers) are cleared, since those ids do not resolve in another workspace \u2014 an imported copy needs them re-selected.", "tags": ["Workflows"], "x-codeSamples": [ { diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index b672f25b78f..220637b4f38 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -26,18 +26,14 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, internalErrorResponse, notFoundResponse, } from '@/app/api/v1/admin/responses' -import { - extractWorkflowMetadata, - type VariableType, - type WorkflowImportRequest, - type WorkflowVariable, -} from '@/app/api/v1/admin/types' +import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowImportAPI') @@ -117,42 +113,8 @@ export const POST = withRouteHandler( return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`) } - if ( - workflowData.variables && - typeof workflowData.variables === 'object' && - !Array.isArray(workflowData.variables) - ) { - const variablesRecord: Record = {} - const vars = workflowData.variables as Record< - string, - { id?: string; name: string; type?: VariableType; value: unknown } - > - Object.entries(vars).forEach(([key, v]) => { - const varId = v.id || key - variablesRecord[varId] = { - id: varId, - name: v.name, - type: v.type ?? 'string', - value: v.value, - } - }) - - await db - .update(workflow) - .set({ variables: variablesRecord, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) - } else if (workflowData.variables && Array.isArray(workflowData.variables)) { - const variablesRecord: Record = {} - workflowData.variables.forEach((v) => { - const varId = v.id || generateId() - variablesRecord[varId] = { - id: varId, - name: v.name, - type: (v.type as VariableType) ?? 'string', - value: v.value, - } - }) - + const variablesRecord = normalizeImportedVariables(workflowData.variables) + if (Object.keys(variablesRecord).length > 0) { await db .update(workflow) .set({ variables: variablesRecord, updatedAt: new Date() }) diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index 02b1a3ceaf9..b445e81a3f1 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -67,9 +67,22 @@ function toExportedEdge(edge: Edge): ExportedEdge { * * Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits * the raw state for backup/restore, this surface runs the payload through - * `sanitizeForExport`: block sub-block values declared `password: true` or - * `oauth-input` are nulled, while `{{ENV_VAR}}` references and block positions - * are preserved. + * `sanitizeForExport`, which nulls three classes of sub-block value: + * - `password: true` fields, unless the value is a whole `{{ENV_VAR}}` + * reference, which is preserved so the import resolves it in the target + * workspace; + * - `oauth-input` credentials; + * - **workspace-scoped bindings** — `knowledge-base-selector`, `file-selector`, + * `channel-selector`, `project-selector`, `folder-selector`, + * `mcp-server-selector` and friends, plus fields keyed `knowledgeBaseId`, + * `fileId`, `channelId`, `projectId`, `documentId`, `tagFilters`. These point + * at rows that do not exist in another workspace, so they are cleared rather + * than carried across as dangling ids. + * + * The last class means an export is **not** a byte-for-byte clone even when + * re-imported into the same workspace: those bindings come back empty and must + * be re-selected. This matches the in-app export and is documented on the + * public endpoint so callers do not expect otherwise. * * Workflow **variables** are emitted as stored, matching `GET * /api/v1/workflows/[id]` and the in-app export. Variables are plaintext diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index 70ef32df8f3..720203b1820 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -18,6 +18,9 @@ const { mockSaveWorkflowToNormalizedTables, mockParseWorkflowJson, mockAssertFolderMutable, + mockAssertFolderInWorkspace, + mockExtractAndPersistCustomTools, + mockPrepareWorkflowState, mockDbDelete, mockDbUpdate, mockWorkspaceRows, @@ -28,6 +31,9 @@ const { mockSaveWorkflowToNormalizedTables: vi.fn(), mockParseWorkflowJson: vi.fn(), mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), + mockExtractAndPersistCustomTools: vi.fn(), + mockPrepareWorkflowState: vi.fn(), mockDbDelete: vi.fn(), mockDbUpdate: vi.fn(), mockWorkspaceRows: { value: [{ id: 'ws-1' }] as Array<{ id: string }> }, @@ -59,10 +65,27 @@ vi.mock('@/app/api/v1/logs/meta', () => ({ })) vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderInWorkspace: mockAssertFolderInWorkspace, assertFolderMutable: mockAssertFolderMutable, FolderLockedError: class FolderLockedError extends Error { status = 423 }, + FolderNotFoundError: class FolderNotFoundError extends Error { + status = 400 + }, +})) + +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: mockExtractAndPersistCustomTools, +})) + +/** + * Mocked to keep the block registry (and its icon/CSS graph) out of this + * suite. The real normalization is covered by `prepare-state.test.ts` and by + * the end-to-end round trip in `import-export-roundtrip.test.ts`. + */ +vi.mock('@/lib/workflows/persistence/prepare-state', () => ({ + prepareWorkflowStateForPersistence: mockPrepareWorkflowState, })) vi.mock('@sim/db', () => ({ @@ -84,8 +107,19 @@ import { POST } from '@/app/api/v1/workflows/import/route' const WORKSPACE_ID = 'ws-1' const CREATED_AT = new Date('2026-07-01T00:00:00Z') +/** A schema-valid block: the route now gates on `workflowStateSchema`. */ +const VALID_BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + const PARSED_STATE = { - blocks: { 'block-1': { id: 'block-1', type: 'starter', position: { x: 0, y: 0 } } }, + blocks: { 'block-1': VALID_BLOCK }, edges: [], loops: {}, parallels: {}, @@ -120,6 +154,12 @@ describe('POST /api/v1/workflows/import', () => { mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) mockValidateWorkspaceAccess.mockResolvedValue(null) mockAssertFolderMutable.mockResolvedValue(undefined) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockExtractAndPersistCustomTools.mockResolvedValue({ saved: 0, errors: [] }) + mockPrepareWorkflowState.mockImplementation((state: { blocks: unknown; edges: unknown }) => ({ + state: { blocks: state.blocks, edges: state.edges, loops: {}, parallels: {} }, + warnings: [], + })) mockParseWorkflowJson.mockReturnValue({ data: { ...PARSED_STATE }, errors: [] }) mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) mockPerformCreateWorkflow.mockResolvedValue({ diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index af782dc1826..b3a33f3ebd7 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -1,27 +1,39 @@ import { db } from '@sim/db' import { workflow, workspace } 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 type { Variable } from '@sim/workflow-types/workflow' +import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + V1_IMPORT_NAME_MAX_LENGTH, type V1ImportWorkflowData, v1ImportWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { workflowStateSchema } from '@/lib/api/contracts/workflows' +import { getValidationErrorMessage, parseRequest, serializeZodIssues } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse, validateWorkspaceAccess, } from '@/app/api/v1/middleware' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('V1WorkflowImportAPI') @@ -70,6 +82,11 @@ function unwrapResponseEnvelope(payload: unknown): unknown { * request overrides and then the payload's own metadata. Accepts every shape * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) * and a bare state (`metadata.*`). + * + * Payload-derived values are truncated to the same bounds the contract applies + * to the explicit overrides — otherwise the declared `maxLength` would not be + * the effective one, and a caller could store an unbounded name simply by + * embedding it in the payload instead of passing it as a field. */ function resolveImportedMetadata( rawPayload: unknown, @@ -80,50 +97,27 @@ function resolveImportedMetadata( const name = overrideName || - readString(payload, 'workflow.name') || - readString(payload, 'state.metadata.name') || - readString(payload, 'metadata.name') || - DEFAULT_IMPORTED_WORKFLOW_NAME + truncate( + readString(payload, 'workflow.name') || + readString(payload, 'state.metadata.name') || + readString(payload, 'metadata.name') || + DEFAULT_IMPORTED_WORKFLOW_NAME, + V1_IMPORT_NAME_MAX_LENGTH + ) const description = overrideDescription ?? - readString(payload, 'workflow.description') ?? - readString(payload, 'state.metadata.description') ?? - readString(payload, 'metadata.description') ?? - '' + truncate( + readString(payload, 'workflow.description') ?? + readString(payload, 'state.metadata.description') ?? + readString(payload, 'metadata.description') ?? + '', + V1_IMPORT_DESCRIPTION_MAX_LENGTH + ) return { name, description } } -/** - * Normalizes parsed variables into the persisted `Record` - * shape, keyed by variable id. Accepts both the record form and the legacy - * array form that older exports carry. - */ -function toVariablesRecord(variables: unknown): Record { - const record: Record = {} - if (!variables || typeof variables !== 'object') return record - - const entries: Array<[string | undefined, unknown]> = Array.isArray(variables) - ? variables.map((value) => [undefined, value]) - : Object.entries(variables) - - for (const [key, value] of entries) { - if (!value || typeof value !== 'object') continue - const raw = value as Partial - const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id : (key ?? generateId()) - - record[id] = { - id, - name: typeof raw.name === 'string' ? raw.name : id, - type: raw.type ?? 'string', - value: raw.value, - } - } - - return record -} - /** * POST /api/v1/workflows/import * @@ -185,17 +179,68 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } + /** + * Ownership before lock state: `assertFolderMutable` walks the folder's + * ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace + * (423) from a nonexistent one (404). + */ + if (folderId) { + await assertFolderInWorkspace(folderId, workspaceId) + } await assertFolderMutable(folderId ?? null) const rawWorkflow = parsed.data.body.workflow const workflowContent = typeof rawWorkflow === 'string' ? rawWorkflow : JSON.stringify(rawWorkflow) - const { data: workflowState, errors } = parseWorkflowJson(workflowContent) - if (!workflowState || errors.length > 0) { + const { data: parsedState, errors } = parseWorkflowJson(workflowContent) + if (!parsedState || errors.length > 0) { return NextResponse.json({ error: `Invalid workflow: ${errors.join(', ')}` }, { status: 400 }) } + /** + * Variables are normalized before validation, not after: older exports + * carry them as an array, which is a shape `workflowStateSchema` rightly + * rejects but the importer has always accepted. Normalizing first keeps + * that tolerance while still validating what actually gets persisted. + */ + const variables = normalizeImportedVariables(parsedState.variables) + + /** + * `parseWorkflowJson` only checks that blocks/edges are structurally + * present. The normalized tables are read back through + * {@link workflowStateSchema}, and the client parses that response + * strictly — so a block field with the wrong type (`data.extent: 'child'`, + * `data.count: '5'`) would persist happily here and then throw on every + * subsequent load, leaving a workflow nothing can open. Gate on the same + * schema the canonical `PUT /api/workflows/[id]/state` path enforces. + */ + const stateValidation = workflowStateSchema.safeParse({ ...parsedState, variables }) + if (!stateValidation.success) { + const issue = stateValidation.error.issues[0] + const path = issue?.path.join('.') + return NextResponse.json( + { + error: `Invalid workflow state${path ? ` at ${path}` : ''}: ${issue?.message ?? 'validation failed'}`, + details: serializeZodIssues(stateValidation.error), + }, + { status: 400 } + ) + } + + /** + * Same normalization the editor's `PUT /api/workflows/[id]/state` runs, via + * the one shared implementation — the two import surfaces must land + * byte-identical data for the same payload. + */ + const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(parsedState) + if (warnings.length > 0) { + logger.warn(`[${requestId}] Normalized imported workflow with warnings`, { warnings }) + } + + const workflowState: WorkflowState = { ...parsedState, ...preparedState } + let parsedPayload: unknown = rawWorkflow if (typeof rawWorkflow === 'string') { try { @@ -229,8 +274,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const workflowId = created.workflow.id - const variables = toVariablesRecord(workflowState.variables) - /** * The graph and the variables are written in one transaction so an import * can never half-land, and any failure deletes the shell row created above @@ -256,10 +299,48 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workflowId, error: getErrorMessage(error, 'Unknown error'), }) - await db.delete(workflow).where(eq(workflow.id, workflowId)) + /** + * The rollback runs under the same conditions that just failed the write, + * so it can fail too. Losing it must not turn into an unlogged orphan: + * the caller still gets a 500, but the id is recorded loudly enough to + * clean up. + */ + try { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + } catch (rollbackError) { + logger.error( + `[${requestId}] Rollback failed, workflow ${workflowId} is orphaned in workspace ${workspaceId}`, + { workflowId, workspaceId, error: getErrorMessage(rollbackError, 'Unknown error') } + ) + } return NextResponse.json({ error: 'Failed to save workflow state' }, { status: 500 }) } + /** + * Matches the canonical state-write path: agent blocks may carry inline + * custom-tool definitions that must exist as workspace rows to be + * resolvable at execution. Failures are logged, not fatal — the workflow + * itself imported successfully. + */ + try { + const { saved, errors: toolErrors } = await extractAndPersistCustomTools( + workflowState, + workspaceId, + userId + ) + if (saved > 0 || toolErrors.length > 0) { + logger.info(`[${requestId}] Persisted ${saved} custom tool(s) from import`, { + workflowId, + errors: toolErrors, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to persist custom tools from import`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + logger.info(`[${requestId}] Imported workflow ${workflowId} into workspace ${workspaceId}`, { name: created.workflow.name, blocksCount: Object.keys(workflowState.blocks).length, @@ -268,7 +349,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const data: V1ImportWorkflowData = { id: workflowId, name: created.workflow.name, - description: created.workflow.description || null, + description: created.workflow.description ?? null, workspaceId, folderId: created.workflow.folderId ?? null, createdAt: created.workflow.createdAt.toISOString(), @@ -280,7 +361,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers }) } catch (error: unknown) { - if (error instanceof FolderLockedError) { + if (error instanceof FolderLockedError || error instanceof FolderNotFoundError) { return NextResponse.json({ error: error.message }, { status: error.status }) } const message = getErrorMessage(error, 'Unknown error') diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index fbd33b045b4..209cfd226e8 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -17,14 +17,12 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' -import { validateEdges } from '@/stores/workflows/workflow/edge-validation' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' const logger = createLogger('WorkflowStateAPI') @@ -152,46 +150,14 @@ export const PUT = withRouteHandler( // per variable (the path param is the source of truth), so the check // is unreachable and was removed. - // Sanitize custom tools in agent blocks before saving - const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( - state.blocks as Record - ) - - // Save to normalized tables - // Ensure all required fields are present for WorkflowState type - // Filter out blocks without type or name before saving - const filteredBlocks = Object.entries(sanitizedBlocks).reduce( - (acc, [blockId, block]: [string, BlockState]) => { - if (block.type && block.name) { - // Ensure all required fields are present - acc[blockId] = { - ...block, - enabled: block.enabled !== undefined ? block.enabled : true, - horizontalHandles: - block.horizontalHandles !== undefined ? block.horizontalHandles : true, - height: block.height !== undefined ? block.height : 0, - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, - } - } - return acc - }, - {} as typeof state.blocks - ) - - const typedBlocks = filteredBlocks as Record - const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks) - const validationWarnings = validatedEdges.dropped.map( - ({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}` - ) - const canonicalLoops = generateLoopBlocks(typedBlocks) - const canonicalParallels = generateParallelBlocks(typedBlocks) + const { state: preparedState, warnings: preparationWarnings } = + prepareWorkflowStateForPersistence({ + blocks: state.blocks as Record, + edges: state.edges as WorkflowState['edges'], + }) const workflowState = { - blocks: filteredBlocks, - edges: validatedEdges.valid, - loops: canonicalLoops, - parallels: canonicalParallels, + ...preparedState, lastSaved: state.lastSaved || Date.now(), isDeployed: state.isDeployed || false, deployedAt: state.deployedAt, @@ -303,10 +269,7 @@ export const PUT = withRouteHandler( ) } - return NextResponse.json( - { success: true, warnings: [...warnings, ...validationWarnings] }, - { status: 200 } - ) + return NextResponse.json({ success: true, warnings: preparationWarnings }, { status: 200 }) } catch (error: any) { if (error instanceof WorkflowLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 39838477d7d..aa5a78c901d 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -177,17 +177,26 @@ const v1WorkflowExportVariableSchema = z.object({ * (credentials stripped, `{{ENV_VAR}}` references preserved), while admin * exports are raw for backup/restore. */ -export const v1WorkflowExportStateSchema = workflowStateSchema.extend({ - metadata: z - .object({ - name: z.string().optional(), - description: z.string().optional(), - sortOrder: z.number().optional(), - exportedAt: z.string().optional(), - }) - .optional(), - variables: z.record(z.string(), v1WorkflowExportVariableSchema).optional(), -}) +export const v1WorkflowExportStateSchema = workflowStateSchema + /** + * `sanitizeForExport` builds its payload from `{blocks, edges, loops, + * parallels, metadata, variables}` only, so these three are structurally + * unreachable on this wire. `deployedAt` in particular is a `z.coerce.date()` + * whose output type is a `Date` — never a valid JSON value — so leaving it + * inherited would put an unrepresentable type in a response contract. + */ + .omit({ lastSaved: true, isDeployed: true, deployedAt: true }) + .extend({ + metadata: z + .object({ + name: z.string().optional(), + description: z.string().optional(), + sortOrder: z.number().optional(), + exportedAt: z.string().optional(), + }) + .optional(), + variables: z.record(z.string(), v1WorkflowExportVariableSchema).optional(), + }) export const v1WorkflowExportPayloadSchema = z.object({ version: z.literal('1.0'), @@ -215,13 +224,17 @@ export const v1ExportWorkflowContract = defineRouteContract({ }) /** - * Upper bound on an imported workflow name, matching the `workflow.name` - * column and the deduplication suffix headroom applied on insert. + * Upper bound on an imported workflow name. `workflow.name` is an unbounded + * `text` column, so this is a product limit rather than a storage one — it + * keeps a name renderable in the sidebar and leaves headroom for the + * deduplication suffix appended on insert. Exported because the route applies + * the same bound to names read out of the payload, so the declared limit is + * also the effective one. */ -const V1_IMPORT_NAME_MAX_LENGTH = 200 +export const V1_IMPORT_NAME_MAX_LENGTH = 200 -/** Upper bound on an imported workflow description. */ -const V1_IMPORT_DESCRIPTION_MAX_LENGTH = 2000 +/** Upper bound on an imported workflow description. See above. */ +export const V1_IMPORT_DESCRIPTION_MAX_LENGTH = 2000 /** * Import request body. `workflow` accepts either the envelope emitted by diff --git a/apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts b/apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts new file mode 100644 index 00000000000..3735775f21c --- /dev/null +++ b/apps/sim/lib/workflows/operations/import-export-roundtrip.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + * + * End-to-end round trip through the REAL sanitizer and importer — no mocks of + * either — pinning the property the export/import API pair exists to provide: + * a workflow exported by `GET /api/v1/workflows/[id]/export` must re-import + * through `POST /api/v1/workflows/import` with its graph intact. + * + * Guards specifically against silent structural loss (child blocks inside loop + * and parallel containers, dangling references after id regeneration) that + * mock-heavy route tests cannot catch. + */ + +import { describe, expect, it } from 'vitest' +import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' + +function makeSourceState() { + return { + blocks: { + starter: { + id: 'starter', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { input: { id: 'input', type: 'short-input', value: 'hello' } }, + outputs: {}, + enabled: true, + }, + loop1: { + id: 'loop1', + type: 'loop', + name: 'Loop 1', + position: { x: 200, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { width: 500, height: 300, count: 3, loopType: 'for' }, + }, + childInLoop: { + id: 'childInLoop', + type: 'function', + name: 'Child', + position: { x: 50, y: 50 }, + subBlocks: { code: { id: 'code', type: 'code', value: 'return 1' } }, + outputs: {}, + enabled: true, + data: { parentId: 'loop1', extent: 'parent' }, + }, + par1: { + id: 'par1', + type: 'parallel', + name: 'Parallel 1', + position: { x: 800, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { width: 500, height: 300, count: 2, parallelType: 'count' }, + }, + childInPar: { + id: 'childInPar', + type: 'function', + name: 'Child P', + position: { x: 60, y: 60 }, + subBlocks: {}, + outputs: {}, + enabled: true, + data: { parentId: 'par1', extent: 'parent' }, + }, + } as Record, + edges: [ + { id: 'e1', source: 'starter', target: 'loop1', sourceHandle: 'source', targetHandle: null }, + { id: 'e2', source: 'loop1', target: 'par1' }, + ] as any[], + loops: { + loop1: { id: 'loop1', nodes: ['childInLoop'], iterations: 3, loopType: 'for' as const }, + } as Record, + parallels: { + par1: { id: 'par1', nodes: ['childInPar'], count: 2, parallelType: 'count' as const }, + } as Record, + variables: { + v1: { id: 'v1', name: 'apiHost', type: 'string' as const, value: 'https://example.com' }, + }, + metadata: { name: 'Round Trip', description: 'probe' }, + } +} + +describe('workflow export -> import round trip', () => { + const exported = sanitizeForExport(makeSourceState() as any) + const { data: reimported, errors } = parseWorkflowJson(JSON.stringify(exported)) + + it('re-imports without validation errors', () => { + expect(errors).toEqual([]) + expect(reimported).not.toBeNull() + }) + + it('preserves every block, including children inside loop and parallel containers', () => { + expect(Object.keys(exported.state.blocks).sort()).toEqual([ + 'childInLoop', + 'childInPar', + 'loop1', + 'par1', + 'starter', + ]) + expect(Object.keys(reimported!.blocks)).toHaveLength(5) + }) + + it('preserves every edge', () => { + expect(reimported!.edges).toHaveLength(2) + }) + + it('leaves no dangling parent, edge, loop or parallel reference after id regeneration', () => { + const ids = new Set(Object.values(reimported!.blocks).map((b: any) => b.id)) + + for (const b of Object.values(reimported!.blocks) as any[]) { + if (b.data?.parentId) expect(ids.has(b.data.parentId)).toBe(true) + } + for (const e of reimported!.edges as any[]) { + expect(ids.has(e.source)).toBe(true) + expect(ids.has(e.target)).toBe(true) + } + for (const loop of Object.values(reimported!.loops ?? {}) as any[]) { + for (const n of loop.nodes ?? []) expect(ids.has(n)).toBe(true) + } + for (const p of Object.values(reimported!.parallels ?? {}) as any[]) { + for (const n of p.nodes ?? []) expect(ids.has(n)).toBe(true) + } + }) + + it('regenerates ids so a payload can be imported alongside its source', () => { + expect(Object.keys(reimported!.blocks)).not.toContain('starter') + }) + + it('preserves workflow variables', () => { + expect(reimported!.variables).toMatchObject({ + v1: { id: 'v1', name: 'apiHost', type: 'string', value: 'https://example.com' }, + }) + }) + + it('does not hang on a block name containing regex metacharacters', () => { + const hostile = makeSourceState() + hostile.blocks.starter.name = 'a*a*a*a*a*a*a*a*b' + hostile.blocks.starter.subBlocks.input.value = `<${'a'.repeat(120)}` + + const started = performance.now() + const { data } = parseWorkflowJson(JSON.stringify(sanitizeForExport(hostile as any))) + const elapsed = performance.now() - started + + expect(data).not.toBeNull() + expect(elapsed).toBeLessThan(2000) + }) +}) diff --git a/apps/sim/lib/workflows/persistence/prepare-state.test.ts b/apps/sim/lib/workflows/persistence/prepare-state.test.ts new file mode 100644 index 00000000000..98405fe82cf --- /dev/null +++ b/apps/sim/lib/workflows/persistence/prepare-state.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + * + * Tests the single normalization pipeline shared by `PUT /api/workflows/[id]/state` + * and `POST /api/v1/workflows/import`. Both write paths must land identical data + * for identical input, so this is where that behavior is pinned. + */ + +import { describe, expect, it } from 'vitest' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import type { BlockState } from '@/stores/workflows/workflow/types' + +function block(overrides: Partial & { id: string }): BlockState { + return { + type: 'function', + name: `Block ${overrides.id}`, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + ...overrides, + } as BlockState +} + +describe('prepareWorkflowStateForPersistence', () => { + it('drops edges whose endpoints do not resolve', () => { + const { state, warnings } = prepareWorkflowStateForPersistence({ + blocks: { a: block({ id: 'a' }), b: block({ id: 'b' }) }, + edges: [ + { id: 'good', source: 'a', target: 'b' }, + { id: 'dangling', source: 'a', target: 'ghost' }, + ] as never, + }) + + expect(state.edges.map((e) => e.id)).toEqual(['good']) + expect(warnings.some((w) => w.includes('dangling'))).toBe(true) + }) + + it('drops blocks missing type or name', () => { + const { state } = prepareWorkflowStateForPersistence({ + blocks: { + ok: block({ id: 'ok' }), + noType: block({ id: 'noType', type: '' }), + noName: block({ id: 'noName', name: '' }), + }, + edges: [] as never, + }) + + expect(Object.keys(state.blocks)).toEqual(['ok']) + }) + + it('backfills the columns the normalized tables require', () => { + const { state } = prepareWorkflowStateForPersistence({ + blocks: { + a: { + id: 'a', + type: 'function', + name: 'A', + position: { x: 1, y: 2 }, + } as unknown as BlockState, + }, + edges: [] as never, + }) + + expect(state.blocks.a).toMatchObject({ + enabled: true, + horizontalHandles: true, + height: 0, + subBlocks: {}, + outputs: {}, + }) + }) + + it('recomputes loop and parallel containers from the blocks', () => { + const { state } = prepareWorkflowStateForPersistence({ + blocks: { + loop1: block({ id: 'loop1', type: 'loop', data: { count: 3, loopType: 'for' } as never }), + child: block({ id: 'child', data: { parentId: 'loop1', extent: 'parent' } as never }), + par1: block({ + id: 'par1', + type: 'parallel', + data: { count: 2, parallelType: 'count' } as never, + }), + childP: block({ id: 'childP', data: { parentId: 'par1', extent: 'parent' } as never }), + }, + edges: [] as never, + }) + + expect(state.loops.loop1?.nodes).toEqual(['child']) + expect(state.parallels.par1?.nodes).toEqual(['childP']) + }) + + it('does not mutate the caller-supplied blocks', () => { + const blocks = { a: block({ id: 'a' }) } + const snapshot = structuredClone(blocks) + + prepareWorkflowStateForPersistence({ blocks, edges: [] as never }) + + expect(blocks).toEqual(snapshot) + }) +}) diff --git a/apps/sim/lib/workflows/persistence/prepare-state.ts b/apps/sim/lib/workflows/persistence/prepare-state.ts new file mode 100644 index 00000000000..e6d4ec74a86 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/prepare-state.ts @@ -0,0 +1,71 @@ +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' +import { validateEdges } from '@/stores/workflows/workflow/edge-validation' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' + +export interface PreparedWorkflowState { + blocks: Record + edges: WorkflowState['edges'] + loops: ReturnType + parallels: ReturnType +} + +export interface PrepareWorkflowStateResult { + state: PreparedWorkflowState + /** Human-readable notes about what was dropped or rewritten, for logging. */ + warnings: string[] +} + +/** + * Normalizes a workflow graph into the exact shape the normalized tables expect, + * shared by every server-side write path so they cannot drift apart. + * + * Callers today: `PUT /api/workflows/[id]/state` (the editor and the in-app zip + * importer, which reaches it over HTTP) and `POST /api/v1/workflows/import`. + * + * The steps are order-dependent: + * 1. Strip secrets from inline agent-tool definitions. + * 2. Drop blocks missing `type`/`name` and backfill the columns the tables + * require, so a partial block cannot violate a NOT NULL constraint. + * 3. Drop edges whose endpoints no longer resolve — `workflow_edges` has + * foreign keys onto `workflow_blocks`, so a dangling edge would otherwise + * abort the whole transaction with an opaque database error. + * 4. Recompute loop and parallel containers from the surviving blocks, which + * are the canonical source for both. + */ +export function prepareWorkflowStateForPersistence(state: { + blocks: Record + edges: WorkflowState['edges'] +}): PrepareWorkflowStateResult { + const { blocks: sanitizedBlocks, warnings: sanitizationWarnings } = sanitizeAgentToolsInBlocks( + state.blocks + ) + + const blocks: Record = {} + for (const [blockId, block] of Object.entries(sanitizedBlocks)) { + if (!block.type || !block.name) continue + blocks[blockId] = { + ...block, + enabled: block.enabled !== undefined ? block.enabled : true, + horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, + height: block.height !== undefined ? block.height : 0, + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + } + } + + const validatedEdges = validateEdges(state.edges, blocks) + + return { + state: { + blocks, + edges: validatedEdges.valid, + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), + }, + warnings: [ + ...sanitizationWarnings, + ...validatedEdges.dropped.map(({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`), + ], + } +} diff --git a/apps/sim/lib/workflows/variables/parse.ts b/apps/sim/lib/workflows/variables/parse.ts index 931daa8b621..58c5a41cfa2 100644 --- a/apps/sim/lib/workflows/variables/parse.ts +++ b/apps/sim/lib/workflows/variables/parse.ts @@ -1,9 +1,19 @@ import type { workflow } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' import type { Variable } from '@sim/workflow-types/workflow' import type { InferSelectModel } from 'drizzle-orm' type DbWorkflowVariables = InferSelectModel['variables'] +const VARIABLE_TYPES = new Set([ + 'string', + 'number', + 'boolean', + 'object', + 'array', + 'plain', +]) + /** * Parses the persisted `workflow.variables` JSONB column into the canonical * `Record` shape. @@ -24,6 +34,12 @@ export function parseWorkflowVariables( if (Array.isArray(varsObj)) { const result: Record = {} for (const v of varsObj) { + /** + * Legacy array rows without a usable id would otherwise key the record + * under the literal string "undefined" and emit `id: undefined`, which + * violates the export contract's `id: z.string()`. + */ + if (!v || typeof v !== 'object' || typeof v.id !== 'string' || !v.id) continue result[v.id] = { id: v.id, name: v.name, @@ -53,3 +69,46 @@ export function parseWorkflowVariables( return undefined } + +/** + * Normalizes variables arriving from an *untrusted* import payload into the + * persisted `Record` shape, keyed by variable id. + * + * Distinct from {@link parseWorkflowVariables}, which reads data this system + * already wrote: nothing here is assumed well-formed. Accepts both the record + * form and the legacy array form, and is built on a null-prototype object so a + * payload keyed `__proto__` is stored as an ordinary own key instead of hitting + * the `Object.prototype` setter, which would silently discard the variable. An + * unrecognized `type` falls back to `'string'` rather than being written + * through to the JSONB column verbatim. + */ +export function normalizeImportedVariables(variables: unknown): Record { + /** + * Assembled on a null-prototype object so a `__proto__` key lands as an + * ordinary own property instead of invoking the prototype setter, then + * spread back onto a plain object (spread defines, never assigns, so the key + * survives) — callers and JSON serialization get an ordinary object. + */ + const record: Record = Object.create(null) + if (!variables || typeof variables !== 'object') return { ...record } + + const entries: Array<[string | undefined, unknown]> = Array.isArray(variables) + ? variables.map((value) => [undefined, value]) + : Object.entries(variables) + + for (const [key, value] of entries) { + if (!value || typeof value !== 'object') continue + const raw = value as Partial + const rawId = typeof raw.id === 'string' ? raw.id.trim() : '' + const id = rawId || key || generateId() + + record[id] = { + id, + name: typeof raw.name === 'string' ? raw.name : id, + type: raw.type && VARIABLE_TYPES.has(raw.type) ? raw.type : 'string', + value: raw.value, + } + } + + return { ...record } +} diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index feaf871af1b..eaa60d714bd 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -10,7 +10,7 @@ import { createDefaultInputFormatField } from '@/lib/workflows/input-format' import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks' -import { normalizeName } from '@/executor/constants' +import { escapeRegExp, normalizeName } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { validateEdges } from '@/stores/workflows/workflow/edge-validation' @@ -218,7 +218,22 @@ function updateValueReferences(value: unknown, nameMap: Map): un if (typeof value === 'string') { let updatedValue = value nameMap.forEach((newName, oldName) => { - const regex = new RegExp(`<${oldName}\\.`, 'g') + /** + * A rename to itself is a no-op, so skip the scan entirely. This is the + * whole map on the import path (`regenerateWorkflowIds` seeds it with + * `name -> name`), which turns an O(names x values) rescan of every + * sub-block string into nothing. + */ + if (oldName === newName) return + + /** + * `oldName` is a block name, which reaches this function straight from + * imported workflow JSON — `normalizeWorkflowBlockName` only lowercases + * and strips whitespace/dots, so regex metacharacters survive. Without + * escaping, a name like `a*a*a*a*b` compiles to a catastrophically + * backtracking pattern that pins the event loop on a sub-kilobyte input. + */ + const regex = new RegExp(`<${escapeRegExp(oldName)}\\.`, 'g') updatedValue = updatedValue.replace(regex, `<${newName}.`) }) return updatedValue From a6d6213c7dad2d7dd7dd7f067769aaed5078ffe6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 20:27:17 -0700 Subject: [PATCH 4/4] fix(api): cap import names inside the bound and align the three import paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `truncate` appends its suffix after slicing, so capping at the contract limit produced 203/2003-character values — past the very bound the cap exists to enforce, and into the headroom reserved for dedup suffixes. Reserve the ellipsis inside the limit. - Match `extractWorkflowName`'s candidate order (state.metadata.name before workflow.name) and trim, so the v1 API and the in-app importer resolve the same name for the same payload. Previously a hand-authored payload carrying both could yield two different names. - Run the admin importer through prepareWorkflowStateForPersistence too. It was writing raw parsed state, so a dangling edge tripped the workflow_edges foreign key and a block missing its backfilled columns could land unopenable — the same class this PR just closed on the v1 path. --- .../api/v1/admin/workflows/import/route.ts | 17 +++++++- .../app/api/v1/workflows/import/route.test.ts | 31 +++++++++++++++ apps/sim/app/api/v1/workflows/import/route.ts | 39 +++++++++++++------ 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 220637b4f38..502c2592b0e 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -24,6 +24,7 @@ import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' @@ -106,7 +107,21 @@ export const POST = withRouteHandler( variables: {}, }) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData) + /** + * Same normalization the editor and the v1 import API run, via the one + * shared implementation — without it this route wrote raw parsed state, + * so a dangling edge tripped the `workflow_edges` foreign key and a block + * missing its backfilled columns could land unopenable. + */ + const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(workflowData) + if (warnings.length > 0) { + logger.warn('Admin API: normalized imported workflow with warnings', { warnings }) + } + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, { + ...workflowData, + ...preparedState, + }) if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index 720203b1820..f35a471f802 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -406,6 +406,37 @@ describe('POST /api/v1/workflows/import', () => { expect(whereSpy).toHaveBeenCalled() }) + it('caps a payload-derived name at the declared bound, ellipsis included', async () => { + await POST( + makeRequest( + validBody({ + workflow: { ...EXPORT_ENVELOPE, workflow: { name: 'N'.repeat(500) } }, + }) + ) + ) + + const { name } = mockPerformCreateWorkflow.mock.calls[0][0] + expect(name.length).toBeLessThanOrEqual(200) + expect(name.endsWith('...')).toBe(true) + }) + + it('prefers state.metadata.name, matching the in-app importer', async () => { + await POST( + makeRequest( + validBody({ + workflow: { + workflow: { name: 'FromWorkflow' }, + state: { ...EXPORT_ENVELOPE.state, metadata: { name: 'FromStateMetadata' } }, + }, + }) + ) + ) + + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ name: 'FromStateMetadata' }) + ) + }) + it('unwraps an export response envelope so its metadata still resolves', async () => { await POST(makeRequest(validBody({ workflow: { data: EXPORT_ENVELOPE, limits: {} } }))) diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index b3a33f3ebd7..037377ed063 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -49,6 +49,18 @@ const MAX_IMPORT_BODY_BYTES = 10 * 1024 * 1024 const DEFAULT_IMPORTED_WORKFLOW_NAME = 'Imported Workflow' +const TRUNCATION_SUFFIX = '...' + +/** + * Caps a payload-derived string at `maxLength` *including* the ellipsis. + * `truncate` appends its suffix after slicing, so passing the limit straight + * through would yield `maxLength + 3` characters and overshoot the very bound + * this is enforcing. + */ +function capLength(value: string, maxLength: number): string { + return truncate(value, maxLength - TRUNCATION_SUFFIX.length, TRUNCATION_SUFFIX) +} + /** * Reads a dot-delimited path off a parsed payload and returns it only when it * is a non-empty string, so blank metadata falls through to the next candidate. @@ -59,7 +71,7 @@ function readString(source: unknown, path: string): string | undefined { if (!current || typeof current !== 'object') return undefined current = (current as Record)[segment] } - return typeof current === 'string' && current.trim() ? current : undefined + return typeof current === 'string' && current.trim() ? current.trim() : undefined } /** @@ -83,10 +95,15 @@ function unwrapResponseEnvelope(payload: unknown): unknown { * the importer takes: the export envelope (`workflow.*`, `state.metadata.*`) * and a bare state (`metadata.*`). * - * Payload-derived values are truncated to the same bounds the contract applies - * to the explicit overrides — otherwise the declared `maxLength` would not be - * the effective one, and a caller could store an unbounded name simply by - * embedding it in the payload instead of passing it as a field. + * Candidate order deliberately matches `extractWorkflowName` — the resolver the + * in-app importer has always used — so the same payload yields the same name on + * both surfaces. The in-app version additionally falls back to the uploaded + * filename, which has no analogue here; that is the only intended difference. + * + * Payload-derived values are capped at the same bounds the contract applies to + * the explicit overrides, otherwise the declared `maxLength` would not be the + * effective one — a caller could store an unbounded name simply by embedding it + * in the payload instead of passing it as a field. */ function resolveImportedMetadata( rawPayload: unknown, @@ -97,9 +114,9 @@ function resolveImportedMetadata( const name = overrideName || - truncate( - readString(payload, 'workflow.name') || - readString(payload, 'state.metadata.name') || + capLength( + readString(payload, 'state.metadata.name') || + readString(payload, 'workflow.name') || readString(payload, 'metadata.name') || DEFAULT_IMPORTED_WORKFLOW_NAME, V1_IMPORT_NAME_MAX_LENGTH @@ -107,9 +124,9 @@ function resolveImportedMetadata( const description = overrideDescription ?? - truncate( - readString(payload, 'workflow.description') ?? - readString(payload, 'state.metadata.description') ?? + capLength( + readString(payload, 'state.metadata.description') ?? + readString(payload, 'workflow.description') ?? readString(payload, 'metadata.description') ?? '', V1_IMPORT_DESCRIPTION_MAX_LENGTH