Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/sim/app/api/tools/deployments/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/app/api/tools/deployments/promote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
34 changes: 13 additions & 21 deletions apps/sim/app/api/tools/deployments/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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', () => ({
Expand All @@ -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'
Expand Down Expand Up @@ -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`)
Expand All @@ -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`)
Expand Down
24 changes: 2 additions & 22 deletions apps/sim/app/api/tools/deployments/version/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
51 changes: 16 additions & 35 deletions apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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')

Expand Down Expand Up @@ -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)

Expand All @@ -73,17 +60,18 @@ 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,
request,
})

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(
Expand Down Expand Up @@ -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 })
}

Expand Down
35 changes: 10 additions & 25 deletions apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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')

Expand Down Expand Up @@ -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 })
}

Expand Down Expand Up @@ -94,17 +81,15 @@ export const POST = withRouteHandler(
workflowId: id,
version: targetVersion,
userId,
workflow: workflowData as Record<string, unknown>,
workflow: workflow as Record<string, unknown>,
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) }
)
}

Expand Down
39 changes: 39 additions & 0 deletions apps/sim/app/api/v1/workflows/utils.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
8 changes: 5 additions & 3 deletions apps/sim/app/api/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`)
Expand Down
Loading
Loading