From b37321306077e903c247fe100974000712f2d282 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 16:43:06 -0700 Subject: [PATCH 1/2] refactor(deployments): consolidate version-list reads onto listWorkflowVersions The UI deployments list route and the mothership get_deployment_log handler each had their own inline version query; both now consume the shared persistence helper, so every deployment surface (UI, v1, admin, tools, mothership) reads versions through one code path. --- .../api/workflows/[id]/deployments/route.ts | 26 ++------ .../tools/handlers/deployment/manage.test.ts | 63 +++++++++++-------- .../tools/handlers/deployment/manage.ts | 29 +++------ 3 files changed, 49 insertions(+), 69 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 905908daecb..4a1a9998ca6 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -1,11 +1,10 @@ -import { db, user, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -27,25 +26,10 @@ export const GET = withRouteHandler( return createErrorResponse(error.message, error.status) } - const rawVersions = await db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedBy: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, id)) - .orderBy(desc(workflowDeploymentVersion.version)) - - const versions = rawVersions.map((v) => ({ - ...v, - deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null), + const { versions: rows } = await listWorkflowVersions(id) + const versions = rows.map(({ deployedByName, ...version }) => ({ + ...version, + deployedBy: deployedByName, })) return createSuccessResponse({ versions }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index bfef16561ac..c43c7d37142 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -14,10 +14,12 @@ const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() = const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion -const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock } = vi.hoisted(() => ({ - resolveWorkflowStateRefMock: vi.fn(), - generateWorkflowDiffSummaryMock: vi.fn(), -})) +const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkflowVersionsMock } = + vi.hoisted(() => ({ + resolveWorkflowStateRefMock: vi.fn(), + generateWorkflowDiffSummaryMock: vi.fn(), + listWorkflowVersionsMock: vi.fn(), + })) vi.mock('@sim/db', () => ({ db: { @@ -72,6 +74,11 @@ vi.mock('@/app/api/workflows/utils', () => ({ checkNeedsRedeployment: checkNeedsRedeploymentMock, })) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: listWorkflowVersionsMock, + updateDeploymentVersionMetadata: vi.fn(), +})) + import { db } from '@sim/db' import { executeCheckDeploymentStatus, @@ -216,34 +223,38 @@ describe('executeGetDeploymentLog', () => { }) }) - it('returns versions from the deployment-version table', async () => { - const rows = [ - { - id: 'v2', - version: 2, - name: null, - description: null, - isActive: true, - createdAt: new Date('2026-05-30T00:00:00.000Z'), - createdBy: 'user-1', - }, - { - id: 'v1', - version: 1, - name: 'first', - description: 'initial', - isActive: false, - createdAt: new Date('2026-05-29T00:00:00.000Z'), - createdBy: null, - }, - ] - vi.mocked(db.select).mockReturnValueOnce(selectChain(rows) as never) + it('returns versions from the shared listWorkflowVersions helper', async () => { + listWorkflowVersionsMock.mockResolvedValue({ + versions: [ + { + id: 'v2', + version: 2, + name: null, + description: null, + isActive: true, + createdAt: new Date('2026-05-30T00:00:00.000Z'), + createdBy: 'user-1', + deployedByName: 'Waleed', + }, + { + id: 'v1', + version: 1, + name: 'first', + description: 'initial', + isActive: false, + createdAt: new Date('2026-05-29T00:00:00.000Z'), + createdBy: null, + deployedByName: null, + }, + ], + }) const result = await executeGetDeploymentLog({ workflowId: 'wf-1' }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) + expect(listWorkflowVersionsMock).toHaveBeenCalledWith('wf-1') expect(result.success).toBe(true) expect(result.output).toMatchObject({ workflowId: 'wf-1', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 4c40ae6820f..d3c1c3df7db 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -1,13 +1,7 @@ import { db } from '@sim/db' -import { - chat, - workflow, - workflowDeploymentVersion, - workflowMcpServer, - workflowMcpTool, -} from '@sim/db/schema' +import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { performCreateWorkflowMcpServer, @@ -16,7 +10,10 @@ import { } from '@/lib/mcp/orchestration' import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration' -import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' +import { + listWorkflowVersions, + updateDeploymentVersionMetadata, +} from '@/lib/workflows/persistence/utils' import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access' import type { @@ -357,19 +354,7 @@ export async function executeGetDeploymentLog( } await ensureWorkflowAccess(workflowId, context.userId) - const rows = await db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - }) - .from(workflowDeploymentVersion) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - .orderBy(desc(workflowDeploymentVersion.version)) + const { versions: rows } = await listWorkflowVersions(workflowId) const versions = rows.map((r) => ({ id: r.id, From 2e6570e6ee6f2ec7cb796032a907f0c36752babd Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 16:56:04 -0700 Subject: [PATCH 2/2] refactor(deployments): shared status mapper, single-version fetch, and v1 workflow resolver - statusForOrchestrationError maps orchestration error codes to HTTP statuses in one place (was an identical ternary in six routes: UI deploy/activate, v1 deploy/rollback, tool deploy/promote) - getWorkflowDeploymentVersion consolidates the single-version fetch used by the UI version GET and the deployments tool version route - resolveV1DeploymentWorkflow extracts the v1 mutation prologue (active-record load, admin permission check, 404 masking) shared by deploy, undeploy, and rollback --- .../app/api/tools/deployments/deploy/route.ts | 8 +-- .../api/tools/deployments/promote/route.ts | 8 +-- .../app/api/tools/deployments/routes.test.ts | 34 +++++-------- .../api/tools/deployments/version/route.ts | 24 +-------- .../app/api/v1/workflows/[id]/deploy/route.ts | 51 ++++++------------- .../api/v1/workflows/[id]/rollback/route.ts | 35 ++++--------- apps/sim/app/api/v1/workflows/utils.ts | 39 ++++++++++++++ .../app/api/workflows/[id]/deploy/route.ts | 8 +-- .../[id]/deployments/[version]/route.ts | 26 +++------- apps/sim/lib/workflows/orchestration/types.ts | 10 ++++ apps/sim/lib/workflows/persistence/utils.ts | 38 ++++++++++++++ 11 files changed, 150 insertions(+), 131 deletions(-) create mode 100644 apps/sim/app/api/v1/workflows/utils.ts diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts index 74d87926bc4..d5731724e49 100644 --- a/apps/sim/app/api/tools/deployments/deploy/route.ts +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performFullDeploy } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, @@ -58,9 +59,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!result.success) { - const status = - result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 - return deploymentToolError(result.error || 'Failed to deploy workflow', status) + return deploymentToolError( + result.error || 'Failed to deploy workflow', + statusForOrchestrationError(result.errorCode) + ) } return NextResponse.json({ diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts index e433f730b55..01a296fc2fa 100644 --- a/apps/sim/app/api/tools/deployments/promote/route.ts +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, @@ -58,9 +59,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 - return deploymentToolError(result.error || 'Failed to promote deployment version', status) + return deploymentToolError( + result.error || 'Failed to promote deployment version', + statusForOrchestrationError(result.errorCode) + ) } return NextResponse.json({ diff --git a/apps/sim/app/api/tools/deployments/routes.test.ts b/apps/sim/app/api/tools/deployments/routes.test.ts index bf75b419fcd..2c77a0b2e20 100644 --- a/apps/sim/app/api/tools/deployments/routes.test.ts +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -5,7 +5,6 @@ * session/internal auth, workspace permission enforcement, and the mapping of * orchestration results to tool responses. */ -import { db } from '@sim/db' import { createMockRequest, hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing' import { WorkflowLockedError } from '@sim/workflow-authz' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,12 +15,14 @@ const { mockPerformFullUndeploy, mockPerformActivateVersion, mockListWorkflowVersions, + mockGetWorkflowDeploymentVersion, } = vi.hoisted(() => ({ mockEnforceUserRateLimit: vi.fn(), mockPerformFullDeploy: vi.fn(), mockPerformFullUndeploy: vi.fn(), mockPerformActivateVersion: vi.fn(), mockListWorkflowVersions: vi.fn(), + mockGetWorkflowDeploymentVersion: vi.fn(), })) vi.mock('@/lib/core/rate-limiter', () => ({ @@ -36,6 +37,7 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ listWorkflowVersions: mockListWorkflowVersions, + getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, })) import { POST as deployPost } from '@/app/api/tools/deployments/deploy/route' @@ -313,26 +315,16 @@ describe('GET /api/tools/deployments/versions', () => { }) describe('GET /api/tools/deployments/version', () => { - function mockVersionRow(rows: unknown[]) { - vi.mocked(db.select).mockReturnValueOnce({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve(rows)) })), - })), - } as never) - } - it('returns version metadata and the deployed state', async () => { - mockVersionRow([ - { - id: 'v-3', - version: 3, - name: 'Release 3', - description: null, - isActive: false, - createdAt: '2026-06-12T00:00:00.000Z', - state: { blocks: {}, edges: [] }, - }, - ]) + mockGetWorkflowDeploymentVersion.mockResolvedValue({ + id: 'v-3', + version: 3, + name: 'Release 3', + description: null, + isActive: false, + createdAt: '2026-06-12T00:00:00.000Z', + state: { blocks: {}, edges: [] }, + }) const response = await getVersionGet( makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=3`) @@ -352,7 +344,7 @@ describe('GET /api/tools/deployments/version', () => { }) it('returns 404 when the version does not exist', async () => { - mockVersionRow([]) + mockGetWorkflowDeploymentVersion.mockResolvedValue(null) const response = await getVersionGet( makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=9`) diff --git a/apps/sim/app/api/tools/deployments/version/route.ts b/apps/sim/app/api/tools/deployments/version/route.ts index 21dd85e92a4..86e56cbaabc 100644 --- a/apps/sim/app/api/tools/deployments/version/route.ts +++ b/apps/sim/app/api/tools/deployments/version/route.ts @@ -1,12 +1,10 @@ -import { db } from '@sim/db' -import { workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deploymentsGetVersionContract } from '@/lib/api/contracts/tools/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, @@ -41,25 +39,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') if (!access.ok) return access.response - const [row] = await db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - state: workflowDeploymentVersion.state, - }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) - .limit(1) - + const row = await getWorkflowDeploymentVersion(workflowId, version) if (!row) { return deploymentToolError('Deployment version not found', 404) } diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index f62421e2e3d..34982534db9 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -1,10 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { - assertWorkflowMutable, - getActiveWorkflowRecord, - WorkflowLockedError, -} from '@sim/workflow-authz' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { v1DeployWorkflowBodySchema, @@ -16,12 +12,10 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { - checkRateLimit, - createRateLimitResponse, - validateWorkspaceAccess, -} from '@/app/api/v1/middleware' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' const logger = createLogger('V1WorkflowDeployAPI') @@ -55,16 +49,9 @@ export const POST = withRouteHandler( return validationErrorResponse(body.error) } - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - const workspaceId = workflowData.workspaceId - - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') - if (accessError) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return target.response + const { workflow, workspaceId } = target await assertWorkflowMutable(id) @@ -73,7 +60,7 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId, - workflowName: workflowData.name || undefined, + workflowName: workflow.name || undefined, versionName: body.data.name, versionDescription: body.data.description ?? undefined, requestId, @@ -81,9 +68,10 @@ export const POST = withRouteHandler( }) if (!result.success) { - const status = - result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 - return NextResponse.json({ error: result.error || 'Failed to deploy workflow' }, { status }) + return NextResponse.json( + { error: result.error || 'Failed to deploy workflow' }, + { status: statusForOrchestrationError(result.errorCode) } + ) } captureServerEvent( @@ -142,18 +130,11 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - const workspaceId = workflowData.workspaceId - - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') - if (accessError) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return target.response + const { workflow, workspaceId } = target - if (!workflowData.isDeployed) { + if (!workflow.isDeployed) { return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) } diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index a29cbd9bd1f..c35933d2773 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -1,10 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { - assertWorkflowMutable, - getActiveWorkflowRecord, - WorkflowLockedError, -} from '@sim/workflow-authz' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { v1RollbackWorkflowBodySchema, @@ -15,13 +11,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' -import { - checkRateLimit, - createRateLimitResponse, - validateWorkspaceAccess, -} from '@/app/api/v1/middleware' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' const logger = createLogger('V1WorkflowRollbackAPI') @@ -55,18 +49,11 @@ export const POST = withRouteHandler( return validationErrorResponse(body.error) } - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - const workspaceId = workflowData.workspaceId - - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') - if (accessError) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return target.response + const { workflow, workspaceId } = target - if (!workflowData.isDeployed) { + if (!workflow.isDeployed) { return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) } @@ -94,17 +81,15 @@ export const POST = withRouteHandler( workflowId: id, version: targetVersion, userId, - workflow: workflowData as Record, + workflow: workflow as Record, requestId, request, }) if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 return NextResponse.json( { error: result.error || 'Failed to roll back workflow' }, - { status } + { status: statusForOrchestrationError(result.errorCode) } ) } diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts new file mode 100644 index 00000000000..92e321f1538 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -0,0 +1,39 @@ +import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/workflow-authz' +import { NextResponse } from 'next/server' +import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' + +function workflowNotFoundResponse(): NextResponse { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) +} + +/** + * Resolves the target workflow for a v1 deployment mutation: loads the active + * record and verifies the caller's admin permission on its workspace. Access + * failures are masked as 404, matching the v1 workflow read surface so + * unauthorized callers cannot probe workflow existence. + */ +export async function resolveV1DeploymentWorkflow( + rateLimit: RateLimitResult, + userId: string, + workflowId: string +): Promise< + | { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string } + | { ok: false; response: NextResponse } +> { + const workflow = await getActiveWorkflowRecord(workflowId) + if (!workflow?.workspaceId) { + return { ok: false, response: workflowNotFoundResponse() } + } + + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflow.workspaceId, + 'admin' + ) + if (accessError) { + return { ok: false, response: workflowNotFoundResponse() } + } + + return { ok: true, workflow, workspaceId: workflow.workspaceId } +} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 23d574efc41..0355519c16b 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, @@ -106,9 +107,10 @@ export const POST = withRouteHandler( }) if (!result.success) { - const status = - result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 - return createErrorResponse(result.error || 'Failed to deploy workflow', status) + return createErrorResponse( + result.error || 'Failed to deploy workflow', + statusForOrchestrationError(result.errorCode) + ) } logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index c4cbd1acf63..751171b0b3b 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -8,7 +8,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { + getWorkflowDeploymentVersion, + updateDeploymentVersionMetadata, +} from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -37,17 +41,7 @@ export const GET = withRouteHandler( return createErrorResponse('Invalid version', 400) } - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .limit(1) - + const row = await getWorkflowDeploymentVersion(id, versionNum) if (!row?.state) { return createErrorResponse('Deployment version not found', 404) } @@ -110,15 +104,9 @@ export const PATCH = withRouteHandler( }) if (!activateResult.success) { - const status = - activateResult.errorCode === 'not_found' - ? 404 - : activateResult.errorCode === 'validation' - ? 400 - : 500 return createErrorResponse( activateResult.error || 'Failed to activate deployment', - status + statusForOrchestrationError(activateResult.errorCode) ) } diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts index ae9f84a9c22..03dd0050dca 100644 --- a/apps/sim/lib/workflows/orchestration/types.ts +++ b/apps/sim/lib/workflows/orchestration/types.ts @@ -1 +1,11 @@ export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | 'internal' + +/** + * Maps an orchestration error code to its HTTP status. Shared by every route + * surface (UI, v1, tool routes) so deployment errors map identically. + */ +export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { + if (code === 'validation') return 400 + if (code === 'not_found') return 404 + return 500 +} diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 28c9ae95806..5663d0f3fd7 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -1186,6 +1186,44 @@ export async function findPreviousDeploymentVersion( return { ok: true, version: previousRow.version } } +/** + * Fetches a single deployment version of a workflow, including its state + * snapshot. Returns null when the version does not exist. + */ +export async function getWorkflowDeploymentVersion( + workflowId: string, + version: number +): Promise<{ + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: Date + state: unknown +} | null> { + const [row] = await db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.version, version) + ) + ) + .limit(1) + + return row ?? null +} + export async function listWorkflowVersions(workflowId: string): Promise<{ versions: Array<{ id: string