diff --git a/apps/sim/app/api/resume/resume-handler.ts b/apps/sim/app/api/resume/resume-handler.ts index 266bd3bc038..2d404e163f1 100644 --- a/apps/sim/app/api/resume/resume-handler.ts +++ b/apps/sim/app/api/resume/resume-handler.ts @@ -1,50 +1,19 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' -import { - assertBillingAttributionSnapshot, - type BillingAttributionSnapshot, -} from '@/lib/billing/core/billing-attribution' -import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' -import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' -import { toTriggerMaxDurationSeconds } from '@/lib/core/execution-limits' -import { generateRequestId } from '@/lib/core/utils/request' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' -import { preprocessExecution } from '@/lib/execution/preprocessing' -import { RESUME_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { - agentStreamProtocolResponseHeaders, - createStreamingResponse, -} from '@/lib/workflows/streaming/streaming' -import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' -import { ExecutionSnapshot } from '@/executor/execution/snapshot' + executeResumeWorkflow, + ResumeWorkflowExecutionError, + type ResumeWorkflowExecutionResult, +} from '@/lib/workflows/executor/resume-execution' +import { agentStreamProtocolResponseHeaders } from '@/lib/workflows/streaming/streaming' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' const logger = createLogger('WorkflowResumeAPI') -const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' -const INVALID_PAUSED_ATTRIBUTION_ERROR = - 'Paused execution billing attribution is missing or invalid' -const PAUSED_EXECUTION_BINDING_ERROR = - 'Paused execution snapshot does not match the requested workflow or execution' -const PAUSED_ATTRIBUTION_BINDING_ERROR = - 'Paused execution billing attribution does not match its workspace or actor' - -interface PausedExecutionSnapshotSource { - workflowId: string - executionId: string - executionSnapshot: unknown -} - -interface PausedExecutionSnapshotBinding { - snapshot: ExecutionSnapshot - billingAttribution: BillingAttributionSnapshot -} - interface HandleResumeExecutionOptions { request: NextRequest workflowId: string @@ -55,344 +24,96 @@ interface HandleResumeExecutionOptions { resumeInput: unknown isApiCaller: boolean pollingSurface: 'legacy' | 'v2' - /** When false, inherited stream-mode resumes use async JSON polling instead of SSE. */ allowStreaming?: boolean } -function loadPausedExecutionSnapshot( - pausedExecution: PausedExecutionSnapshotSource, - expected: { workflowId: string; executionId: string; workspaceId: string } -): PausedExecutionSnapshotBinding { - if ( - !isRecordLike(pausedExecution.executionSnapshot) || - typeof pausedExecution.executionSnapshot.snapshot !== 'string' - ) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let snapshot: ExecutionSnapshot - try { - snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) - } catch { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - if (!isRecordLike(snapshot.metadata)) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let billingAttribution: BillingAttributionSnapshot - try { - billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) - } catch { - throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) - } - - if ( - pausedExecution.workflowId !== expected.workflowId || - pausedExecution.executionId !== expected.executionId || - snapshot.metadata.workflowId !== expected.workflowId || - snapshot.metadata.executionId !== expected.executionId - ) { - throw new Error(PAUSED_EXECUTION_BINDING_ERROR) - } - - if ( - snapshot.metadata.workspaceId !== expected.workspaceId || - billingAttribution.workspaceId !== expected.workspaceId || - snapshot.metadata.userId !== billingAttribution.actorUserId - ) { - throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) - } - - return { snapshot, billingAttribution } -} - -/** Executes the shared resume flow while preserving each API surface's polling contract. */ -export async function handleResumeExecution({ - request, - workflowId, - executionId, - contextId, - workspaceId, - userId, - resumeInput, - isApiCaller, - pollingSurface, - allowStreaming = true, -}: HandleResumeExecutionOptions): Promise { - const requestId = generateRequestId() - const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) - if (!pausedExecution) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } - - let snapshotBinding: PausedExecutionSnapshotBinding - try { - snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { - workflowId, - executionId, - workspaceId, - }) - } catch (error) { - const message = toError(error).message - logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { - workflowId, - executionId, - error: message, - }) - return NextResponse.json({ error: message }, { status: 500 }) - } - - const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding - const resumeExecutionId = generateId() - - logger.info(`[${requestId}] Preprocessing resume execution`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - userId, - actorUserId: billingAttribution.actorUserId, - }) - - /** - * This preflight gives synchronous callers current block/usage feedback - * without reserving under a throwaway id. The claimed resume reruns every - * gate and reserves atomically under its persisted resume execution id. - */ - const preprocessResult = await preprocessExecution({ - workflowId, - userId, - triggerType: 'manual', - executionId: resumeExecutionId, - requestId, - checkRateLimit: false, - checkDeployment: false, - skipConcurrencyReservation: true, - logPreprocessingErrors: false, - workspaceId, - billingAttribution, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed for resume`, { - workflowId, - parentExecutionId: executionId, - error: preprocessResult.error?.message, - statusCode: preprocessResult.error?.statusCode, - }) - - return NextResponse.json( - { - error: - preprocessResult.error?.message || - 'Failed to validate resume execution. Please try again.', - }, - { status: preprocessResult.error?.statusCode || 400 } - ) - } - - logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - actorUserId: preprocessResult.actorUserId, - }) - - try { - const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ - executionId, - workflowId, - contextId, - resumeInput, - userId, - allowedPauseKinds: ['human'], - }) - - if (enqueueResult.status === 'queued') { +function presentResumeResult( + result: ResumeWorkflowExecutionResult, + request: NextRequest, + workflowId: string, + pollingSurface: 'legacy' | 'v2' +): NextResponse { + switch (result.kind) { + case 'queued': return NextResponse.json({ status: 'queued', - executionId: enqueueResult.resumeExecutionId, - queuePosition: enqueueResult.queuePosition, + executionId: result.executionId, + queuePosition: result.queuePosition, message: 'Resume queued. It will run after current resumes finish.', }) - } - - const resumeArgs = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecution: enqueueResult.pausedExecution, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - } - - const persistedExecutionMode = persistedSnapshot.metadata.executionMode ?? 'sync' - const executionMode = isApiCaller - ? persistedExecutionMode === 'stream' && !allowStreaming - ? 'async' - : persistedExecutionMode - : undefined - const includeThinking = persistedSnapshot.metadata.includeThinking === true - const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true - - if (isApiCaller && executionMode === 'stream') { - const stream = await createStreamingResponse({ - requestId, - streamConfig: { - selectedOutputs: persistedSnapshot.selectedOutputs, - timeoutMs: preprocessResult.executionTimeout?.sync, - includeThinking, - includeToolCalls, - }, - executionId: enqueueResult.resumeExecutionId, - workspaceId, - workflowId, - userId: enqueueResult.userId, - allowLargeValueWorkflowScope: true, - requestSignal: request.signal, - requestHeaders: request.headers, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - PauseResumeManager.startResumeExecution({ - ...resumeArgs, - onStream, - onBlockComplete, - abortSignal, - }), - }) - - return new NextResponse(stream, { + case 'stream': + return new NextResponse(result.stream, { headers: { ...SSE_HEADERS, ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), - 'X-Execution-Id': enqueueResult.resumeExecutionId, + 'X-Execution-Id': result.executionId, }, }) - } - - if (isApiCaller && executionMode === 'sync') { - const result = await PauseResumeManager.startResumeExecution(resumeArgs) - + case 'sync': return NextResponse.json({ success: result.success, - status: result.status ?? (result.success ? 'completed' : 'failed'), - executionId: enqueueResult.resumeExecutionId, + status: result.status, + executionId: result.executionId, output: result.output, error: result.error, - metadata: result.metadata - ? { - duration: result.metadata.duration, - startTime: result.metadata.startTime, - endTime: result.metadata.endTime, - } - : undefined, + metadata: result.metadata, }) - } - - if (isApiCaller && executionMode === 'async') { - const correlation: AsyncExecutionCorrelation = { - executionId, - requestId, - source: 'workflow', - workflowId, - triggerType: 'resume', - } - const resumePayload: ResumeExecutionPayload = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecutionId: enqueueResult.pausedExecution.id, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - workflowId, - parentExecutionId: executionId, - executionTimeoutMs: preprocessResult.executionTimeout.async, - billingAttribution: preprocessResult.billingAttribution, - } - - let jobId: string - try { - const jobQueue = await getJobQueue() - const executeInline = shouldExecuteInline() - jobId = await jobQueue.enqueue('resume-execution', resumePayload, { - ...(pollingSurface === 'v2' - ? { jobId: `${RESUME_EXECUTION_JOB_ID_PREFIX}${enqueueResult.resumeEntryId}` } - : {}), - metadata: { - executionId, - workflowId, - workspaceId, - userId, - resumeExecutionId: enqueueResult.resumeExecutionId, - correlation, - }, - maxDurationSeconds: toTriggerMaxDurationSeconds(preprocessResult.executionTimeout.async), - ...(executeInline - ? { - runner: (_queuedPayload: unknown, signal: AbortSignal) => - executeResumeJob(resumePayload, signal), - } - : {}), - }) - logger.info('Enqueued async resume execution', { - jobId, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - } catch (dispatchError) { - logger.error('Failed to dispatch async resume execution', { - error: toError(dispatchError).message, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - await PauseResumeManager.markResumeAttemptFailed({ - resumeEntryId: enqueueResult.resumeEntryId, - pausedExecutionId: enqueueResult.pausedExecution.id, - parentExecutionId: executionId, - contextId: enqueueResult.contextId, - failureReason: 'Failed to queue async resume execution', - }) - await PauseResumeManager.processQueuedResumes(executionId, workflowId) - return NextResponse.json( - { error: 'Failed to queue resume execution. Please try again.' }, - { status: 503 } - ) - } - + case 'async': return NextResponse.json( { success: true, async: true, - ...(pollingSurface === 'legacy' ? { jobId } : {}), - executionId: enqueueResult.resumeExecutionId, + ...(pollingSurface === 'legacy' ? { jobId: result.jobId } : {}), + executionId: result.executionId, message: 'Resume execution queued', statusUrl: pollingSurface === 'legacy' - ? `${getBaseUrl()}/api/jobs/${jobId}` - : `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${enqueueResult.resumeExecutionId}`, + ? `${getBaseUrl()}/api/jobs/${result.jobId}` + : `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${result.executionId}`, }, { status: 202 } ) - } - - PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { - logger.error( - 'Failed to start resume execution', - projectResolvedSecretDiagnosticError(error, undefined, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - ) - }) + case 'started': + return NextResponse.json({ + status: 'started', + executionId: result.executionId, + message: 'Resume execution started.', + }) + } +} - return NextResponse.json({ - status: 'started', - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution started.', +/** Adapts the transport-neutral resume transition to the legacy response contract. */ +export async function handleResumeExecution({ + request, + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, + allowStreaming = true, +}: HandleResumeExecutionOptions): Promise { + try { + const result = await executeResumeWorkflow({ + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, + allowStreaming, + requestSignal: request.signal, + requestHeaders: request.headers, }) + return presentResumeResult(result, request, workflowId, pollingSurface) } catch (error) { + if (error instanceof ResumeWorkflowExecutionError) { + return NextResponse.json({ error: error.message }, { status: error.statusCode }) + } logger.error( 'Resume request failed', projectResolvedSecretDiagnosticError(error, undefined, { @@ -401,11 +122,9 @@ export async function handleResumeExecution({ contextId, }) ) - const statusCode = - isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400 return NextResponse.json( { error: toError(error).message || 'Failed to queue resume request' }, - { status: statusCode } + { status: 400 } ) } } diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts new file mode 100644 index 00000000000..049527d5fda --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + defineRoute: vi.fn((definition) => definition), + capture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { DELETE, POST } from '@/app/api/v2/workflows/[id]/deploy/route' + +describe('/api/v2/workflows/[id]/deploy route definitions', () => { + it('keeps an omitted deploy body valid and binds the authorized deployment use case', async () => { + expect(v2DeployWorkflowContract.body?.parse(undefined)).toEqual({}) + expect(POST).toMatchObject({ + operation: workflowOperations.deploy, + useCase: deployWorkflow, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { optionalJsonBody: true }, + }) + expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( + expect.objectContaining({ + workflowId: 'workflow-1', + name: undefined, + description: undefined, + }) + ) + + const invalidJsonResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'invalidJsonResponse' + )() + expect(invalidJsonResponse.status).toBe(400) + expect(await invalidJsonResponse.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + + const payloadTooLargeResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'payloadTooLargeResponse' + )() + expect(payloadTooLargeResponse.status).toBe(413) + expect(await payloadTooLargeResponse.json()).toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + }) + + it('presents the full declared deployment lifecycle response', () => { + const body = Reflect.get( + POST, + 'present' + )({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + deployedAt: new Date('2026-01-01T00:00:00.000Z'), + version: 2, + warnings: [], + activeDeployment: null, + latestDeploymentAttempt: null, + }) + expect(body.data.isDeployed).toBe(false) + expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body) + }) + + it('keeps product analytics on the v2 adapter', async () => { + const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' } + await Reflect.get( + POST, + 'onSuccess' + )({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + result, + }) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'workflow_deployed', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, + expect.objectContaining({ groups: { workspace: 'workspace-1' } }) + ) + }) + + it('keeps undeploy on the authorized operation and declared response schema', () => { + expect(DELETE).toMatchObject({ + operation: workflowOperations.undeploy, + useCase: undeployWorkflow, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + }) + const body = Reflect.get( + DELETE, + 'present' + )({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + warnings: [], + }) + expect(v2UndeployWorkflowContract.response.schema.parse(body)).toEqual(body) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 0bf14474706..4827ea5e8e0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -1,139 +1,91 @@ -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2DeployWorkflowContract, v2UndeployWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody } from '@/lib/api/server' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' -import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' - -const logger = createLogger('V2WorkflowDeployAPI') +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2DeployWorkflowContract, - rateLimitEndpoint: 'workflow-deploy', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const rawBody = await parseOptionalJsonBody(request) - if (!rawBody.success) { - return rawBody.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : v2Error('BAD_REQUEST', 'Request body must be valid JSON') - } - const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) - if (!body.success) return v2ValidationError(body.error) - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - const { workspaceId } = target - - await assertWorkflowMutable(id) - - logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) - - const result = await performFullDeploy({ - workflowId: id, - userId, - versionName: body.data.name, - versionDescription: body.data.description ?? undefined, - requestId, - }) - - if (!result.success) { - const code = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to deploy workflow') - } - - captureServerEvent( - userId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - - return v2Data( - { - id, - isDeployed: true, - deployedAt: result.deployedAt?.toISOString() ?? null, - version: result.version, - warnings: result.warnings ?? [], - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof WorkflowLockedError) { - return v2Error('LOCKED', error.message) - } - throw error + auth: v2ApiKeyAuth, + operation: workflowOperations.deploy, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { + optionalJsonBody: true, + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + name: body.name, + description: body.description ?? undefined, + requestId: generateRequestId(), + }), + useCase: deployWorkflow, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + }, + }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'personal_api_key') { + throw new Error('Admin deployment unexpectedly admitted a workspace API key') } + captureServerEvent( + principal.userId, + 'workflow_deployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) }, }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2UndeployWorkflowContract, - rateLimitEndpoint: 'workflow-deploy', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - const { workflow, workspaceId } = target - - if (!workflow.isDeployed) { - return v2Error('BAD_REQUEST', 'Workflow is not deployed') - } - - await assertWorkflowMutable(id) - - logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) - - const result = await performFullUndeploy({ workflowId: id, userId, requestId }) - if (!result.success) { - return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') - } - - captureServerEvent( - userId, - 'workflow_undeployed', - { workflow_id: id, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return v2Data( - { - id, - isDeployed: false, - deployedAt: null, - warnings: result.warnings ?? [], - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof WorkflowLockedError) { - return v2Error('LOCKED', error.message) - } - throw error + auth: v2ApiKeyAuth, + operation: workflowOperations.undeploy, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + useCase: undeployWorkflow, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + activeDeployment: null, + latestDeploymentAttempt: null, + }, + }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'personal_api_key') { + throw new Error('Admin undeployment unexpectedly admitted a workspace API key') } + captureServerEvent( + principal.userId, + 'workflow_undeployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index ac9cdc87de5..f5de09391d9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -18,39 +18,58 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthenticateV1Request, + MockV2ApiKeyUnauthenticatedError, + mockAuthenticateV2ApiKey, mockClaimExecutionId, + mockCheckOperationRate, + mockCheckPreAuthRate, mockEnqueue, mockExecuteWorkflowCore, mockGenerateId, - mockGetWorkspaceBillingSettings, mockHasDurableExecutionOwner, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ - mockAuthenticateV1Request: vi.fn(), + MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {}, + mockAuthenticateV2ApiKey: vi.fn(), mockClaimExecutionId: vi.fn(), + mockCheckOperationRate: vi.fn(), + mockCheckPreAuthRate: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'), mockExecuteWorkflowCore: vi.fn(), mockGenerateId: vi.fn(() => 'execution-123'), - mockGetWorkspaceBillingSettings: vi.fn(), mockHasDurableExecutionOwner: vi.fn(), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), mockValidatePublicApiAllowed: vi.fn(), })) -vi.mock('@/app/api/v1/auth', () => ({ - authenticateV1Request: mockAuthenticateV1Request, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mockCheckPreAuthRate + checkRateLimitDirectOrThrow = mockCheckOperationRate + }, })) vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ releaseExecutionSlot: mockReleaseExecutionSlot, })) -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('read'), })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ @@ -164,7 +183,25 @@ const workflowRecord = { variables: {}, } +const applicationContext = { + workflowId: 'workflow-1', + workflow: workflowRecord, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'actor-1', +} + function callExecute(body: Record, headers: Record = {}) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + 'X-API-Key': 'test-key', + ...headers, + }) + return POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) +} + +function callPublicExecute(body: Record, headers: Record = {}) { const req = createMockRequest('POST', body, { 'Content-Type': 'application/json', ...headers, @@ -178,12 +215,28 @@ describe('POST /api/v2/workflows/[id]/execute', () => { resetDbChainMock() setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockGenerateId.mockReturnValue('execution-123') - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', + mockCheckPreAuthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-08T05:00:00Z'), + }) + mockCheckOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T05:00:00Z'), + }) + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + rolloutUserId: 'actor-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: null, keyType: 'workspace', - workspaceId: 'workspace-1', }) + dbChainMockFns.limit.mockResolvedValue([applicationContext]) mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, @@ -272,6 +325,47 @@ describe('POST /api/v2/workflows/[id]/execute', () => { ) }) + it('admits keyed execution through request-rate buckets before separate execution preprocessing', async () => { + const response = await callExecute({ input: {} }) + + expect(response.status).toBe(200) + expect(mockCheckPreAuthRate).toHaveBeenCalledOnce() + expect(mockCheckOperationRate).toHaveBeenCalledTimes(2) + expect(mockCheckOperationRate).toHaveBeenCalledWith( + 'v2:workflows.execute:api-key:key-1', + expect.anything() + ) + expect(mockCheckOperationRate).toHaveBeenCalledWith( + 'v2:workflows.execute:workspace:workspace-1', + expect.anything() + ) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'sync' }) + ) + }) + + it('stops keyed execution at request-rate admission without consuming execution quota', async () => { + mockCheckOperationRate + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-08T05:00:00Z'), + retryAfterMs: 12_000, + }) + .mockResolvedValueOnce({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T05:00:00Z'), + }) + + const response = await callExecute({ input: {} }) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('12') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + expect(mockClaimExecutionId).not.toHaveBeenCalled() + }) + it('404s the whole surface when the v2-api flag is off', async () => { const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') @@ -300,11 +394,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('masks a workspace-key/workflow mismatch as 404', async () => { - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'other-workspace', + keyId: 'key-1', + }, + rolloutUserId: 'actor-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:other-workspace'], + rateLimitSubscription: null, keyType: 'workspace', - workspaceId: 'other-workspace', }) const res = await callExecute({ input: {} }) @@ -314,12 +413,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('rejects personal keys when the workspace disallows them', async () => { - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'key-user-1', keyId: 'key-1' }, + rolloutUserId: 'key-user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:key-user-1'], + rateLimitSubscription: null, keyType: 'personal', }) - mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { ...applicationContext, allowPersonalApiKeys: false }, + ]) const res = await callExecute({ input: {} }) @@ -352,6 +455,19 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(mockPreprocessExecution).not.toHaveBeenCalled() }) + it('rejects an invalid API key without entering public execution', async () => { + mockAuthenticateV2ApiKey.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('Invalid API key') + ) + + const response = await callExecute({ input: {} }, { 'X-API-Key': 'invalid' }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mockAuthorize).not.toHaveBeenCalled() + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + it('surfaces the rate-limit failure with Retry-After', async () => { mockPreprocessExecution.mockResolvedValue({ success: false, @@ -371,32 +487,36 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('runs the anonymous public path sync but refuses async', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) - const okRes = await callExecute({ input: {} }) + const okRes = await callPublicExecute({ input: {} }) expect(okRes.status).toBe(200) + expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() + expect(mockCheckOperationRate).not.toHaveBeenCalled() + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'sync' }) + ) - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) - const asyncRes = await callExecute({ input: {}, async: true }) + const asyncRes = await callPublicExecute({ input: {}, async: true }) expect(asyncRes.status).toBe(400) }) it('401s non-public workflows without a key', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) - const res = await callExecute({ input: {} }) + const res = await callPublicExecute({ input: {} }) expect(res.status).toBe(401) expect((await res.json()).error.code).toBe('UNAUTHORIZED') + expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() + expect(mockCheckOperationRate).not.toHaveBeenCalled() }) it('releases the unused execution-id claim after a failed preprocess', async () => { @@ -410,4 +530,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(res.status).toBe(404) expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() }) + + it('returns a safe error when canonical workflow lookup fails', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database connection details')) + + const response = await callExecute({ input: {} }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 4947f04ba32..5c7a78cad99 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -10,13 +10,24 @@ import { v2ExecuteWorkflowContract, } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' +import { + admitV2Request, + V2RouteInfrastructureError, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth' import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' +import { workflowOperations } from '@/lib/workflows/application/operations' import { type ExecuteWorkflowServiceFailure, + type ExecuteWorkflowServiceResult, executeWorkflowService, } from '@/lib/workflows/executor/execute-service' import { @@ -25,8 +36,6 @@ import { clientAcceptsAgentStreamProtocol, hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' -import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { authenticateV1Request } from '@/app/api/v1/auth' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { @@ -90,9 +99,9 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { * an in-band run failure is `status: 'failed'`, never an HTTP error. A * Response block's declared payload stays inside `output` — v2 never lets a * workflow author control response status or headers on this origin. - * - Rate limiting: the execution `sync`/`async` buckets via preprocessing — - * deliberately NOT the shared `api-endpoint` bucket, and async runs debit - * the async bucket (unlike v1's known sync-bucket bug). + * - Rate limiting: keyed requests consume the shared request-rate bucket; + * execution preprocessing separately enforces the `sync`/`async` execution + * bucket, quota, billing, and concurrency checks. */ export const POST = withRouteHandler( async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -101,18 +110,19 @@ export const POST = withRouteHandler( let userId: string let isPublicApiAccess = false - let apiKeyType: 'personal' | 'workspace' | undefined - let apiKeyWorkspaceId: string | undefined + let apiKeyPrincipal: V2ApiKeyPrincipal | undefined - const auth = await authenticateV1Request(req) - if (auth.authenticated && auth.userId) { - userId = auth.userId - apiKeyType = auth.keyType - apiKeyWorkspaceId = auth.workspaceId + if (req.headers.has('x-api-key')) { + const admission = await admitV2Request( + req, + workflowOperations.execute, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + apiKeyPrincipal = admission.auth.principal + userId = admission.auth.rolloutUserId } else { - if (req.headers.has('x-api-key')) { - return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') - } const [wf] = await db .select({ isPublicApi: workflowTable.isPublicApi, @@ -139,8 +149,10 @@ export const POST = withRouteHandler( isPublicApiAccess = true } - const gate = await v2ApiGateError(userId) - if (gate) return gate + if (isPublicApiAccess) { + const gate = await v2ApiGateError(userId) + if (gate) return gate + } const ticket = tryAdmit() if (!ticket) { @@ -204,48 +216,56 @@ export const POST = withRouteHandler( requestedExecutionId = runIdHeader } - const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - // Mask authorization failures as 404 so cross-workspace existence never leaks. - if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { - return v2Error('NOT_FOUND', 'Workflow not found') - } - const workflowRecord = workflowAuthorization.workflow - - if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) { - return v2Error('NOT_FOUND', 'Workflow not found') - } - if (apiKeyType === 'personal' && workflowRecord.workspaceId) { - const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId) - if (!settings?.allowPersonalApiKeys) { - return v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace') + let result: ExecuteWorkflowServiceResult + if (apiKeyPrincipal) { + result = await executeWorkflowOperation.execute({ + principal: apiKeyPrincipal, + input: { + workflowId, + requestId, + input: body.input ?? {}, + executionId: requestedExecutionId, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + requestedTimeoutSeconds: body.executionTimeoutSeconds, + abortSignal: req.signal, + mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }, + request: req, + }) + } else { + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + // Mask authorization failures as 404 so cross-workspace existence never leaks. + if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { + return v2Error('NOT_FOUND', 'Workflow not found') } + result = await executeWorkflowService({ + workflowId, + userId, + input: body.input ?? {}, + triggerType: 'api', + requestId, + workflowRecord: workflowAuthorization.workflow, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + rateLimitCounter: 'sync', + abortSignal: req.signal, + mode: body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) } - const result = await executeWorkflowService({ - workflowId, - userId, - input: body.input ?? {}, - triggerType: 'api', - requestId, - executionId: requestedExecutionId, - useAuthenticatedUserAsActor: apiKeyType === 'personal', - workflowRecord, - includeFileBase64: body.includeFileBase64, - base64MaxBytes: body.base64MaxBytes, - selectedOutputs: body.selectedOutputs, - rateLimitCounter: body.async ? 'async' : 'sync', - requestedTimeoutSeconds: body.executionTimeoutSeconds, - abortSignal: req.signal, - mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', - requestHeaders: req.headers, - includeThinking: body.includeThinking, - includeToolCalls: body.includeToolCalls, - }) - if (!result.ok) { return serviceFailureResponse(result.failure) } @@ -285,6 +305,8 @@ export const POST = withRouteHandler( { headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } ) } catch (error) { + const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) + if (classified) return classified logger.error(`[${requestId}] v2 execute failed`, { workflowId, error: getErrorMessage(error, 'Unknown error'), @@ -293,5 +315,11 @@ export const POST = withRouteHandler( } finally { ticket.release() } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), } ) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts new file mode 100644 index 00000000000..25e0062241d --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) + +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { exportWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { GET } from '@/app/api/v2/workflows/[id]/export/route' + +describe('/api/v2/workflows/[id]/export route definition', () => { + it('uses canonical workflow authorization with concealment', () => { + expect(GET).toMatchObject({ + operation: workflowOperations.export, + useCase: exportWorkflow, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index 113b19fd3fa..d4011a4f64c 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -1,77 +1,30 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowExportAPI') +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { exportWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/workflows/[id]/export - * - * Exports a workflow as a portable JSON envelope that - * `POST /api/v2/workflows/import` accepts verbatim. Payload assembly and the - * sanitization guarantees are documented on the shared - * {@link buildWorkflowExportPayload}; this route authenticates and renders the - * v2 envelope. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ExportWorkflowContract, - rateLimitEndpoint: 'workflow-export', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - const { id } = input.params - - logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const payload = await buildWorkflowExportPayload(workflowData) - if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const folderPath = folderPathForId(folderIndex, workflowData.folderId) - - recordAudit({ - workspaceId: workflowData.workspaceId, - actorId: userId, - action: AuditAction.WORKFLOW_EXPORTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowData.id, - resourceName: workflowData.name, - description: `Exported workflow "${workflowData.name}" via the API`, - metadata: { - workspaceId: workflowData.workspaceId, + auth: v2ApiKeyAuth, + operation: workflowOperations.export, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: exportWorkflow, + present: ({ payload, folderPath }) => ({ + data: { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, folderPath, - blocksCount: Object.keys(payload.state.blocks).length, - edgesCount: payload.state.edges.length, - }, - request, - }) - - return v2Data( - { - ...payload, - workflow: { - id: payload.workflow.id, - name: payload.workflow.name, - description: payload.workflow.description, - workspaceId: payload.workflow.workspaceId, - folderPath, - }, }, - { rateLimit } - ) - }, + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts new file mode 100644 index 00000000000..34d98dd6f1e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + defineRoute: vi.fn((definition) => definition), + capture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { POST } from '@/app/api/v2/workflows/[id]/rollback/route' + +describe('/api/v2/workflows/[id]/rollback route definition', () => { + it('keeps an omitted rollback body valid and delegates version selection to the use case', async () => { + expect(v2RollbackWorkflowContract.body?.parse(undefined)).toEqual({}) + expect(POST).toMatchObject({ + operation: workflowOperations.activateVersion, + useCase: activateWorkflowVersion, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { optionalJsonBody: true }, + }) + expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( + expect.objectContaining({ + workflowId: 'workflow-1', + version: undefined, + transition: 'rollback', + }) + ) + + const invalidJsonResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'invalidJsonResponse' + )() + expect(invalidJsonResponse.status).toBe(400) + expect(await invalidJsonResponse.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + + const payloadTooLargeResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'payloadTooLargeResponse' + )() + expect(payloadTooLargeResponse.status).toBe(413) + expect(await payloadTooLargeResponse.json()).toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + }) + + it('presents the full declared rollback lifecycle response', () => { + const body = Reflect.get( + POST, + 'present' + )({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + deployedAt: new Date('2026-01-01T00:00:00.000Z'), + version: 1, + warnings: [], + activeDeployment: null, + latestDeploymentAttempt: null, + }) + expect(body.data.isDeployed).toBe(false) + expect(v2RollbackWorkflowContract.response.schema.parse(body)).toEqual(body) + }) + + it('keeps activation analytics on the v2 adapter', async () => { + await Reflect.get( + POST, + 'onSuccess' + )({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + result: { workflowId: 'workflow-1', workspaceId: 'workspace-1', version: 1 }, + }) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'deployment_version_activated', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1', version: 1 }, + { groups: { workspace: 'workspace-1' } } + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 09fc344c878..42c1ef2e58e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -1,104 +1,58 @@ -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody } from '@/lib/api/server' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' -import { performActivateVersion } from '@/lib/workflows/orchestration' -import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' - -const logger = createLogger('V2WorkflowRollbackAPI') +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2RollbackWorkflowContract, - rateLimitEndpoint: 'workflow-rollback', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const rawBody = await parseOptionalJsonBody(request) - if (!rawBody.success) { - return rawBody.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : v2Error('BAD_REQUEST', 'Request body must be valid JSON') - } - const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) - if (!body.success) return v2ValidationError(body.error) - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - const { workflow, workspaceId } = target - - if (!workflow.isDeployed) { - return v2Error('BAD_REQUEST', 'Workflow is not deployed') - } - - await assertWorkflowMutable(id) - - let targetVersion = body.data.version - if (targetVersion === undefined) { - const previous = await findPreviousDeploymentVersion(id) - if (!previous.ok) { - const message = - previous.reason === 'no_active_version' - ? 'Workflow has no active deployment to roll back from' - : 'No previous deployment version to roll back to' - return v2Error('BAD_REQUEST', message) - } - targetVersion = previous.version - } - - logger.info( - `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, - { userId } - ) - - const result = await performActivateVersion({ - workflowId: id, - version: targetVersion, - userId, - requestId, - }) - - if (!result.success) { - const code = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to roll back workflow') - } - - captureServerEvent( - userId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, - { groups: { workspace: workspaceId } } - ) - - return v2Data( - { - id, - isDeployed: true, - deployedAt: result.deployedAt?.toISOString() ?? null, - version: targetVersion, - warnings: result.warnings ?? [], - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof WorkflowLockedError) { - return v2Error('LOCKED', error.message) - } - throw error + auth: v2ApiKeyAuth, + operation: workflowOperations.activateVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { + optionalJsonBody: true, + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + version: body.version, + transition: 'rollback' as const, + requestId: generateRequestId(), + }), + useCase: activateWorkflowVersion, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + }, + }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'personal_api_key') { + throw new Error('Admin activation unexpectedly admitted a workspace API key') } + captureServerEvent( + principal.userId, + 'deployment_version_activated', + { + workflow_id: result.workflowId, + workspace_id: result.workspaceId, + version: result.version, + }, + { groups: { workspace: result.workspaceId } } + ) }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 33de9864022..0d828ae87f0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -1,348 +1,182 @@ /** * @vitest-environment node - * - * Public v2 workflow update/delete: the 404 mask on an access failure (the - * caller never names a workspace, so a 403 would confirm the workflow exists), - * the 423 a workflow mutation lock produces, and the orchestration failure - * codes rendered in the v2 error envelope. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetActiveWorkflowRecord, - mockPerformUpdateWorkflow, - mockPerformDeleteWorkflow, - mockAssertWorkflowMutable, - mockAssertFolderMutable, - mockLoadActiveFolderPathIndex, - WorkflowLockedErrorMock, - FolderLockedErrorMock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetActiveWorkflowRecord: vi.fn(), - mockPerformUpdateWorkflow: vi.fn(), - mockPerformDeleteWorkflow: vi.fn(), - mockAssertWorkflowMutable: vi.fn(), - mockAssertFolderMutable: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - WorkflowLockedErrorMock: class WorkflowLockedError extends Error { - status = 423 - }, - FolderLockedErrorMock: class FolderLockedError extends Error { - status = 423 - }, +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + readWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + deleteWorkflow: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/read-workflow', () => ({ + readWorkflow: { operation: { id: 'workflows.read' }, execute: mocks.readWorkflow }, })) - -vi.mock('@/lib/workflows/orchestration', () => ({ - performUpdateWorkflow: mockPerformUpdateWorkflow, - performDeleteWorkflow: mockPerformDeleteWorkflow, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: mockGetActiveWorkflowRecord, - assertWorkflowMutable: mockAssertWorkflowMutable, - assertFolderMutable: mockAssertFolderMutable, - WorkflowLockedError: WorkflowLockedErrorMock, - FolderLockedError: FolderLockedErrorMock, +vi.mock('@/lib/workflows/application/update-workflow', () => ({ + updateWorkflow: { operation: { id: 'workflows.update' }, execute: mocks.updateWorkflow }, })) - -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/workflows/application/delete-workflow', () => ({ + deleteWorkflow: { operation: { id: 'workflows.delete' }, execute: mocks.deleteWorkflow }, })) - -vi.mock('@/lib/workflows/input-format', () => ({ - extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]), +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) - -import { DELETE, PATCH } from '@/app/api/v2/workflows/[id]/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const WORKFLOW_RECORD = { - id: 'wf-1', - name: 'Support Agent', - description: 'Handles tickets', +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +import { + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, +} from '@/lib/core/application' +import { DELETE, GET, PATCH } from '@/app/api/v2/workflows/[id]/route' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' +const workflow = { + id: WORKFLOW_ID, + name: 'Daily digest', + description: null, + workspaceId: WORKSPACE_ID, folderId: null, - workspaceId: 'workspace-1', + variables: {}, isDeployed: true, - deployedAt: new Date('2024-01-03T00:00:00Z'), - runCount: 12, - lastRunAt: new Date('2024-01-04T00:00:00Z'), - locked: false, - forkSyncExcluded: false, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), -} - -const UPDATED = { - id: 'wf-1', - name: 'Support Agent v2', - description: 'Handles tickets', - workspaceId: 'workspace-1', - folderId: null, - sortOrder: 0, - locked: false, - forkSyncExcluded: false, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-05T00:00:00Z'), - archivedAt: null, + deployedAt: new Date('2026-08-03T00:00:00.000Z'), + runCount: 4, + lastRunAt: new Date('2026-08-04T00:00:00.000Z'), + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), } - -const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() - ) +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } -const callDelete = () => - DELETE( - new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { method: 'DELETE' }), - routeContext() - ) - -describe('PATCH /api/v2/workflows/[id]', () => { +describe('/api/v2/workflows/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockAssertWorkflowMutable.mockResolvedValue(undefined) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), - pathById: new Map([['fld-1', '/Locked']]), - idByPath: new Map([['/Locked', 'fld-1']]), + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ name: 'Support Agent v2' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({}) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(404) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the workflow does not exist or is archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(404) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('423s the denial when the workflow is locked rather than failing with a 500', async () => { - mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('423s when the destination folder is locked', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPatch({ folderPath: '/Locked' }) - expect(res.status).toBe(423) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('404s a path outside the workspace without ever reading its lock state', async () => { - const res = await callPatch({ folderPath: '/Elsewhere' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockAssertFolderMutable).not.toHaveBeenCalled() - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('resolves the canonical path against the workflow workspace before mutability', async () => { - await callPatch({ folderPath: '/Locked' }) - - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - 'workspace-1', - 'workflow', - expect.any(Object) - ) - expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') - }) - - it('skips the containment check on a rename that does not move the workflow', async () => { - await callPatch({ name: 'Support Agent v2' }) - expect(mockAssertFolderMutable).not.toHaveBeenCalled() - }) - - it('409s when the target name is taken in the destination folder', async () => { - mockPerformUpdateWorkflow.mockResolvedValue({ - success: false, - error: 'A workflow named "Support Agent v2" already exists in this folder', - errorCode: 'conflict', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('updates the workflow and carries the untouched deployment counters through', async () => { - const res = await callPatch({ name: 'Support Agent v2' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body).toEqual({ - data: { - id: 'wf-1', - name: 'Support Agent v2', - description: 'Handles tickets', - folderPath: '/', - workspaceId: 'workspace-1', + mocks.readWorkflow.mockResolvedValue({ + workflow, + workspaceId: WORKSPACE_ID, + folderPath: '/', + inputs: [], + }) + mocks.updateWorkflow.mockResolvedValue({ + workflow: { ...workflow, name: 'Weekly digest' }, + workspaceId: WORKSPACE_ID, + folderPath: '/', + deployment: { isDeployed: true, - deployedAt: '2024-01-03T00:00:00.000Z', - runCount: 12, - lastRunAt: '2024-01-04T00:00:00.000Z', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-05T00:00:00.000Z', + deployedAt: workflow.deployedAt, + runCount: 4, + lastRunAt: workflow.lastRunAt, }, }) - expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'wf-1', - userId: 'user-1', - workspaceId: 'workspace-1', - currentName: 'Support Agent', - currentFolderId: null, - name: 'Support Agent v2', - }) - ) - }) -}) - -describe('DELETE /api/v2/workflows/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockAssertWorkflowMutable.mockResolvedValue(undefined) - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) + mocks.deleteWorkflow.mockResolvedValue({ workflowId: WORKFLOW_ID }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + it('presents the authorized canonical workflow detail', async () => { + const request = new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`) + const response = await GET(request, routeContext) - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ + id: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, + folderPath: '/', + inputs: [], + }) + expect(mocks.readWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: WORKFLOW_ID }, + request, + }) }) - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() - }) + it('conceals typed insufficient authorization as workflow absence', async () => { + mocks.readWorkflow.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), + routeContext + ) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) }) - it('404s when the workflow does not exist or is already archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() - }) + it('preserves the personal-key-disabled 403 instead of concealing it', async () => { + mocks.readWorkflow.mockRejectedValue(new PersonalApiKeysDisabledError()) + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), + routeContext + ) - it('423s the denial when the workflow is locked rather than failing with a 500', async () => { - mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) - const res = await callDelete() - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') }) - it('400s when it is the last workflow in the workspace', async () => { - mockPerformDeleteWorkflow.mockResolvedValue({ - success: false, - error: 'Cannot delete the only workflow in the workspace', - errorCode: 'validation', + it('updates only through the shared semantic use case', async () => { + const request = new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Weekly digest' }), + }) + const response = await PATCH(request, routeContext) + + expect(response.status).toBe(200) + expect((await response.json()).data.name).toBe('Weekly digest') + expect(mocks.updateWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: WORKFLOW_ID, name: 'Weekly digest' }, + request, }) - const res = await callDelete() - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('only workflow') }) - it('archives the workflow and acknowledges the delete', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } }) - expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' }) - ) + it('deletes through the shared use case and preserves the response contract', async () => { + const request = new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`, { + method: 'DELETE', + }) + const response = await DELETE(request, routeContext) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: WORKFLOW_ID, deleted: true } }) + expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: WORKFLOW_ID }, + request, + }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index f2f77b0f8e7..43c40be983f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,192 +1,76 @@ import { - assertFolderMutable, - assertWorkflowMutable, - FolderLockedError, - getActiveWorkflowRecord, - WorkflowLockedError, -} from '@sim/platform-authz/workflow' -import { - type V2WorkflowDetail, - type V2WorkflowListItem, v2DeleteWorkflowContract, v2GetWorkflowContract, v2UpdateWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' -import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflow } from '@/lib/workflows/application/read-workflow' +import { updateWorkflow } from '@/lib/workflows/application/update-workflow' export const revalidate = 0 -interface RouteContext { - params: Promise<{ id: string }> -} - -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetWorkflowContract, - rateLimitEndpoint: 'workflow-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - - const snapshot = await loadWorkflowReadSnapshot(id) - const workflowData = snapshot.workflowRecord - if (!workflowData?.workspaceId || workflowData.archivedAt) { - return v2Error('NOT_FOUND', 'Workflow not found') - } - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) - - const detail: V2WorkflowDetail = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - folderPath: folderPathForId(folderIndex, workflowData.folderId), - workspaceId: workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - variables: (workflowData.variables as Record | null) ?? {}, + auth: v2ApiKeyAuth, + operation: workflowOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflow, + present: ({ workflow, workspaceId, folderPath, inputs }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath, + workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt?.toISOString() ?? null, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt?.toISOString() ?? null, + variables: (workflow.variables as Record | null) ?? {}, inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), - } - - return v2Data(detail, { rateLimit }) - }, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) -/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateWorkflowContract, - rateLimitEndpoint: 'workflow-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - const { name, description, folderPath } = input.body - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const resolution = - folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: workflowData.workspaceId, - resourceType: 'workflow', - path: folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const folderId = resolution?.folderId - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration( - result.errorCode, - result.error ?? 'Failed to update workflow' - ) - } - - const updated = result.workflow - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - /** - * Deployment and run counters are untouched by a metadata update, so they - * come from the record read above rather than a second query. - */ - const item: V2WorkflowListItem = { - id: updated.id, - name: updated.name, - description: updated.description, - folderPath: folderPathForId(folderIndex, updated.folderId), - workspaceId: updated.workspaceId ?? workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - } - - return v2Data(item, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - return v2Error('LOCKED', error.message) - } - - throw error - } - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.update, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ workflowId: params.id, ...body }), + useCase: updateWorkflow, + present: ({ workflow, workspaceId, folderPath, deployment }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath, + workspaceId, + isDeployed: deployment.isDeployed, + deployedAt: deployment.deployedAt?.toISOString() ?? null, + runCount: deployment.runCount, + lastRunAt: deployment.lastRunAt?.toISOString() ?? null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteWorkflowContract, - rateLimitEndpoint: 'workflow-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - await assertWorkflowMutable(id) - - const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - result.error ?? 'Failed to delete workflow' - ) - } - - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) - - throw error - } - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: deleteWorkflow, + present: ({ workflowId }) => ({ data: { id: workflowId, deleted: true as const } }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts index 8e0f76b136f..5418355124d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts @@ -1,61 +1,39 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - cancelWorkflowExecution, - WorkflowExecutionNotFoundError, -} from '@/lib/execution/cancel-workflow-execution' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' - -const logger = createLogger('V2CancelRunAPI') +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run' +import { workflowOperations } from '@/lib/workflows/application/operations' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { - const parsed = await parseRequest(v2CancelWorkflowRunContract, req, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: workflowId, runId } = parsed.data.params - - const access = await resolveV2WorkflowAccess(req, workflowId, 'write') - if (!access.ok) return access.response - - try { - logger.info('Cancel run requested', { workflowId, runId, userId: access.userId }) - - const result = await cancelWorkflowExecution({ - executionId: runId, - workflowId, - userId: access.userId, - workspaceId: access.workflow.workspaceId ?? undefined, - }) - - return v2Data({ - success: result.success, - runId: result.executionId, - redisAvailable: result.redisAvailable, - durablyRecorded: result.durablyRecorded, - locallyAborted: result.locallyAborted, - pausedCancelled: result.pausedCancelled, - reason: result.reason, - }) - } catch (error) { - if (error instanceof WorkflowExecutionNotFoundError) { - return v2Error('NOT_FOUND', error.message) - } - logger.error('Failed to cancel run', { - workflowId, - runId, - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const POST = defineV2JsonRoute({ + contract: v2CancelWorkflowRunContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.cancelRun, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id, runId: params.runId }), + useCase: cancelWorkflowRun, + present: (result) => ({ + data: { + success: result.success, + runId: result.executionId, + redisAvailable: result.redisAvailable, + durablyRecorded: result.durablyRecorded, + locallyAborted: result.locallyAborted, + pausedCancelled: result.pausedCancelled, + reason: result.reason, + }, + }), + onSuccess: ({ principal, result }) => { + if (!result.success || principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'workflow_execution_cancelled', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts index 82d3324c6bd..84430febc76 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts @@ -4,28 +4,44 @@ import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockHandleResumeExecution, mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ - mockHandleResumeExecution: vi.fn(), - mockResolveV2WorkflowAccess: vi.fn(), +const mocks = vi.hoisted(() => ({ + admit: vi.fn(), + resume: vi.fn(), })) -vi.mock('@/app/api/resume/resume-handler', () => ({ - handleResumeExecution: mockHandleResumeExecution, +vi.mock('@/lib/api/server/routes', () => { + class V2RouteInfrastructureError extends Error {} + return { + admitV2Request: mocks.admit, + V2RouteInfrastructureError, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { render: () => null }, + } +}) + +vi.mock('@/lib/workflows/application/resume-run', () => ({ + resumeWorkflowRun: { execute: mocks.resume }, })) -vi.mock('@/app/api/v2/workflows/lib/access', () => ({ - resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +vi.mock('@/lib/workflows/executor/resume-execution', () => ({ + ResumeWorkflowExecutionError: class ResumeWorkflowExecutionError extends Error {}, })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://test.sim.ai', + SITE_URL: 'https://test.sim.ai', })) import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { PersonalApiKeysDisabledError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workflowOperations } from '@/lib/workflows/application/operations' import { POST } from '@/app/api/v2/workflows/[id]/runs/[runId]/resume/route' const WORKFLOW_ID = 'workflow-1' const RUN_ID = 'run-1' +const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } function makeRequest(body: string) { return { @@ -44,17 +60,12 @@ function makeRequest(body: string) { describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { beforeEach(() => { vi.clearAllMocks() - mockResolveV2WorkflowAccess.mockResolvedValue({ - ok: true, - userId: 'user-1', - keyType: 'workspace', - workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' }, - }) + mocks.admit.mockResolvedValue({ success: true, auth: { principal } }) }) - it('authenticates before parsing the request body', async () => { - mockResolveV2WorkflowAccess.mockResolvedValueOnce({ - ok: false, + it('runs v2 admission before parsing the bounded request body', async () => { + mocks.admit.mockResolvedValueOnce({ + success: false, response: NextResponse.json( { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }, { status: 401 } @@ -68,23 +79,41 @@ describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { expect(await response.json()).toEqual({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' }, }) - expect(mockResolveV2WorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, 'write') - expect(mockHandleResumeExecution).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalledOnce() + expect(mocks.admit).toHaveBeenCalledWith( + request, + workflowOperations.resumeRun, + { kind: 'v2-api-key' }, + { kind: 'public-api' } + ) + expect(mocks.resume).not.toHaveBeenCalled() }) - it('resumes a pause context through the run-scoped v2 endpoint', async () => { - mockHandleResumeExecution.mockResolvedValueOnce( - NextResponse.json( - { - success: true, - async: true, - executionId: 'resume-execution-1', - message: 'Resume execution queued', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-1', - }, - { status: 202 } - ) + it('stops at request-rate admission without invoking resume execution controls', async () => { + mocks.admit.mockResolvedValueOnce({ + success: false, + response: NextResponse.json( + { error: { code: 'RATE_LIMITED', message: 'Rate limit exceeded' } }, + { status: 429, headers: { 'Retry-After': '7' } } + ), + }) + const { request, context } = makeRequest( + JSON.stringify({ contextId: 'context-1', input: { approved: true } }) ) + + const response = await POST(request, context) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('7') + expect(mocks.resume).not.toHaveBeenCalled() + }) + + it('resumes through the authorized run use case and returns a polling receipt', async () => { + mocks.resume.mockResolvedValueOnce({ + kind: 'async', + executionId: 'resume-execution-1', + jobId: 'resume-job-1', + }) const { request, context } = makeRequest( JSON.stringify({ contextId: 'context-1', input: { approved: true } }) ) @@ -101,57 +130,54 @@ describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { }, }) expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) - expect(mockHandleResumeExecution).toHaveBeenCalledWith({ + expect(mocks.resume).toHaveBeenCalledWith({ + principal, + input: { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + contextId: 'context-1', + resumeInput: { approved: true }, + }, request, - workflowId: WORKFLOW_ID, - executionId: RUN_ID, - contextId: 'context-1', - workspaceId: 'workspace-1', - userId: 'user-1', - resumeInput: { approved: true }, - isApiCaller: true, - pollingSurface: 'v2', - allowStreaming: false, }) }) - it('returns queued resumes as a v2 polling receipt', async () => { - mockHandleResumeExecution.mockResolvedValueOnce( - NextResponse.json({ - status: 'queued', - executionId: 'resume-execution-2', - queuePosition: 2, - message: 'Resume queued. It will run after current resumes finish.', - }) - ) + it('returns queued resumes as the declared v2 receipt', async () => { + mocks.resume.mockResolvedValueOnce({ + kind: 'queued', + executionId: 'resume-execution-2', + queuePosition: 2, + }) const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-2' })) const response = await POST(request, context) + const body = await response.json() expect(response.status).toBe(202) - expect(await response.json()).toEqual({ + expect(body).toEqual({ data: { runId: 'resume-execution-2', statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-2', queuePosition: 2, }, }) + expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) }) it('wraps synchronous resume results in the canonical v2 run shape', async () => { - mockHandleResumeExecution.mockResolvedValueOnce( - NextResponse.json({ - success: true, - status: 'completed', - executionId: 'resume-execution-3', - output: { approved: true }, - metadata: { - startTime: '2026-08-05T00:00:00.000Z', - endTime: '2026-08-05T00:00:01.000Z', - duration: 1000, - }, - }) - ) + mocks.resume.mockResolvedValueOnce({ + kind: 'sync', + success: true, + status: 'completed', + executionId: 'resume-execution-3', + output: { approved: true }, + error: undefined, + metadata: { + startTime: '2026-08-05T00:00:00.000Z', + endTime: '2026-08-05T00:00:01.000Z', + duration: 1000, + }, + }) const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-3' })) const response = await POST(request, context) @@ -172,4 +198,39 @@ describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { }) expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) }) + + it('conceals canonical parent-run/workflow mismatches as absence', async () => { + mocks.resume.mockRejectedValueOnce(new OrchestrationError('not_found', 'Run not found')) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-4' })) + + const response = await POST(request, context) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Run not found', + }) + }) + + it('preserves the personal-key-disabled authorization response as forbidden', async () => { + mocks.resume.mockRejectedValueOnce(new PersonalApiKeysDisabledError()) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-5' })) + + const response = await POST(request, context) + + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') + }) + + it('returns a safe error when the resume manager fails', async () => { + mocks.resume.mockRejectedValueOnce(new Error('resume database connection details')) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-6' })) + + const response = await POST(request, context) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index 0021620ccc6..cc0938d8232 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -1,17 +1,24 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import type { NextRequest } from 'next/server' import { V2_WORKFLOW_RUN_ID_HEADER, v2ResumeWorkflowContract, } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' +import { + admitV2Request, + V2RouteInfrastructureError, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { handleResumeExecution } from '@/app/api/resume/resume-handler' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' +import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution' import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' import { classifyExecutionError } from '@/executor/utils/errors' const logger = createLogger('V2WorkflowResumeAPI') @@ -34,110 +41,89 @@ const ERROR_CODE_BY_STATUS: Record = { const TERMINAL_RESUME_STATUSES = new Set(['completed', 'failed', 'paused', 'cancelled']) -function errorMessage(payload: Record): string { - return typeof payload.error === 'string' ? payload.error : 'Resume execution failed' -} - -/** - * POST /api/v2/workflows/[id]/runs/[runId]/resume resumes one pause context on - * the parent run. The new resume attempt gets its own run ID, which is the only - * polling handle exposed by v2. - */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { - const { id: workflowId } = await context.params - const access = await resolveV2WorkflowAccess(request, workflowId, 'write') - if (!access.ok) return access.response + const admission = await admitV2Request( + request, + workflowOperations.resumeRun, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { maxBodyBytes: 10 * 1024 * 1024, validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response - const { runId } = parsed.data.params + const { id: workflowId, runId } = parsed.data.params const { contextId, input } = parsed.data.body - if (!access.workflow.workspaceId) { - return v2Error('INTERNAL_ERROR', 'Workflow has no associated workspace') - } - try { - const response = await handleResumeExecution({ + const result = await resumeWorkflowRun.execute({ + principal: admission.auth.principal, + input: { + workflowId, + runId, + contextId, + resumeInput: input === undefined ? {} : input, + }, request, - workflowId, - executionId: runId, - contextId, - workspaceId: access.workflow.workspaceId, - userId: access.userId, - resumeInput: input === undefined ? {} : input, - isApiCaller: true, - pollingSurface: 'v2', - allowStreaming: false, }) - const payload: unknown = await response.json() - if (!isRecordLike(payload)) { - return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid response') - } - - if (!response.ok) { - return v2Error( - ERROR_CODE_BY_STATUS[response.status] ?? 'INTERNAL_ERROR', - errorMessage(payload), - { status: response.status } - ) - } - - if (typeof payload.executionId !== 'string') { - return v2Error('INTERNAL_ERROR', 'Resume execution did not return a run ID') - } - - const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${payload.executionId}` - const headers = { [V2_WORKFLOW_RUN_ID_HEADER]: payload.executionId } - - if (response.status === 202 || payload.status === 'queued') { + const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${result.executionId}` + const headers = { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } + if (result.kind === 'async' || result.kind === 'queued') { return v2Data( { - runId: payload.executionId, + runId: result.executionId, statusUrl, - ...(typeof payload.queuePosition === 'number' - ? { queuePosition: payload.queuePosition } - : {}), + ...(result.kind === 'queued' ? { queuePosition: result.queuePosition } : {}), }, { status: 202, headers } ) } - - if (typeof payload.status !== 'string' || !TERMINAL_RESUME_STATUSES.has(payload.status)) { + if (result.kind !== 'sync' || !TERMINAL_RESUME_STATUSES.has(result.status)) { return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid status') } - const metadata = isRecordLike(payload.metadata) ? payload.metadata : undefined return v2Data( { - runId: payload.executionId, + runId: result.executionId, workflowId, - status: payload.status as 'completed' | 'failed' | 'paused' | 'cancelled', - output: payload.output ?? null, + status: result.status as 'completed' | 'failed' | 'paused' | 'cancelled', + output: result.output ?? null, error: - typeof payload.error === 'string' - ? classifyExecutionError(new Error(payload.error)) + typeof result.error === 'string' + ? classifyExecutionError(new Error(result.error)) : null, - startedAt: - metadata && typeof metadata.startTime === 'string' ? metadata.startTime : undefined, - endedAt: metadata && typeof metadata.endTime === 'string' ? metadata.endTime : undefined, - durationMs: - metadata && typeof metadata.duration === 'number' ? metadata.duration : undefined, + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, }, { headers } ) } catch (error) { + const domainResponse = v2WorkflowErrorPolicies.concealRunAuthorization.render(error) + if (domainResponse) return domainResponse + if (error instanceof ResumeWorkflowExecutionError) { + if (!error.safeForPublicApi) throw error + return v2Error(ERROR_CODE_BY_STATUS[error.statusCode] ?? 'INTERNAL_ERROR', error.message, { + status: error.statusCode, + }) + } logger.error('Failed to resume workflow run', { workflowId, runId, error: getErrorMessage(error, 'Unknown error'), }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), } ) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index 20a8e07fc1a..56ba1b4ba1d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -1,46 +1,72 @@ /** * @vitest-environment node */ -import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthenticateV1Request, mockGetWorkflowExecutionStatus, mockCancel } = vi.hoisted( - () => ({ - mockAuthenticateV1Request: vi.fn(), - mockGetWorkflowExecutionStatus: vi.fn(), - mockCancel: vi.fn(), - }) -) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + MockV2ApiKeyUnauthenticatedError, + mocks: { + authenticate: vi.fn(), + cancel: vi.fn(), + capture: vi.fn(), + checkOperationRate: vi.fn(), + checkPreAuthRate: vi.fn(), + readRun: vi.fn(), + }, + } +}) -vi.mock('@/app/api/v1/auth', () => ({ - authenticateV1Request: mockAuthenticateV1Request, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreAuthRate + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/workflows/executor/execution-status', () => ({ - getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus, +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ - cancelWorkflowExecution: mockCancel, -})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/workflows/application/read-workflow-run', () => ({ + readWorkflowRun: { + operation: { id: 'workflows.runs.read' }, + execute: mocks.readRun, + }, })) -import { POST as cancelPost } from './cancel/route' -import { GET } from './route' +vi.mock('@/lib/workflows/application/cancel-run', () => ({ + cancelWorkflowRun: { + operation: { id: 'workflows.runs.cancel' }, + execute: mocks.cancel, + }, +})) -const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' +import { POST as cancelPost } from '@/app/api/v2/workflows/[id]/runs/[runId]/cancel/route' +import { GET } from '@/app/api/v2/workflows/[id]/runs/[runId]/route' -const workflowRecord = { - id: 'workflow-1', - userId: 'owner-1', +const principal = { + kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } function callStatus(query = '') { @@ -48,84 +74,106 @@ function callStatus(query = '') { 'GET', undefined, {}, - `http://localhost:3000/api/v2/workflows/workflow-1/runs/exec-1${query}` + `http://localhost:3000/api/v2/workflows/workflow-1/runs/run-1${query}` ) - return GET(req, { params: Promise.resolve({ id: 'workflow-1', runId: 'exec-1' }) }) + return GET(req, { params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }) }) +} + +const baseStatus = { + executionId: 'run-1', + workflowId: 'workflow-1', + status: 'failed' as const, + trigger: 'api', + level: 'error', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: '2026-07-31T00:00:05.000Z', + totalDurationMs: 5000, + paused: null, + cost: { total: 0.02 }, + error: 'Send Email: Invalid credentials', + finalOutput: null, + blockOutputs: null, } -describe('v2 runs status + cancel', () => { +const successfulCancellation = { + success: true, + executionId: 'run-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', +} + +describe('v2 run detail and cancel adapters', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', + mocks.authenticate.mockResolvedValue(auth) + mocks.checkPreAuthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-05T01:00:00Z'), }) - mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + mocks.readRun.mockResolvedValue(baseStatus) + mocks.cancel.mockResolvedValue(successfulCancellation) }) it('returns the run resource with a structured error', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue({ - executionId: 'exec-1', + const response = await callStatus() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toMatchObject({ + runId: 'run-1', workflowId: 'workflow-1', status: 'failed', - trigger: 'api', - level: 'error', - startedAt: '2026-07-31T00:00:00.000Z', - endedAt: '2026-07-31T00:00:05.000Z', - totalDurationMs: 5000, - paused: null, - cost: { total: 0.02 }, - error: 'Send Email: Invalid credentials', - finalOutput: null, - blockOutputs: null, + durationMs: 5000, + error: { + code: 'EXECUTION_FAILED', + message: 'Send Email: Invalid credentials', + }, + }) + expect(mocks.readRun).toHaveBeenCalledWith({ + principal, + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: false, + selectedOutputs: [], + }, + request: expect.anything(), }) - - const res = await callStatus() - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data.status).toBe('failed') - expect(body.data.runId).toBe('exec-1') - expect(body.data.error.code).toBe('EXECUTION_FAILED') - expect(body.data.error.message).toBe('Send Email: Invalid credentials') - expect(body.data.durationMs).toBe(5000) }) - it('returns the queued run resource before the log row exists', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue({ - executionId: 'exec-1', - workflowId: 'workflow-1', + it('returns the queued run resource before a durable log exists', async () => { + mocks.readRun.mockResolvedValueOnce({ + ...baseStatus, status: 'queued', - trigger: 'api', level: 'info', - startedAt: '2026-07-31T00:00:00.000Z', endedAt: null, totalDurationMs: null, - paused: null, cost: null, error: null, - finalOutput: null, - blockOutputs: null, }) - const res = await callStatus() - - expect(res.status).toBe(200) - expect((await res.json()).data.status).toBe('queued') + expect((await (await callStatus()).json()).data.status).toBe('queued') }) - it('returns the resume context for a paused execution', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue({ - executionId: 'exec-1', - workflowId: 'workflow-1', + it('returns the public pause context without its internal paused-execution ID', async () => { + mocks.readRun.mockResolvedValueOnce({ + ...baseStatus, status: 'paused', - trigger: 'api', level: 'info', - startedAt: '2026-07-31T00:00:00.000Z', endedAt: null, totalDurationMs: null, + error: null, paused: { contextId: 'context-1', pausedAt: '2026-07-31T00:00:01.000Z', @@ -137,10 +185,6 @@ describe('v2 runs status + cancel', () => { pausePointCount: 1, resumedCount: 0, }, - cost: null, - error: null, - finalOutput: null, - blockOutputs: null, }) const body = await (await callStatus()).json() @@ -149,61 +193,111 @@ describe('v2 runs status + cancel', () => { expect(body.data.paused).not.toHaveProperty('pausedExecutionId') }) - it('404s when neither a log row nor a matching job exists', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue(null) + it('conceals canonical run authorization failures as absence', async () => { + mocks.readRun.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) - const res = await callStatus() + const response = await callStatus() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Run not found', + }) }) - it('masks cross-workspace access as 404', async () => { - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', - keyType: 'workspace', - workspaceId: 'other-workspace', - }) + it('rejects missing API keys before reading the run', async () => { + mocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) - const res = await callStatus() + const response = await callStatus() - expect(res.status).toBe(404) - expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled() + expect(response.status).toBe(401) + expect(mocks.readRun).not.toHaveBeenCalled() }) - it('cancels through the shared lib and returns the tightened result', async () => { - mockCancel.mockResolvedValue({ + it('keeps cancel on its semantic application operation', async () => { + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + }) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ success: true, - executionId: 'exec-1', - redisAvailable: true, - durablyRecorded: true, - locallyAborted: false, - pausedCancelled: false, + runId: 'run-1', reason: 'recorded', }) + expect(mocks.cancel).toHaveBeenCalledWith({ + principal, + input: { workflowId: 'workflow-1', runId: 'run-1' }, + request: expect.anything(), + }) + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.checkOperationRate).toHaveBeenCalledWith( + 'v2:workflows.runs.cancel:api-key:key-1', + expect.anything() + ) + expect(mocks.capture).not.toHaveBeenCalled() + }) - const req = createMockRequest('POST', undefined, {}) - const res = await cancelPost(req, { - params: Promise.resolve({ id: 'workflow-1', runId: 'exec-1' }), + it('keeps cancellation request-rate admission separate from run control', async () => { + mocks.checkOperationRate + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-05T01:00:00Z'), + retryAfterMs: 5_000, + }) + .mockResolvedValueOnce({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), }) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data).toMatchObject({ success: true, runId: 'exec-1', reason: 'recorded' }) - expect(mockCancel).toHaveBeenCalledWith({ - executionId: 'exec-1', - workflowId: 'workflow-1', - userId: 'key-user-1', - workspaceId: 'workspace-1', + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('5') + expect(mocks.cancel).not.toHaveBeenCalled() + }) + + it('conceals cancellation authorization failures using canonical run policy', async () => { + mocks.cancel.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + }) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Run not found', }) + expect(mocks.capture).not.toHaveBeenCalled() }) - it('401s without an API key (no session/anonymous path on runs)', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + it('projects cancellation analytics only after a successful personal-key result', async () => { + mocks.authenticate.mockResolvedValueOnce({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + rolloutUserId: 'key-user', + rateLimitSubjectIds: ['api-key:personal-key', 'user:key-user'], + keyType: 'personal', + }) - const res = await callStatus() + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + }) - expect(res.status).toBe(401) + expect(response.status).toBe(200) + expect(mocks.capture).toHaveBeenCalledOnce() + expect(mocks.capture).toHaveBeenCalledWith( + 'key-user', + 'workflow_execution_cancelled', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, + { groups: { workspace: 'workspace-1' } } + ) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts index 65793c6307d..f511c4391a8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts @@ -1,23 +1,13 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetWorkflowRunContract, v2WorkflowRunStatusSchema, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, - FunctionalOutputsUnavailableError, -} from '@/lib/logs/execution/functional-outputs' -import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' import { classifyExecutionError } from '@/executor/utils/errors' -const logger = createLogger('V2WorkflowRunStatusAPI') - export const dynamic = 'force-dynamic' /** @@ -26,54 +16,33 @@ export const dynamic = 'force-dynamic' * queue is consulted (deterministic job id) so a freshly-queued run reports * `queued` instead of 404. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { - const parsed = await parseRequest(v2GetWorkflowRunContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: workflowId, runId } = parsed.data.params - const { includeOutput, selectedOutputs } = parsed.data.query - - const access = await resolveV2WorkflowAccess(request, workflowId, 'read') - if (!access.ok) return access.response - - try { - const status = await getWorkflowExecutionStatus({ - workflowId, - executionId: runId, - includeOutput, - selectedOutputs, - }) - - if (!status) { - return v2Error('NOT_FOUND', 'Run not found') - } - - return v2Data({ - runId: status.executionId, - workflowId: status.workflowId, - status: status.status, - trigger: status.trigger ?? null, - startedAt: status.startedAt, - endedAt: status.endedAt, - durationMs: status.totalDurationMs, - paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, - cost: status.cost, - error: status.error ? classifyExecutionError(new Error(status.error)) : null, - output: status.finalOutput, - blockOutputs: status.blockOutputs, - }) - } catch (error) { - if (error instanceof FunctionalOutputsUnavailableError) { - return v2Error('CONFLICT', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) - } - logger.error('Failed to fetch run status', { - workflowId, - runId, - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowRunContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.readRun, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + mapInput: ({ params, query }) => ({ + workflowId: params.id, + runId: params.runId, + includeOutput: query.includeOutput, + selectedOutputs: query.selectedOutputs, + }), + useCase: readWorkflowRun, + present: (status) => ({ + data: { + runId: status.executionId, + workflowId: status.workflowId, + status: status.status, + trigger: status.trigger ?? null, + startedAt: status.startedAt, + endedAt: status.endedAt, + durationMs: status.totalDurationMs, + paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, + cost: status.cost, + error: status.error ? classifyExecutionError(new Error(status.error)) : null, + output: status.finalOutput, + blockOutputs: status.blockOutputs, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index 102088826c4..f43dfac8df8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -1,20 +1,58 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ - mockResolveV2WorkflowAccess: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreAuthRate: vi.fn(), + checkOperationRate: vi.fn(), + listRuns: vi.fn(), })) -vi.mock('@/app/api/v2/workflows/lib/access', () => ({ - resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreAuthRate + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({ + listWorkflowRuns: { + operation: { id: 'workflows.runs.list' }, + execute: mocks.listRuns, + }, +})) + +import { + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, +} from '@/lib/core/application' import { GET } from '@/app/api/v2/workflows/[id]/runs/route' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const routeContext = () => ({ params: Promise.resolve({ id: 'workflow-1' }) }) const callGet = (query = '') => GET( @@ -50,96 +88,122 @@ const EXECUTIONS = [ describe('GET /api/v2/workflows/[id]/runs', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockResolveV2WorkflowAccess.mockResolvedValue({ - ok: true, - userId: 'user-1', - keyType: 'workspace', - workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + mocks.authenticate.mockResolvedValue(auth) + mocks.checkPreAuthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + mocks.listRuns.mockResolvedValue({ + data: EXECUTIONS, + nextCursor: null, + workflowId: 'workflow-1', + order: 'desc', }) - dbChainMockFns.limit.mockResolvedValue(EXECUTIONS) }) - it('lists lightweight run resources in the cursor envelope', async () => { + it('lists lightweight run resources through the semantic operation', async () => { const response = await callGet() - const body = await response.json() expect(response.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - runId: 'execution-2', - workflowId: 'workflow-1', - status: 'paused', - trigger: 'api', - startedAt: '2026-08-05T00:02:00.000Z', - endedAt: null, - durationMs: null, - cost: { total: 0.02 }, - }, - { - runId: 'execution-1', - workflowId: 'workflow-1', - status: 'completed', - trigger: 'schedule', - startedAt: '2026-08-05T00:01:00.000Z', - endedAt: '2026-08-05T00:01:03.000Z', - durationMs: 3000, - cost: null, - }, - ]) + expect(await response.json()).toEqual({ + data: [ + { + runId: 'execution-2', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + startedAt: '2026-08-05T00:02:00.000Z', + endedAt: null, + durationMs: null, + cost: { total: 0.02 }, + }, + { + runId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'schedule', + startedAt: '2026-08-05T00:01:00.000Z', + endedAt: '2026-08-05T00:01:03.000Z', + durationMs: 3000, + cost: null, + }, + ], + nextCursor: null, + }) + expect(mocks.listRuns).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workflowId: 'workflow-1', limit: 50, order: 'desc' }), + request: expect.anything(), + }) }) - it('returns an opaque cursor when another row exists', async () => { - dbChainMockFns.limit.mockResolvedValue([...EXECUTIONS, { ...EXECUTIONS[1], rowId: 'row-0' }]) + it('encodes the repository cursor using the requested order', async () => { + mocks.listRuns.mockResolvedValueOnce({ + data: EXECUTIONS, + nextCursor: { startedAt: EXECUTIONS[1].startedAt, rowId: 'row-1' }, + workflowId: 'workflow-1', + order: 'asc', + }) - const body = await (await callGet('?limit=2')).json() + const body = await (await callGet('?order=asc')).json() - expect(body.data).toHaveLength(2) - expect(body.nextCursor).toEqual(expect.any(String)) expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ - sort: 'startedAt:desc', + sort: 'startedAt:asc', keys: ['2026-08-05T00:01:00.000Z', 'row-1'], }) }) - it('rejects an invalid cursor', async () => { + it('rejects an invalid cursor after API-key admission without calling the use case', async () => { const response = await callGet('?cursor=not-a-cursor') expect(response.status).toBe(400) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() - }) - - it('rejects a cursor minted under a different order', async () => { - const cursor = Buffer.from( - JSON.stringify({ - sort: 'startedAt:desc', - keys: ['2026-08-05T00:01:00.000Z', 'row-1'], - }) - ).toString('base64') - - const response = await callGet(`?order=asc&cursor=${encodeURIComponent(cursor)}`) - - expect(response.status).toBe(400) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.listRuns).not.toHaveBeenCalled() }) it('rejects queued as a durable-history filter', async () => { const response = await callGet('?status=queued') expect(response.status).toBe(400) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect(mocks.listRuns).not.toHaveBeenCalled() }) - it('authorizes the workflow before validating filters', async () => { - mockResolveV2WorkflowAccess.mockResolvedValue({ - ok: false, - response: new Response(null, { status: 404 }), - }) + it('conceals workflow authorization failures as absence', async () => { + mocks.listRuns.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) - const response = await callGet('?limit=0') + const response = await callGet() expect(response.status).toBe(404) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workflow not found', + }) + }) + + it('preserves the personal API-key workspace-policy denial', async () => { + mocks.listRuns.mockRejectedValueOnce(new PersonalApiKeysDisabledError()) + + const response = await callGet() + + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') + }) + + it('returns a safe error when run storage fails', async () => { + mocks.listRuns.mockRejectedValueOnce(new Error('database connection details')) + + const response = await callGet() + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts index 5e9a2c5cec8..6893f79f822 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -1,46 +1,33 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2WorkflowRunListItem, v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries' -import { - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, - v2CursorList, - v2CursorSortError, - v2Error, - v2ValidationError, -} from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' - -const logger = createLogger('V2WorkflowRunsAPI') +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** List the durable runs belonging to one workflow. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const { id: workflowId } = await context.params - const access = await resolveV2WorkflowAccess(request, workflowId, 'read') - if (!access.ok) return access.response - - const parsed = await parseRequest(v2ListWorkflowRunsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { status, trigger, startDate, endDate, limit, cursor, order } = parsed.data.query +export const GET = defineV2JsonRoute({ + contract: v2ListWorkflowRunsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.listRuns, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, query }) => { + const { status, trigger, startDate, endDate, limit, cursor, order } = query const sort = cursorSortKey('startedAt', order) const decodedCursor = decodeSortedCursor(cursor, sort) - if (decodedCursor.status === 'invalid') return v2CursorSortError() + if (decodedCursor.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null if ( @@ -50,49 +37,42 @@ export const GET = withRouteHandler( Number.isNaN(cursorDate.getTime()) || typeof cursorRowId !== 'string') ) { - return v2CursorSortError() + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } - try { - const result = await listWorkflowExecutions({ - workflowId, - status, - trigger, - startDate: startDate ? new Date(startDate) : undefined, - endDate: endDate ? new Date(endDate) : undefined, - limit, - cursor: - decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' - ? { startedAt: cursorDate, rowId: cursorRowId } - : undefined, - order, - }) - - const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ - runId: row.executionId, - workflowId: row.workflowId ?? workflowId, - status: v2WorkflowRunListStatusValueSchema.parse(row.status), - trigger: row.trigger, - startedAt: row.startedAt.toISOString(), - endedAt: row.endedAt?.toISOString() ?? null, - durationMs: row.durationMs, - cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, - })) - - const nextCursor = result.nextCursor - ? encodeSortedCursor(sort, [ - result.nextCursor.startedAt.toISOString(), - result.nextCursor.rowId, - ]) - : null - - return v2CursorList(data, nextCursor) - } catch (error) { - logger.error('Failed to list workflow runs', { - workflowId, - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + return { + workflowId: params.id, + status, + trigger, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + limit, + cursor: + decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' + ? { startedAt: cursorDate, rowId: cursorRowId } + : undefined, + order, } - } -) + }, + useCase: listWorkflowRuns, + present: (result) => { + const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ + runId: row.executionId, + workflowId: row.workflowId ?? result.workflowId, + status: v2WorkflowRunListStatusValueSchema.parse(row.status), + trigger: row.trigger, + startedAt: row.startedAt.toISOString(), + endedAt: row.endedAt?.toISOString() ?? null, + durationMs: row.durationMs, + cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, + })) + const sort = cursorSortKey('startedAt', result.order) + const nextCursor = result.nextCursor + ? encodeSortedCursor(sort, [ + result.nextCursor.startedAt.toISOString(), + result.nextCursor.rowId, + ]) + : null + return { data, nextCursor } + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts index 72e3811cb6e..9eb2b7b2aa5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -1,154 +1,90 @@ /** * @vitest-environment node - * - * Public v2 deployment-version detail: the 404 mask on an access failure, the - * coerced numeric version param, and the pinned workflow state it serves. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetActiveWorkflowRecord, - mockGetWorkflowDeploymentVersion, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetActiveWorkflowRecord: vi.fn(), - mockGetWorkflowDeploymentVersion: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + readVersion: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/read-workflow-version', () => ({ + readWorkflowVersion: { + operation: { id: 'workflows.versions.read' }, + execute: mocks.readVersion, + }, })) - -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/workflows/persistence/utils', () => ({ - getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } - -const DEPLOYED_STATE = { blocks: {}, edges: [], loops: {}, parallels: {} } - -const VERSION_ROW = { - id: 'dv-3', - version: 3, - name: 'Escalation branch', - description: null, - isActive: true, - createdAt: new Date('2024-01-03T00:00:00Z'), - state: DEPLOYED_STATE, +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } -const routeContext = (version = '3') => ({ params: Promise.resolve({ id: 'wf-1', version }) }) -const callGet = (version = '3') => - GET( - new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions/${version}`), - routeContext(version) - ) - describe('GET /api/v2/workflows/[id]/versions/[version]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockGetWorkflowDeploymentVersion.mockResolvedValue(VERSION_ROW) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('400s on a non-numeric version', async () => { - const res = await callGet('latest') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the workflow does not exist or is archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('404s when the version does not exist on this workflow', async () => { - mockGetWorkflowDeploymentVersion.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.message).toBe('Deployment version not found') - }) - - it('returns the version with the workflow state it pins', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body).toEqual({ - data: { - id: 'dv-3', - version: 3, - name: 'Escalation branch', + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), + }) + mocks.readVersion.mockResolvedValue({ + version: { + id: 'version-2', + version: 2, + name: 'Production', description: null, isActive: true, - createdAt: '2024-01-03T00:00:00.000Z', - state: DEPLOYED_STATE, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + state: { blocks: {}, edges: [], loops: {}, parallels: {}, version: '1.0' }, }, }) - expect(mockGetWorkflowDeploymentVersion).toHaveBeenCalledWith('wf-1', 3) + }) + + it('reads the requested version through the semantic use case', async () => { + const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions/2') + const response = await GET(request, { + params: Promise.resolve({ id: 'workflow-1', version: '2' }), + }) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ id: 'version-2', version: 2 }) + expect(mocks.readVersion).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: 'workflow-1', version: 2 }, + request, + }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index f1fe2633758..351e2de49fa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -1,41 +1,30 @@ -import { - type V2WorkflowVersionDetail, - v2GetWorkflowVersionContract, -} from '@/lib/api/contracts/v2/workflows' -import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' +import type { V2WorkflowVersionDetail } from '@/lib/api/contracts/v2/workflows' +import { v2GetWorkflowVersionContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version - * and the workflow state it pins. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetWorkflowVersionContract, - rateLimitEndpoint: 'workflow-version-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id, version } = input.params - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - const row = await getWorkflowDeploymentVersion(id, version) - if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') - - const detail: V2WorkflowVersionDetail = { - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - state: row.state as V2WorkflowVersionDetail['state'], - } - - return v2Data(detail, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.readVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), + useCase: readWorkflowVersion, + present: ({ version }) => ({ + data: { + id: version.id, + version: version.version, + name: version.name, + description: version.description, + isActive: version.isActive, + createdAt: version.createdAt.toISOString(), + state: version.state as V2WorkflowVersionDetail['state'], + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts index 53025c2d07d..aca18ee8ede 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -1,221 +1,119 @@ /** * @vitest-environment node - * - * Public v2 deployment-version listing: the 404 mask on an access failure, the - * public projection (no raw `createdBy` user id), and the version-keyed cursor. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetActiveWorkflowRecord, - mockListWorkflowVersions, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetActiveWorkflowRecord: vi.fn(), - mockListWorkflowVersions: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + listVersions: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/list-workflow-versions', () => ({ + listWorkflowVersions: { + operation: { id: 'workflows.versions.list' }, + execute: mocks.listVersions, + }, })) - -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: mockGetActiveWorkflowRecord, -})) - -vi.mock('@/lib/workflows/persistence/utils', () => ({ - listWorkflowVersions: mockListWorkflowVersions, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) import { GET } from '@/app/api/v2/workflows/[id]/versions/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:workspace-key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } - -function buildVersion(version: number, overrides: Record = {}) { - return { - id: `dv-${version}`, - version, - name: null, - description: null, - isActive: false, - createdAt: new Date(`2024-01-0${version}T00:00:00Z`), - createdBy: 'user-9', - deployedByName: 'Ada Lovelace', - latestOperationStatus: null, - ...overrides, - } -} - -const ALL_VERSIONS = [ - buildVersion(3, { isActive: true, name: 'Escalation branch', latestOperationStatus: 'active' }), - buildVersion(2), - buildVersion(1), -] - -const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) -const callGet = (query = '') => - GET( - new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions${query}`), - routeContext() - ) +const context = { params: Promise.resolve({ id: 'workflow-1' }) } describe('GET /api/v2/workflows/[id]/versions', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - /** - * Stands in for the keyset query the helper now runs, so the route's - * has-more probe and cursor round-trip are exercised against realistic - * `limit`/`afterVersion` behavior rather than a fixed array. - */ - mockListWorkflowVersions.mockImplementation( - async (_workflowId: string, options: { limit?: number; afterVersion?: number } = {}) => { - let versions = ALL_VERSIONS - if (options.afterVersion !== undefined) { - versions = versions.filter((row) => row.version < options.afterVersion!) - } - if (options.limit !== undefined) versions = versions.slice(0, options.limit) - return { versions } - } - ) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('400s on an out-of-range limit', async () => { - const res = await callGet('?limit=0') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the workflow does not exist or is archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('returns the public version shape newest-first, without the raw creator id', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toHaveLength(3) - expect(body.data[0]).toEqual({ - id: 'dv-3', - version: 3, - name: 'Escalation branch', - description: null, - isActive: true, - createdAt: '2024-01-03T00:00:00.000Z', - deployedBy: 'Ada Lovelace', - latestOperationStatus: 'active', + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - expect(body.data[0]).not.toHaveProperty('createdBy') - // Paging is pushed into the helper — the route never reads the full set. - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { - limit: 51, - afterVersion: undefined, + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - }) - - it('bounds the read to one page plus the has-more probe', async () => { - await callGet('?limit=2') - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { - limit: 3, - afterVersion: undefined, + mocks.listVersions.mockResolvedValue({ + versions: [ + { + id: 'version-2', + version: 2, + name: 'Production', + description: null, + isActive: true, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + deployedByName: 'Ada', + latestOperationStatus: 'active', + }, + ], + hasMore: false, }) }) - it('pushes the cursor down to the helper as a keyset bound', async () => { - const cursor = Buffer.from(JSON.stringify({ version: 3 })).toString('base64') - await callGet(`?limit=2&cursor=${encodeURIComponent(cursor)}`) - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { limit: 3, afterVersion: 3 }) - }) - - it('400s a structurally invalid cursor instead of silently truncating the list', async () => { - // Decodes to valid JSON with no numeric `version` — the shape that would - // otherwise filter every row out and report a clean end-of-list. - const bogus = Buffer.from(JSON.stringify({ offset: 2 })).toString('base64') - const res = await callGet(`?cursor=${encodeURIComponent(bogus)}`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('400s a cursor that is not decodable at all', async () => { - const res = await callGet('?cursor=not-a-cursor') - expect(res.status).toBe(400) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() + it('lists versions through canonical workflow authorization', async () => { + const request = new NextRequest( + 'http://localhost/api/v2/workflows/workflow-1/versions?limit=10' + ) + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'version-2', + version: 2, + name: 'Production', + description: null, + isActive: true, + createdAt: '2026-08-01T00:00:00.000Z', + deployedBy: 'Ada', + latestOperationStatus: 'active', + }, + ], + nextCursor: null, + }) + expect(mocks.listVersions).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: 'workflow-1', limit: 10, afterVersion: undefined }, + request, + }) }) - it('pages with a version-keyed cursor', async () => { - const first = await callGet('?limit=2') - const firstBody = await first.json() - - expect(firstBody.data.map((v: { version: number }) => v.version)).toEqual([3, 2]) - expect(firstBody.nextCursor).toEqual(expect.any(String)) - - const second = await callGet(`?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`) - const secondBody = await second.json() + it('rejects malformed cursors before the use case', async () => { + const response = await GET( + new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions?cursor=bad'), + context + ) - expect(secondBody.data.map((v: { version: number }) => v.version)).toEqual([1]) - expect(secondBody.nextCursor).toBeNull() + expect(response.status).toBe(400) + expect(mocks.listVersions).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index 1a75e45cd16..1fbb169fe33 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,71 +1,55 @@ -import { - type V2WorkflowVersion, - v2ListWorkflowVersionsContract, -} from '@/lib/api/contracts/v2/workflows' -import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { decodeCursor, encodeCursor, v2CursorList, v2Error } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' +import type { V2WorkflowVersion } from '@/lib/api/contracts/v2/workflows' +import { v2ListWorkflowVersionsContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Keyset cursor over the dense, strictly-descending version number. */ interface WorkflowVersionCursor { version: number } -/** - * GET /api/v2/workflows/[id]/versions — List a workflow's deployment versions, - * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback` - * accepts, so a caller no longer has to guess a version number. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkflowVersionsContract, - rateLimitEndpoint: 'workflow-versions', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { limit, cursor } = input.query - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - /** - * A cursor that decodes to anything other than a version number is - * rejected rather than ignored: comparing every row against a missing - * `version` yields an empty page with `nextCursor: null`, which reads to - * the caller as a clean end-of-list while versions are still pending. - */ - const after = cursor ? decodeCursor(cursor) : null - if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { - return v2Error('BAD_REQUEST', 'Invalid cursor') + auth: v2ApiKeyAuth, + operation: workflowOperations.listVersions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, query }) => { + const after = query.cursor ? decodeCursor(query.cursor) : null + if (query.cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + throw new OrchestrationError('validation', 'Invalid cursor') } - - // One extra row is the has-more probe, matching the other v2 cursor lists. - const { versions: rows } = await listWorkflowVersions(id, { - limit: limit + 1, + return { + workflowId: params.id, + limit: query.limit, afterVersion: after?.version, - }) - - const hasMore = rows.length > limit - const page = rows.slice(0, limit) - - const data: V2WorkflowVersion[] = page.map((row) => ({ - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - deployedBy: row.deployedByName, - // The shared helper widens the operation-status pg enum to `string`. + } + }, + useCase: listWorkflowVersions, + present: ({ versions, hasMore }) => { + const data: V2WorkflowVersion[] = versions.map((version) => ({ + id: version.id, + version: version.version, + name: version.name, + description: version.description, + isActive: version.isActive, + createdAt: version.createdAt.toISOString(), + deployedBy: version.deployedByName, latestOperationStatus: - row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + version.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], })) - - const nextCursor = - hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null - - return v2CursorList(data, nextCursor, { rateLimit }) + return { + data, + nextCursor: + hasMore && data.length > 0 + ? encodeCursor({ version: data[data.length - 1].version }) + : null, + } }, }) diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts index 696286333ed..9d37f033e5a 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.test.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -1,216 +1,79 @@ /** * @vitest-environment node */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockLoadActiveFolderPathIndex, - mockListActiveFolderRows, - mockCreateFolderAtPath, - mockRelocateFolderByPath, - mockDeleteFolderByPath, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockListActiveFolderRows: vi.fn(), - mockCreateFolderAtPath: vi.fn(), - mockRelocateFolderByPath: vi.fn(), - mockDeleteFolderByPath: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) +const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, - listActiveFolderRows: mockListActiveFolderRows, -})) - -vi.mock('@/lib/folders/orchestration', () => ({ - createFolderAtPath: mockCreateFolderAtPath, - relocateFolderByPath: mockRelocateFolderByPath, - deleteFolderByPath: mockDeleteFolderByPath, +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, })) +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + createWorkflowFolder, + deleteWorkflowFolder, + listWorkflowFolders, + relocateWorkflowFolder, +} from '@/lib/workflows/application/workflow-folders' import { DELETE, GET, PATCH, POST } from '@/app/api/v2/workflows/folders/route' -const WORKSPACE_ID = 'workspace-1' -const FOLDER_ID = 'internal-folder-id' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const folder = { - id: FOLDER_ID, - resourceType: 'workflow' as const, - name: 'Reports', - userId: 'user-1', - workspaceId: WORKSPACE_ID, - parentId: null, - sortOrder: 0, - locked: false, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - deletedAt: null, -} - -function pathIndex(path = '/Reports') { - return { - rowById: new Map([[FOLDER_ID, folder]]), - pathById: new Map([[FOLDER_ID, path]]), - idByPath: new Map([[path, FOLDER_ID]]), - } -} - -function request(method: string, path: string, body?: Record) { - return new NextRequest(`http://localhost:3000${path}`, { - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, - }) -} - -describe('/api/v2/workflows/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex()) - mockListActiveFolderRows.mockResolvedValue([folder]) - mockCreateFolderAtPath.mockResolvedValue({ - success: true, - folder, - path: '/Reports', +describe('/api/v2/workflows/folders route definitions', () => { + it('binds every method to the matching semantic operation and authorized use case', () => { + expect(GET).toMatchObject({ + operation: workflowOperations.listFolders, + useCase: listWorkflowFolders, + errorPolicy: v2WorkflowErrorPolicies.default, }) - mockRelocateFolderByPath.mockResolvedValue({ - success: true, - folder, - path: '/Reports', + expect(POST).toMatchObject({ + operation: workflowOperations.createFolder, + useCase: createWorkflowFolder, + errorPolicy: v2WorkflowErrorPolicies.default, }) - mockDeleteFolderByPath.mockResolvedValue({ - success: true, - path: '/Reports', - deletedItems: { folders: 1, workflows: 2 }, + expect(PATCH).toMatchObject({ + operation: workflowOperations.relocateFolder, + useCase: relocateWorkflowFolder, + errorPolicy: v2WorkflowErrorPolicies.default, }) - }) - - it('lists only root children when parentPath is root and never exposes database ids', async () => { - const response = await GET( - request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2F`) - ) - const body = await response.json() - - expect(response.status).toBe(200) - expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { - parentId: null, - search: undefined, - sortBy: 'name', - sortOrder: 'asc', + expect(DELETE).toMatchObject({ + operation: workflowOperations.deleteFolder, + useCase: deleteWorkflowFolder, + errorPolicy: v2WorkflowErrorPolicies.default, }) - expect(body.data).toEqual([ - { - name: 'Reports', - path: '/Reports', - parentPath: '/', - locked: false, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ]) }) - it('omits the parent filter to list folders from the whole tree', async () => { - await GET(request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}`)) - - expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { - parentId: undefined, - search: undefined, + it('maps only contract-owned inputs into application inputs', () => { + expect( + Reflect.get( + GET, + 'mapInput' + )({ + query: { + workspaceId: 'ws-1', + parentPath: '/', + search: 'reports', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + ).toEqual({ + workspaceId: 'ws-1', + parentPath: '/', + search: 'reports', sortBy: 'name', sortOrder: 'asc', }) - }) - - it('creates a folder from a canonical path and rejects internal ids', async () => { - const created = await POST( - request('POST', '/api/v2/workflows/folders', { - workspaceId: WORKSPACE_ID, - path: '/Reports', + expect( + Reflect.get( + DELETE, + 'mapInput' + )({ + query: { workspaceId: 'ws-1', path: '/Reports', recursive: true }, }) - ) - - expect(created.status).toBe(201) - expect(mockCreateFolderAtPath).toHaveBeenCalledWith({ - resourceType: 'workflow', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - path: '/Reports', - }) - - const rejected = await POST( - request('POST', '/api/v2/workflows/folders', { - workspaceId: WORKSPACE_ID, - path: '/Reports', - folderId: FOLDER_ID, - }) - ) - expect(rejected.status).toBe(400) - }) - - it('relocates one folder by source and destination paths', async () => { - mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex('/Archive')) - const response = await PATCH( - request('PATCH', '/api/v2/workflows/folders', { - workspaceId: WORKSPACE_ID, - path: '/Reports', - destinationPath: '/Archive', - }) - ) - - expect(response.status).toBe(200) - expect(mockRelocateFolderByPath).toHaveBeenCalledWith({ - resourceType: 'workflow', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - path: '/Reports', - destinationPath: '/Archive', - }) - }) - - it('requires an explicit recursive delete choice', async () => { - const missing = await DELETE( - request('DELETE', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports`) - ) - expect(missing.status).toBe(400) - expect(mockDeleteFolderByPath).not.toHaveBeenCalled() - - const deleted = await DELETE( - request( - 'DELETE', - `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` - ) - ) - expect(deleted.status).toBe(200) - expect(await deleted.json()).toEqual({ - data: { - path: '/Reports', - deleted: true, - deletedItems: { folders: 1, workflows: 2 }, - }, - }) + ).toEqual({ workspaceId: 'ws-1', path: '/Reports', recursive: true }) }) }) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts index 81fb20fa35a..dd59563e2e1 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -4,122 +4,92 @@ import { v2ListWorkflowFoldersContract, v2RelocateWorkflowFolderContract, } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' import { - createFolderAtPath, - deleteFolderByPath, - relocateFolderByPath, -} from '@/lib/folders/orchestration' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createWorkflowFolder, + deleteWorkflowFolder, + listWorkflowFolders, + relocateWorkflowFolder, +} from '@/lib/workflows/application/workflow-folders' +import { toV2PathFolder } from '@/app/api/v2/lib/folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ - contract: v2ListWorkflowFoldersContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) +function toV2WorkflowFolder( + folder: Parameters[0], + index: Parameters[1] +) { + const view = toV2PathFolder(folder, index, true) + if (!('locked' in view)) throw new Error('Workflow folder projection omitted lock state') + return view +} - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'workflow', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, true)), - null, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2ListWorkflowFoldersContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + parentPath: query.parentPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listWorkflowFolders, + present: ({ folders, index }) => ({ + data: folders.map((folder) => toV2WorkflowFolder(folder, index)), + nextCursor: null, + }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateWorkflowFolderContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data( - { folder: toV2PathFolder(result.folder, index, true) }, - { rateLimit, status: 201 } - ) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: createWorkflowFolder, + present: ({ folder, index }) => ({ + data: { folder: toV2WorkflowFolder(folder, index) }, + }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateWorkflowFolderContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await relocateFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.relocateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + path: body.path, + destinationPath: body.destinationPath, + }), + useCase: relocateWorkflowFolder, + present: ({ folder, index }) => ({ + data: { folder: toV2WorkflowFolder(folder, index) }, + }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteWorkflowFolderContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await deleteFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { - path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - workflows: result.deletedItems.workflows ?? 0, - }, - }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.deleteFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + path: query.path, + recursive: query.recursive, + }), + useCase: deleteWorkflowFolder, + present: ({ path, deletedItems }) => ({ + data: { path, deleted: true as const, deletedItems }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/import/route.test.ts b/apps/sim/app/api/v2/workflows/import/route.test.ts new file mode 100644 index 00000000000..68d72c0bfa6 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -0,0 +1,30 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) + +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { importWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' +import { POST } from '@/app/api/v2/workflows/import/route' + +describe('/api/v2/workflows/import route definition', () => { + it('uses authorized admission and preserves the bounded import lifecycle', () => { + expect(POST).toMatchObject({ + operation: workflowOperations.import, + useCase: importWorkflow, + errorPolicy: v2WorkflowErrorPolicies.import, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 8ddd288d6a3..c4a9a7ea310 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -1,92 +1,37 @@ -import { createLogger } from '@sim/logger' import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { - importWorkflowIntoWorkspace, - MAX_IMPORT_BODY_BYTES, -} from '@/lib/workflows/operations/import-workflow' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { - type V2ErrorCode, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowImportAPI') +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { importWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' export const dynamic = 'force-dynamic' export const revalidate = 0 -const ERROR_CODE_BY_STATUS: Record = { - 400: 'BAD_REQUEST', - 404: 'NOT_FOUND', - 409: 'CONFLICT', - 423: 'LOCKED', - 500: 'INTERNAL_ERROR', -} - -/** - * POST /api/v2/workflows/import - * - * Creates a new workflow in the target workspace from an export payload - * produced by `GET /api/v2/workflows/{id}/export`. The shared - * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this - * route authenticates and renders the v2 envelope. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2ImportWorkflowContract, - rateLimitEndpoint: 'workflow-import', - parseOptions: { - maxBodyBytes: MAX_IMPORT_BODY_BYTES, - }, - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - const { workspaceId, folderPath, name, description } = input.body - - logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { - userId, + auth: v2ApiKeyAuth, + operation: workflowOperations.import, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.import, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + folderPath: body.folderPath, + name: body.name, + description: body.description, + workflow: body.workflow, + }), + useCase: importWorkflow, + present: ({ workflow, folderPath }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + workspaceId: workflow.workspaceId, folderPath, - }) - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'workflow', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const result = await importWorkflowIntoWorkspace({ - workspaceId, - folderId: resolution.folderId ?? undefined, - name, - description, - workflow: input.body.workflow, - userId, - requestId, - }) - - if (!result.success) { - return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { - status: result.status, - details: result.details, - }) - } - - return v2Data( - { - id: result.workflow.id, - name: result.workflow.name, - description: result.workflow.description, - workspaceId: result.workflow.workspaceId, - folderPath: folderPathForId(resolution.index, result.workflow.folderId), - createdAt: result.workflow.createdAt.toISOString(), - updatedAt: result.workflow.updatedAt.toISOString(), - }, - { rateLimit, status: 201 } - ) - }, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts deleted file mode 100644 index 404b820ac89..00000000000 --- a/apps/sim/app/api/v2/workflows/lib/access.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { workflow as workflowTable } from '@sim/db/schema' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import type { NextRequest, NextResponse } from 'next/server' -import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { authenticateV1Request } from '@/app/api/v1/auth' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Error } from '@/app/api/v2/lib/response' - -type WorkflowRecord = typeof workflowTable.$inferSelect - -export type V2WorkflowAccess = - | { - ok: true - userId: string - keyType: 'personal' | 'workspace' | undefined - workflow: WorkflowRecord - } - | { ok: false; response: NextResponse } - -/** - * X-API-Key auth + workflow authorization for the v2 execution sub-resources. - * Authorization failures and workspace-key scope mismatches are masked as 404 - * so cross-workspace workflow existence never leaks; personal keys honor the - * workspace's `allowPersonalApiKeys` setting. - */ -export async function resolveV2WorkflowAccess( - request: NextRequest, - workflowId: string, - action: 'read' | 'write' -): Promise { - const auth = await authenticateV1Request(request) - if (!auth.authenticated || !auth.userId) { - return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } - } - - const gate = await v2ApiGateError(auth.userId) - if (gate) return { ok: false, response: gate } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: auth.userId, - action, - }) - if (!authorization.allowed || !authorization.workflow) { - return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } - } - const workflow = authorization.workflow as WorkflowRecord - - if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) { - return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } - } - if (auth.keyType === 'personal' && workflow.workspaceId) { - const settings = await getWorkspaceBillingSettings(workflow.workspaceId) - if (!settings?.allowPersonalApiKeys) { - return { - ok: false, - response: v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace'), - } - } - } - - return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow } -} diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 9ab8c6575ae..6a3e094b23c 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -1,434 +1,179 @@ /** * @vitest-environment node - * - * Public v2 workflow list: the search/sort/filter convention, and the keyset - * cursor's binding to the sort it was minted under. The assertions look at the - * WHERE/ORDER BY the route hands drizzle, because that is the whole point of - * the change — a search must narrow the query, not the result. */ -import { - dbChainMockFns, - flattenMockConditions, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockPerformCreateWorkflow, - mockAssertFolderMutable, - mockLoadActiveFolderPathIndex, - FolderLockedErrorMock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformCreateWorkflow: vi.fn(), - mockAssertFolderMutable: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - FolderLockedErrorMock: class FolderLockedError extends Error { - status = 423 - }, +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createWorkflow: vi.fn(), + listWorkflows: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/create-workflow', () => ({ + createWorkflow: { operation: { id: 'workflows.create' }, execute: mocks.createWorkflow }, })) -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateWorkflow: mockPerformCreateWorkflow, +vi.mock('@/lib/workflows/application/list-workflows', () => ({ + listWorkflows: { operation: { id: 'workflows.list' }, execute: mocks.listWorkflows }, })) -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mockAssertFolderMutable, - FolderLockedError: FolderLockedErrorMock, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) import { GET, POST } from '@/app/api/v2/workflows/route' -const WS = 'workspace-1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW = { + id: 'workflow-1', + name: 'Daily digest', + description: null, + folderId: null, + folderPath: '/', + workspaceId: WORKSPACE_ID, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + sortOrder: 0, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), } -function buildRow(overrides: Record = {}) { - return { - id: 'wf_1', - name: 'Daily digest', - description: null, - folderId: null, - workspaceId: WS, - isDeployed: false, - deployedAt: null, - runCount: 3, - lastRunAt: null, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } +const workspaceAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:workspace-key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`)) - -/** The condition nodes the route passed to `.where()` on the last query. */ -const lastConditions = () => - flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) - -const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] - -/** - * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw - * column, so the mocked `sql` fragment carries the column in its interpolated - * values rather than being the column itself. - */ -const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0] +const personalAuth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} -describe('GET /api/v2/workflows', () => { +describe('/api/v2/workflows', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map(), + mocks.authenticateV2ApiKey.mockResolvedValue(workspaceAuth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - }) - - it('narrows the query with a case-insensitive substring match on the name', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - const res = await callList(`workspaceId=${WS}&search=digest`) - - expect(res.status).toBe(200) - const search = lastConditions().find((c) => c.type === 'ilike') - expect(search).toMatchObject({ column: schemaMock.workflow.name, pattern: '%digest%' }) - }) - - it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { - queueTableRows(schemaMock.workflow, []) - - await callList(`workspaceId=${WS}&search=${encodeURIComponent('100%_x')}`) - - expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ - pattern: '%100\\%\\_x%', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) + mocks.listWorkflows.mockResolvedValue({ + workflows: [WORKFLOW], + nextCursorKeys: null, + sortBy: 'position', + sortOrder: 'asc', + }) + mocks.createWorkflow.mockResolvedValue({ workflow: WORKFLOW, folderPath: '/' }) }) - it('adds no search condition when the caller did not search', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}`) - - expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) - }) - - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}&folderPath=%2F`) - - expect( - lastConditions().some( - (condition) => - condition.type === 'isNull' && condition.column === schemaMock.workflow.folderId - ) - ).toBe(true) - }) - - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) - - expect(res.status).toBe(400) - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WS}&search=`) - - expect(res.status).toBe(400) - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('defaults to the workspace position ordering', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}`) - - const orderBy = lastOrderBy() - expect(orderBy.map((e: { type: string }) => e.type)).toEqual(['asc', 'asc', 'asc']) - expect(orderBy[0].column).toBe(schemaMock.workflow.sortOrder) - expect(truncatedColumnOf(orderBy[1])).toBe(schemaMock.workflow.createdAt) - expect(orderBy[2].column).toBe(schemaMock.workflow.id) - }) - - it('orders by the requested field and direction', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}&sortBy=name&sortOrder=desc`) - - expect(lastOrderBy()).toEqual([ - { type: 'desc', column: schemaMock.workflow.name }, - { type: 'desc', column: schemaMock.workflow.id }, - ]) - }) - - it('combines a filter with a cursor into one consistent page', async () => { - queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2', name: 'Zebra' })]) - - const first = await callList(`workspaceId=${WS}&search=a&sortBy=name&limit=1`) - const body = await first.json() - - expect(body.data).toHaveLength(1) - expect(body.nextCursor).not.toBeNull() - - queueTableRows(schemaMock.workflow, [buildRow({ id: 'wf_2', name: 'Zebra' })]) - const second = await callList( - `workspaceId=${WS}&search=a&sortBy=name&limit=1&cursor=${encodeURIComponent(body.nextCursor)}` - ) - - expect(second.status).toBe(200) - const conditions = lastConditions() - // The filter survives the cursor page, and the keyset resumes from the last row. - expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%a%' }) - expect(conditions.some((c) => c.type === 'or')).toBe(true) - }) - - it('terminates pagination once a filtered page is not full', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - const res = await callList(`workspaceId=${WS}&search=digest&limit=50`) + it('authenticates and rate limits before parsing list input', async () => { + const response = await GET(new NextRequest('http://localhost/api/v2/workflows')) - expect((await res.json()).nextCursor).toBeNull() + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledOnce() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.listWorkflows).not.toHaveBeenCalled() }) - it('400s when a cursor is replayed under a different sort', async () => { - queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2' })]) - - const first = await callList(`workspaceId=${WS}&sortBy=name&limit=1`) - const { nextCursor } = await first.json() - vi.clearAllMocks() - - const res = await callList( - `workspaceId=${WS}&sortBy=createdAt&limit=1&cursor=${encodeURIComponent(nextCursor)}` + it('lists through the workspace principal and preserves rate headers', async () => { + const request = new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`, + { headers: { 'x-api-key': 'secret' } } ) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/cursor does not match/i) - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('400s on a malformed cursor instead of silently restarting from page one', async () => { - const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) - - expect(res.status).toBe(400) - expect(dbChainMockFns.where).not.toHaveBeenCalled() + const response = await GET(request) + + expect(response.status).toBe(200) + expect(response.headers.get('x-ratelimit-limit')).toBe('100') + expect(response.headers.get('x-ratelimit-remaining')).toBe('99') + expect(await response.json()).toEqual({ + data: [ + { + id: WORKFLOW.id, + name: WORKFLOW.name, + description: null, + folderPath: '/', + workspaceId: WORKSPACE_ID, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(mocks.listWorkflows).toHaveBeenCalledWith({ + principal: workspaceAuth.principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID, limit: 50 }), + request, + }) }) -}) - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const CREATED = { - id: 'wf-1', - name: 'Support Agent', - description: 'Handles tickets', - workspaceId: 'workspace-1', - folderId: null, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-01T00:00:00Z'), - startBlockId: 'block-1', - subBlockValues: {}, -} - -const VALID_BODY = { - workspaceId: 'workspace-1', - name: 'Support Agent', - description: 'Handles tickets', -} - -function callPost(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/workflows', { + it('creates through a personal-key principal with the exact 201 contract', async () => { + mocks.authenticateV2ApiKey.mockResolvedValue(personalAuth) + const request = new NextRequest('http://localhost/api/v2/workflows', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: WORKFLOW.name }), }) - ) -} - -describe('POST /api/v2/workflows', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), - pathById: new Map([['fld-1', '/Locked']]), - idByPath: new Map([['/Locked', 'fld-1']]), + const response = await POST(request) + + expect(response.status).toBe(201) + expect((await response.json()).data.id).toBe(WORKFLOW.id) + expect(mocks.createWorkflow).toHaveBeenCalledWith({ + principal: personalAuth.principal, + input: { workspaceId: WORKSPACE_ID, name: WORKFLOW.name }, + request, }) - mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPost(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('400s when name is missing', async () => { - const res = await callPost({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('400s on an unknown body field', async () => { - const res = await callPost({ ...VALID_BODY, sortOrder: 3 }) - expect(res.status).toBe(400) - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPost(VALID_BODY) - expect(res.status).toBe(403) - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) - it('requires write access on the target workspace', async () => { - await callPost(VALID_BODY) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'write' + it('hides infrastructure failures behind the safe v2 500 envelope', async () => { + mocks.listWorkflows.mockRejectedValue(new Error('database connection details')) + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`) ) - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPost(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('423s when the destination folder is locked', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPost({ ...VALID_BODY, folderPath: '/Locked' }) - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('404s a path outside the workspace without ever reading its lock state', async () => { - const res = await callPost({ ...VALID_BODY, folderPath: '/Elsewhere' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockAssertFolderMutable).not.toHaveBeenCalled() - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('resolves the canonical path before checking mutability', async () => { - await callPost({ ...VALID_BODY, folderPath: '/Locked' }) - - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - 'workspace-1', - 'workflow', - expect.any(Object) - ) - expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') - }) - - it('skips the containment check when no folder is supplied', async () => { - await callPost(VALID_BODY) - expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) - }) - - it('409s when the name is already taken in the target folder', async () => { - mockPerformCreateWorkflow.mockResolvedValue({ - success: false, - error: 'A workflow named "Support Agent" already exists in this folder', - errorCode: 'conflict', - }) - const res = await callPost(VALID_BODY) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('creates the workflow and returns 201 with the public shape', async () => { - const res = await callPost(VALID_BODY) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body).toEqual({ - data: { - id: 'wf-1', - name: 'Support Agent', - description: 'Handles tickets', - folderPath: '/', - workspaceId: 'workspace-1', - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }, + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Support Agent', - description: 'Handles tickets', - folderId: null, - }) - ) }) }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 3e0a42c8171..f4dc34c3ef7 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,150 +1,88 @@ -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import type { V2WorkflowListItem } from '@/lib/api/contracts/v2/workflows' +import { v2CreateWorkflowContract, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { - type V2WorkflowListItem, - v2CreateWorkflowContract, - v2ListWorkflowsContract, -} from '@/lib/api/contracts/v2/workflows' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { performCreateWorkflow } from '@/lib/workflows/orchestration' -import { InvalidWorkflowListCursorError, listWorkspaceWorkflows } from '@/lib/workflows/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - folderPathForId, - resolveFolderPathId, - resolveFolderPathIdentity, -} from '@/app/api/v2/lib/folders' -import { - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, - v2CursorList, - v2CursorSortError, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { createWorkflow } from '@/lib/workflows/application/create-workflow' +import { listWorkflows } from '@/lib/workflows/application/list-workflows' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkflowsContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const params = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(params.workspaceId, 'workflow') - const folderId = - params.folderPath === undefined - ? undefined - : resolveFolderPathId(folderIndex, params.folderPath) - if (params.folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') + auth: v2ApiKeyAuth, + operation: workflowOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => { + const sort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, sort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } - - const sortKey = cursorSortKey(params.sortBy, params.sortOrder) - const decoded = decodeSortedCursor(params.cursor, sortKey) - if (decoded.status === 'invalid') return v2CursorSortError() - - let result - try { - result = await listWorkspaceWorkflows({ - workspaceId: params.workspaceId, - folderId, - deployedOnly: params.deployedOnly, - search: params.search, - sortBy: params.sortBy, - sortOrder: params.sortOrder, - cursorKeys: decoded.status === 'ok' ? decoded.keys : undefined, - limit: params.limit, - }) - } catch (error) { - if (error instanceof InvalidWorkflowListCursorError) return v2CursorSortError() - throw error + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + deployedOnly: query.deployedOnly, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + cursorKeys: decoded.status === 'ok' ? decoded.keys : undefined, + limit: query.limit, } - - const nextCursor = result.nextCursorKeys - ? encodeSortedCursor(sortKey, result.nextCursorKeys) - : null - - const formatted: V2WorkflowListItem[] = result.data.map((w) => ({ - id: w.id, - name: w.name, - description: w.description, - folderPath: folderPathForId(folderIndex, w.folderId), - workspaceId: w.workspaceId ?? params.workspaceId, - isDeployed: w.isDeployed, - deployedAt: w.deployedAt?.toISOString() ?? null, - runCount: w.runCount, - lastRunAt: w.lastRunAt?.toISOString() ?? null, - createdAt: w.createdAt.toISOString(), - updatedAt: w.updatedAt.toISOString(), - })) - - return v2CursorList(formatted, nextCursor, { rateLimit }) }, + useCase: listWorkflows, + present: ({ workflows, nextCursorKeys, sortBy, sortOrder }) => ({ + data: workflows.map( + (workflow): V2WorkflowListItem => ({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath: workflow.folderPath, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt?.toISOString() ?? null, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt?.toISOString() ?? null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }) + ), + nextCursor: nextCursorKeys + ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + : null, + }), }) -/** POST /api/v2/workflows — Create an empty workflow in a workspace. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateWorkflowContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { workspaceId, name, description, folderPath } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'workflow', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - await assertFolderMutable(resolution.folderId) - const result = await performCreateWorkflow({ - userId, - workspaceId, - name, - description, - folderId: resolution.folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration( - result.errorCode, - result.error ?? 'Failed to create workflow' - ) - } - - const created = result.workflow - const item: V2WorkflowListItem = { - id: created.id, - name: created.name, - description: created.description ?? null, - folderPath: folderPathForId(resolution.index, created.folderId), - workspaceId: created.workspaceId, - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: created.createdAt.toISOString(), - updatedAt: created.updatedAt.toISOString(), - } - - return v2Data(item, { rateLimit, status: 201 }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - throw error - } - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: createWorkflow, + present: ({ workflow, folderPath }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description ?? null, + folderPath, + workspaceId: workflow.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/utils.ts b/apps/sim/app/api/v2/workflows/utils.ts deleted file mode 100644 index 450521e2cca..00000000000 --- a/apps/sim/app/api/v2/workflows/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PermissionType } from '@sim/platform-authz/workspace' -import { - type DeploymentWorkflowTarget, - getDeploymentWorkflowTarget, -} from '@/lib/workflows/deployments/queries' -import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' - -/** Resolves an authorized active workflow while keeping the v2 response adapter route-local. */ -export async function resolveV2WorkflowTarget( - rateLimit: RateLimitResult, - userId: string, - workflowId: string, - level: PermissionType = 'read' -): Promise { - const target = await getDeploymentWorkflowTarget(workflowId) - if (!target) return null - - const accessError = await resolveWorkspaceAccess(rateLimit, userId, target.workspaceId, level) - return accessError ? null : target -} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 4fb34919b76..30960639858 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -7,15 +7,13 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - getWorkflowDeploymentSummary, - performFullDeploy, - performFullUndeploy, -} from '@/lib/workflows/orchestration' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' +import { getWorkflowDeploymentSummary } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, @@ -94,9 +92,11 @@ export const GET = withRouteHandler( latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, warnings: deploymentSummary.warnings, }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) - return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) + } catch (error: unknown) { + logger.error(`[${requestId}] Error fetching deployment info: ${id}`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to fetch deployment information', 500) } } ) @@ -107,47 +107,31 @@ export const POST = withRouteHandler( const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - - const actorUserId: string | null = session?.user?.id ?? null - if (!actorUserId) { - logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) - return createErrorResponse('Unable to determine deploying user', 400) - } - await assertWorkflowMutable(id) - - const result = await performFullDeploy({ - workflowId: id, - userId: actorUserId, - requestId, + const principal = await internalSessionAuth.authenticate() + const result = await deployWorkflow.execute({ + principal, + input: { workflowId: id, requestId }, + request, }) - if (!result.success) { - return createErrorResponse( - result.error || 'Failed to deploy workflow', - statusForOrchestrationError(result.errorCode) - ) - } - const isDeployed = Boolean(result.activeDeployment) const attemptActivated = result.latestDeploymentAttempt?.status === 'active' logger.info( `[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}` ) - const responseApiKeyInfo = workflowData!.workspaceId - ? 'Workspace API keys' - : 'Personal API keys' + captureServerEvent( + principal.userId, + 'workflow_deployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) return createSuccessResponse({ - apiKey: responseApiKeyInfo, + apiKey: 'Workspace API keys', isDeployed, deployedAt: result.deployedAt, warnings: result.warnings, @@ -155,12 +139,20 @@ export const POST = withRouteHandler( latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error: unknown) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) } - const message = getErrorMessage(error, 'Failed to deploy workflow') - logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) - return createErrorResponse(message, 500) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error deploying workflow: ${id}`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to deploy workflow', 500) } } ) @@ -230,45 +222,31 @@ export const PATCH = withRouteHandler( if (error instanceof WorkflowLockedError) { return createErrorResponse(error.message, error.status) } - const message = getErrorMessage(error, 'Failed to update deployment settings') - logger.error(`[${requestId}] Error updating deployment settings`, { error }) - return createErrorResponse(message, 500) + logger.error(`[${requestId}] Error updating deployment settings`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to update deployment settings', 500) } } ) export const DELETE = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - await assertWorkflowMutable(id) - - const result = await performFullUndeploy({ - workflowId: id, - userId: session!.user.id, - requestId, + const principal = await internalSessionAuth.authenticate() + const result = await undeployWorkflow.execute({ + principal, + input: { workflowId: id, requestId }, + request, }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) - } - - const wsId = workflowData?.workspaceId captureServerEvent( - session!.user.id, + principal.userId, 'workflow_undeployed', - { workflow_id: id, workspace_id: wsId ?? '' }, - wsId ? { groups: { workspace: wsId } } : undefined + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } ) return createSuccessResponse({ @@ -278,12 +256,20 @@ export const DELETE = withRouteHandler( warnings: result.warnings, }) } catch (error: unknown) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) } - const message = getErrorMessage(error, 'Failed to undeploy workflow') - logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) - return createErrorResponse(message, 500) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to undeploy workflow', 500) } } ) 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 5d5300ec13d..42516bd676a 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,14 +4,14 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performActivateVersion } from '@/lib/workflows/orchestration' -import { - getWorkflowDeploymentVersion, - updateDeploymentVersionMetadata, -} from '@/lib/workflows/persistence/utils' +import { captureServerEvent } from '@/lib/posthog/server' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' +import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -30,28 +30,36 @@ export const GET = withRouteHandler( const { id, version } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) - } + const principal = await internalSessionAuth.authenticate() const versionNum = Number(version) if (!Number.isFinite(versionNum)) { return createErrorResponse('Invalid version', 400) } - const row = await getWorkflowDeploymentVersion(id, versionNum) - if (!row?.state) { - return createErrorResponse('Deployment version not found', 404) - } + const { version: row } = await readWorkflowVersion.execute({ + principal, + input: { workflowId: id, version: versionNum }, + request, + }) return createSuccessResponse({ deployedState: row.state }) - } catch (error: any) { + } catch (error: unknown) { + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) + } + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } logger.error( `[${requestId}] Error fetching deployment version ${version} for workflow ${id}`, - error + { error } ) - return createErrorResponse(error.message || 'Failed to fetch deployment version', 500) + return createErrorResponse('Failed to fetch deployment version', 500) } } ) @@ -61,6 +69,7 @@ export const PATCH = withRouteHandler( const requestId = generateRequestId() try { + const principal = await internalSessionAuth.authenticate() const parsed = await parseRequest(updateDeploymentVersionMetadataContract, request, context, { validationErrorResponse: (error) => createErrorResponse(getValidationErrorMessage(error, 'Invalid request body'), 400), @@ -70,43 +79,16 @@ export const PATCH = withRouteHandler( const { id, version } = parsed.data.params const { name, description, isActive } = parsed.data.body - // Activation requires admin permission, other updates require write - const requiredPermission = isActive ? 'admin' : 'write' - const { error, session } = await validateWorkflowPermissions( - id, - requestId, - requiredPermission - ) - if (error) { - return createErrorResponse(error.message, error.status) - } - const versionNum = version // Handle activation if (isActive) { - const actorUserId = session?.user?.id - if (!actorUserId) { - logger.warn( - `[${requestId}] Unable to resolve actor user for deployment activation: ${id}` - ) - return createErrorResponse('Unable to determine activating user', 400) - } - - const activateResult = await performActivateVersion({ - workflowId: id, - version: versionNum, - userId: actorUserId, - requestId, + const activateResult = await activateWorkflowVersion.execute({ + principal, + input: { workflowId: id, version: versionNum, transition: 'activate', requestId }, + request, }) - if (!activateResult.success) { - return createErrorResponse( - activateResult.error || 'Failed to activate deployment', - statusForOrchestrationError(activateResult.errorCode) - ) - } - let updatedName: string | null | undefined let updatedDescription: string | null | undefined if (name !== undefined || description !== undefined) { @@ -142,6 +124,17 @@ export const PATCH = withRouteHandler( } } + captureServerEvent( + principal.userId, + 'deployment_version_activated', + { + workflow_id: activateResult.workflowId, + workspace_id: activateResult.workspaceId, + version: versionNum, + }, + { groups: { workspace: activateResult.workspaceId } } + ) + return createSuccessResponse({ success: true, deployedAt: activateResult.deployedAt ?? null, @@ -153,6 +146,11 @@ export const PATCH = withRouteHandler( }) } + const { error } = await validateWorkflowPermissions(id, requestId, 'write') + if (error) { + return createErrorResponse(error.message, error.status) + } + // Handle name/description updates (shared with the update_deployment_version copilot tool) const updated = await updateDeploymentVersionMetadata({ workflowId: id, @@ -171,9 +169,19 @@ export const PATCH = withRouteHandler( }) return createSuccessResponse({ name: updated.name, description: updated.description }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating deployment version`, error) - return createErrorResponse(error.message || 'Failed to update deployment version', 500) + } catch (error: unknown) { + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) + } + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error updating deployment version`, { error }) + return createErrorResponse('Failed to update deployment version', 500) } } ) diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 4a1a9998ca6..f958f5b5de3 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -2,10 +2,11 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' 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 { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeploymentsListAPI') @@ -16,26 +17,37 @@ export const runtime = 'nodejs' export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const parsed = await parseRequest(listDeploymentVersionsContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) - } + const principal = await internalSessionAuth.authenticate() + const parsed = await parseRequest(listDeploymentVersionsContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params - const { versions: rows } = await listWorkflowVersions(id) + const { versions: rows } = await listWorkflowVersions.execute({ + principal, + input: { workflowId: id }, + request, + }) const versions = rows.map(({ deployedByName, ...version }) => ({ ...version, deployedBy: deployedByName, })) return createSuccessResponse({ versions }) - } catch (error: any) { - logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error) - return createErrorResponse(error.message || 'Failed to list deployments', 500) + } catch (error: unknown) { + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) + } + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error listing workflow deployments`, { error }) + return createErrorResponse('Failed to list deployments', 500) } } ) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 36b8c118fde..259e66f88bb 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -9,8 +9,10 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { V1_IMPORT_DESCRIPTION_MAX_LENGTH, V1_IMPORT_NAME_MAX_LENGTH, + v1DeployWorkflowBodySchema, v1DeployWorkflowDataSchema, v1ImportWorkflowBodySchema, + v1RollbackWorkflowBodySchema, v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' @@ -222,6 +224,7 @@ export const v2CreateWorkflowContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2WorkflowListItemSchema), + status: 201, }, }) @@ -268,7 +271,7 @@ export const v2CreateWorkflowFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/folders', body: v2CreateFolderBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema), status: 201 }, }) export const v2RelocateWorkflowFolderContract = defineRouteContract({ @@ -345,6 +348,7 @@ export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', params: workflowIdParamsSchema, + body: v1DeployWorkflowBodySchema.optional().default({}), response: { mode: 'json', schema: v2DataResponse(v1DeployWorkflowDataSchema), @@ -365,6 +369,7 @@ export const v2RollbackWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/rollback', params: workflowIdParamsSchema, + body: v1RollbackWorkflowBodySchema.optional().default({}), response: { mode: 'json', schema: v2DataResponse(v1RollbackWorkflowDataSchema), @@ -668,5 +673,6 @@ export const v2ImportWorkflowContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2ImportWorkflowDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index f9fdcfd39c1..3eacbed3f3e 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -15,8 +15,10 @@ export { } from '@/lib/api/server/routes/internal-json-route' export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { + admitV2Request, defineV2JsonRoute, type V2ErrorPolicy, + V2RouteInfrastructureError, v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits, diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 06b433c7652..cc9f327d7bb 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -59,6 +59,8 @@ export interface ParseRequestOptions { * routes that legitimately accept large JSON payloads (e.g. inline file uploads). */ maxBodyBytes?: number + /** Treat an absent or whitespace-only body as `undefined` before contract validation. */ + optionalJsonBody?: boolean } export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] { @@ -164,7 +166,12 @@ export async function parseOptionalJsonBody( request: Request, maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES ): Promise< - { success: true; data: unknown } | { success: false; response: NextResponse<{ error: string }> } + | { success: true; data: unknown } + | { + success: false + reason: 'too_large' | 'invalid_json' + response: NextResponse<{ error: string }> + } > { try { assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL) @@ -184,6 +191,7 @@ export async function parseOptionalJsonBody( if (isPayloadSizeLimitError(error)) { return { success: false, + reason: 'too_large', response: NextResponse.json( { error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` }, { status: 413 } @@ -192,6 +200,7 @@ export async function parseOptionalJsonBody( } return { success: false, + reason: 'invalid_json', response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }), } } @@ -244,12 +253,22 @@ export async function parseRequest( let body: unknown if (shouldReadJsonBody(contract)) { - const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) + const parsedBody = options?.optionalJsonBody + ? await parseOptionalJsonBody(request, options.maxBodyBytes) + : await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) if (!parsedBody.success) { - if (options?.invalidJsonResponse && parsedBody.reason === 'invalid_json') { + if ( + options?.invalidJsonResponse && + 'reason' in parsedBody && + parsedBody.reason === 'invalid_json' + ) { return { success: false, response: options.invalidJsonResponse() } } - if (options?.payloadTooLargeResponse && parsedBody.reason === 'too_large') { + if ( + options?.payloadTooLargeResponse && + 'reason' in parsedBody && + parsedBody.reason === 'too_large' + ) { return { success: false, response: options.payloadTooLargeResponse() } } return parsedBody diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 9c28f293d70..ea8138bf790 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -16,7 +16,12 @@ export type { } from '@/lib/core/application/workspace-authorization' export { authorizeWorkspaceOperation, + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, + WorkspaceApiKeyAuthorizationError, } from '@/lib/core/application/workspace-authorization' export { defineWorkspaceOperation, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 01d5e904e31..b511270f1f9 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -28,21 +28,53 @@ export interface WorkspaceAuthorizationOptions } +export class InsufficientWorkspacePermissionsError extends OrchestrationError { + constructor() { + super('forbidden', 'Insufficient workspace permissions') + this.name = 'InsufficientWorkspacePermissionsError' + } +} + +export class PersonalApiKeysDisabledError extends OrchestrationError { + constructor() { + super('forbidden', 'Personal API keys are not allowed for this workspace') + this.name = 'PersonalApiKeysDisabledError' + } +} + +export class WorkspaceApiKeyAuthorizationError extends OrchestrationError { + constructor() { + super('forbidden', 'Workspace API key cannot perform this operation') + this.name = 'WorkspaceApiKeyAuthorizationError' + } +} + +export class DelegatedWorkspaceAuthorizationError extends OrchestrationError { + constructor() { + super('forbidden', 'Delegated workspace access is no longer valid') + this.name = 'DelegatedWorkspaceAuthorizationError' + } +} + +export class PrincipalKindAuthorizationError extends OrchestrationError { + constructor(principalKind: Principal['kind'], operationId: string) { + super('forbidden', `Principal kind ${principalKind} cannot perform operation ${operationId}`) + this.name = 'PrincipalKindAuthorizationError' + } +} + export function requireAllowedWorkspacePrincipal( principal: Principal, operation: O ): asserts principal is PrincipalForOperation { if (!operation.principalKinds.some((kind) => kind === principal.kind)) { - throw new OrchestrationError( - 'forbidden', - `Principal kind ${principal.kind} cannot perform operation ${operation.id}` - ) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } } function requirePermission(permission: PermissionType | null, required: PermissionType): void { if (!permissionSatisfies(permission, required)) { - throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') + throw new InsufficientWorkspacePermissionsError() } } @@ -76,10 +108,7 @@ export async function authorizeWorkspaceOperation ({ import { createFolder, createFolderAtPath, + createFolderAtPathTransition, deleteFolder, deleteFolderByPath, relocateFolderByPath, @@ -333,6 +334,21 @@ describe('createFolder', () => { }) describe('path-owned folder mutations', () => { + it('does not project legacy audit from the application transition', async () => { + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) + + const result = await createFolderAtPathTransition({ + resourceType: 'workflow', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + }) + + expect(result).toMatchObject({ success: true, path: '/Reports' }) + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('creates only the addressed leaf under an existing canonical parent path', async () => { const parent = folderRow({ id: 'parent-1', name: 'Reports' }) mockLoadActiveFolderPathIndex.mockResolvedValue({ diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index c19ca42d061..582b1f755cd 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -107,6 +107,8 @@ export interface DeleteFolderByPathParams { export interface DeleteFolderByPathResult extends DeleteFolderResult { path?: string + folderId?: string + folderName?: string } function validatePathLeafName(path: string): string { @@ -161,9 +163,9 @@ function pathMutationError(error: unknown): FolderPathMutationResult { return { success: false, error: 'Internal server error', errorCode: 'internal' } } -/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ -export async function createFolderAtPath( - params: Omit & { path: string } +async function executeCreateFolderAtPath( + params: Omit & { path: string }, + projectLegacyLifecycle: boolean ): Promise { try { requireNonRootFolderPath(params.path) @@ -211,16 +213,18 @@ export async function createFolderAtPath( { label: 'create-folder-at-path' } ) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, - metadata: { path: params.path, folderResourceType: params.resourceType }, - }) + if (projectLegacyLifecycle) { + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, + metadata: { path: params.path, folderResourceType: params.resourceType }, + }) + } await notifyFolderResourceChanged(params.resourceType, params.workspaceId) return { success: true, folder, path: params.path } } catch (error) { @@ -228,14 +232,32 @@ export async function createFolderAtPath( } } -/** Renames, moves, or both by replacing one canonical path with another. */ -export async function relocateFolderByPath(params: { +/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ +export async function createFolderAtPath( + params: Omit & { path: string } +): Promise { + return executeCreateFolderAtPath(params, true) +} + +/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +export async function createFolderAtPathTransition( + params: Omit & { path: string } +): Promise { + return executeCreateFolderAtPath(params, false) +} + +type RelocateFolderByPathParams = { resourceType: FolderResourceType workspaceId: string userId: string path: string destinationPath: string -}): Promise { +} + +async function executeRelocateFolderByPath( + params: RelocateFolderByPathParams, + projectLegacyLifecycle: boolean +): Promise { try { requireNonRootFolderPath(params.path) requireNonRootFolderPath(params.destinationPath) @@ -287,20 +309,22 @@ export async function relocateFolderByPath(params: { { label: 'relocate-folder-by-path' } ) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - action: AuditAction.FOLDER_MOVED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, - metadata: { - sourcePath: params.path, - destinationPath: params.destinationPath, - folderResourceType: params.resourceType, - }, - }) + if (projectLegacyLifecycle) { + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, + metadata: { + sourcePath: params.path, + destinationPath: params.destinationPath, + folderResourceType: params.resourceType, + }, + }) + } await notifyFolderResourceChanged(params.resourceType, params.workspaceId) return { success: true, folder, path: params.destinationPath } } catch (error) { @@ -308,9 +332,23 @@ export async function relocateFolderByPath(params: { } } -/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ -export async function deleteFolderByPath( - params: DeleteFolderByPathParams +/** Renames, moves, or both by replacing one canonical path with another. */ +export async function relocateFolderByPath( + params: RelocateFolderByPathParams +): Promise { + return executeRelocateFolderByPath(params, true) +} + +/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +export async function relocateFolderByPathTransition( + params: RelocateFolderByPathParams +): Promise { + return executeRelocateFolderByPath(params, false) +} + +async function executeDeleteFolderByPath( + params: DeleteFolderByPathParams, + projectLegacyLifecycle: boolean ): Promise { try { requireNonRootFolderPath(params.path) @@ -360,13 +398,32 @@ export async function deleteFolderByPath( } ) - const result = await deleteFolderWithoutTreeLock(resolved, null) - return { ...result, path: result.success ? params.path : undefined } + const result = await deleteFolderWithoutTreeLock(resolved, null, projectLegacyLifecycle) + return { + ...result, + path: result.success ? params.path : undefined, + folderId: result.success ? resolved.folderId : undefined, + folderName: result.success ? resolved.folderName : undefined, + } } catch (error) { return pathMutationError(error) } } +/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ +export async function deleteFolderByPath( + params: DeleteFolderByPathParams +): Promise { + return executeDeleteFolderByPath(params, true) +} + +/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +export async function deleteFolderByPathTransition( + params: DeleteFolderByPathParams +): Promise { + return executeDeleteFolderByPath(params, false) +} + /** * Verifies that a prospective parent folder exists, belongs to the target workspace, is of * the same `resourceType`, and is not archived. @@ -669,12 +726,13 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) @@ -700,24 +758,25 @@ async function deleteFolderWithoutTreeLock( logger.info('Deleted folder and all contents', { folderId, resourceType, counts }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderId, - resourceName: folderName, - description: `Deleted ${config.label} folder "${folderPath ?? folderName ?? folderId}"`, - metadata: { - folderResourceType: resourceType, - path: folderPath, - affected: { - [config.countKey]: counts.children, - subfolders: Math.max(counts.folders - 1, 0), + if (projectLegacyLifecycle) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName, + description: `Deleted ${config.label} folder "${folderPath ?? folderName ?? folderId}"`, + metadata: { + folderResourceType: resourceType, + path: folderPath, + affected: { + [config.countKey]: counts.children, + subfolders: Math.max(counts.folders - 1, 0), + }, }, - }, - }) - + }) + } // Live resource list (e.g. tables): a delete removes the folder and cascades to its contents. await notifyFolderResourceChanged(resourceType, workspaceId) return { success: true, deletedItems: toCascadeCounts(config, counts) } diff --git a/apps/sim/lib/workflows/api/index.ts b/apps/sim/lib/workflows/api/index.ts new file mode 100644 index 00000000000..ad2a025d322 --- /dev/null +++ b/apps/sim/lib/workflows/api/index.ts @@ -0,0 +1 @@ +export { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts new file mode 100644 index 00000000000..0955c8082a0 --- /dev/null +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' + +describe('v2 workflow error policies', () => { + it.each([ + new InsufficientWorkspacePermissionsError(), + new WorkspaceApiKeyAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'), + ])('conceals workflow authorization failures as absence', async (error) => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + }) + + it('preserves the personal-api-key workspace policy failure as forbidden', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( + new PersonalApiKeysDisabledError() + ) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + }, + }) + }) + + it('uses run-specific concealment text for canonical run operations', async () => { + const response = v2WorkflowErrorPolicies.concealRunAuthorization.render( + new InsufficientWorkspacePermissionsError() + ) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Run not found' }, + }) + }) + + it('does not conceal unrelated forbidden business errors', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( + new OrchestrationError('forbidden', 'Workflow transition is forbidden') + ) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Workflow transition is forbidden' }, + }) + }) +}) diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts new file mode 100644 index 00000000000..a90b6888819 --- /dev/null +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -0,0 +1,51 @@ +import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' +import { + v2CaughtOrchestrationError, + v2Error, + v2ErrorForOrchestration, +} from '@/app/api/v2/lib/response' + +function isConcealedResourceAuthorizationError(error: unknown): boolean { + return ( + error instanceof DelegatedWorkspaceAuthorizationError || + error instanceof InsufficientWorkspacePermissionsError || + error instanceof PrincipalKindAuthorizationError || + error instanceof WorkspaceApiKeyAuthorizationError + ) +} + +function concealResourceAuthorization(resourceName: 'Workflow' | 'Run'): V2ErrorPolicy { + return { + render(error) { + if (error instanceof PersonalApiKeysDisabledError) { + return v2CaughtOrchestrationError(error) + } + if (isConcealedResourceAuthorizationError(error)) { + return v2Error('NOT_FOUND', `${resourceName} not found`) + } + return v2CaughtOrchestrationError(error) + }, + } +} + +export const v2WorkflowErrorPolicies = { + default: v2OrchestrationErrorPolicy, + import: { + render(error) { + if (error instanceof WorkflowImportError) { + return v2ErrorForOrchestration(error.code, error.message, error.details) + } + return v2CaughtOrchestrationError(error) + }, + } satisfies V2ErrorPolicy, + concealWorkflowAuthorization: concealResourceAuthorization('Workflow'), + concealRunAuthorization: concealResourceAuthorization('Run'), +} as const diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts new file mode 100644 index 00000000000..f8dffb5ba3e --- /dev/null +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -0,0 +1,23 @@ +import type { Principal } from '@sim/auth/principal' +import type { + WorkspaceAuthorizationContext, + WorkspaceDelegationPolicy, +} from '@/lib/core/application' + +export const WORKFLOW_DELEGATION_AUDIENCE = 'sim:workflows' + +export interface WorkflowAuthorizationContext extends WorkspaceAuthorizationContext { + workflowId?: string + runId?: string + billedAccountUserId: string +} + +export const workflowDelegationPolicy: WorkspaceDelegationPolicy = { + audience: WORKFLOW_DELEGATION_AUDIENCE, + isWithinScope( + principal: Extract, + context: WorkflowAuthorizationContext + ) { + return principal.workspaceId === context.workspaceId + }, +} diff --git a/apps/sim/lib/workflows/application/authorized-workflow-use-case.ts b/apps/sim/lib/workflows/application/authorized-workflow-use-case.ts new file mode 100644 index 00000000000..b943c0ad4b9 --- /dev/null +++ b/apps/sim/lib/workflows/application/authorized-workflow-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type WorkflowAuthorizationContext, + workflowDelegationPolicy, +} from '@/lib/workflows/application/authorization' + +type AuthorizedWorkflowUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkflowAuthorizationContext, + R, +> = Omit, 'authorizationOptions'> + +export function defineAuthorizedWorkflowUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkflowAuthorizationContext, + R, +>(definition: AuthorizedWorkflowUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: workflowDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/workflows/application/cancel-run.ts b/apps/sim/lib/workflows/application/cancel-run.ts new file mode 100644 index 00000000000..c3f52c3c4c4 --- /dev/null +++ b/apps/sim/lib/workflows/application/cancel-run.ts @@ -0,0 +1,51 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + cancelWorkflowExecution, + WorkflowExecutionNotFoundError, +} from '@/lib/execution/cancel-workflow-execution' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export interface CancelWorkflowRunInput { + workflowId: string + runId: string +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.cancelRun, + resolveContext: ({ principal, input }: { principal: Principal; input: CancelWorkflowRunInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ principal, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + try { + const result = await cancelWorkflowExecution({ + executionId: context.runId, + workflowId: context.workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + captureAnalytics: false, + }) + return { ...result, workflowId: context.workflowId, workspaceId: context.workspaceId } + } catch (error) { + if (error instanceof WorkflowExecutionNotFoundError) { + throw new OrchestrationError('not_found', 'Run not found') + } + throw error + } + }, +}) diff --git a/apps/sim/lib/workflows/application/context.ts b/apps/sim/lib/workflows/application/context.ts new file mode 100644 index 00000000000..27bbdab0bed --- /dev/null +++ b/apps/sim/lib/workflows/application/context.ts @@ -0,0 +1,136 @@ +import { db } from '@sim/db' +import { + pausedExecutions, + resumeQueue, + workflow, + workflowExecutionLogs, + workspace, +} from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' + +export interface ActiveWorkflowApplicationContext { + workflowId: string + workflow: typeof workflow.$inferSelect + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +export interface ActiveWorkspaceApplicationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowApplicationContext { + runId: string +} + +export async function resolveActiveWorkspaceApplicationContext( + workspaceId: string +): Promise { + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +export async function resolveActiveWorkflowApplicationContext(input: { + workflowId: string + assertedWorkspaceId?: string +}): Promise { + const [context] = await db + .select({ + workflowId: workflow.id, + workflow, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workflow) + .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) + .where( + and( + eq(workflow.id, input.workflowId), + isNull(workflow.archivedAt), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + + if ( + !context || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== context.workspaceId) + ) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + return context +} + +async function resolveCanonicalRunWorkflowId(runId: string): Promise { + const [logRows, pausedRows, resumeRows] = await Promise.all([ + db + .select({ workflowId: workflowExecutionLogs.workflowId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, runId)) + .limit(1), + db + .select({ workflowId: pausedExecutions.workflowId }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, runId)) + .limit(1), + db + .select({ workflowId: pausedExecutions.workflowId }) + .from(resumeQueue) + .innerJoin(pausedExecutions, eq(resumeQueue.pausedExecutionId, pausedExecutions.id)) + .where(eq(resumeQueue.newExecutionId, runId)) + .limit(1), + ]) + + const canonicalIds = new Set( + [logRows[0]?.workflowId, pausedRows[0]?.workflowId, resumeRows[0]?.workflowId].filter( + (value): value is string => typeof value === 'string' + ) + ) + + if (canonicalIds.size > 1) { + throw new Error(`Run ${runId} has conflicting canonical workflow bindings`) + } + if (canonicalIds.size === 1) return [...canonicalIds][0] + + const queue = await getJobQueue() + const job = await queue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${runId}`) + return job?.metadata.workflowId ?? null +} + +export async function resolveActiveWorkflowRunApplicationContext(input: { + runId: string + assertedWorkflowId?: string + assertedWorkspaceId?: string +}): Promise { + const workflowId = await resolveCanonicalRunWorkflowId(input.runId) + if (!workflowId || (input.assertedWorkflowId && input.assertedWorkflowId !== workflowId)) { + throw new OrchestrationError('not_found', 'Run not found') + } + + const context = await resolveActiveWorkflowApplicationContext({ + workflowId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return { ...context, runId: input.runId } +} diff --git a/apps/sim/lib/workflows/application/create-workflow.ts b/apps/sim/lib/workflows/application/create-workflow.ts new file mode 100644 index 00000000000..eeac83f1b40 --- /dev/null +++ b/apps/sim/lib/workflows/application/create-workflow.ts @@ -0,0 +1,77 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration' + +const logger = createLogger('CreateWorkflow') + +export interface CreateWorkflowInput { + workspaceId: string + name: string + description?: string | null + folderPath?: string +} + +export const createWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.create, + resolveContext: ({ input }: { input: CreateWorkflowInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + try { + await assertFolderMutable(resolution.folderId) + } catch (error) { + if (error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const transition = await performCreateWorkflowTransition({ + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + name: input.name, + description: input.description, + folderId: resolution.folderId, + }) + requireWorkflowTransition(transition, 'Failed to create workflow') + if (!transition.workflow) throw new Error('Successful workflow create returned no workflow') + + logger.info('Created workflow', { + workspaceId: context.workspaceId, + workflowId: transition.workflow.id, + principalKind: principal.kind, + }) + return { + workflow: transition.workflow, + folderPath: workflowFolderPathForId(resolution.index, transition.workflow.folderId), + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflow.id, + resourceName: result.workflow.name, + description: `Created workflow "${result.workflow.name}"`, + metadata: { + name: result.workflow.name, + description: result.workflow.description || undefined, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId || undefined, + sortOrder: result.workflow.sortOrder, + }, + }), +}) diff --git a/apps/sim/lib/workflows/application/delete-workflow.ts b/apps/sim/lib/workflows/application/delete-workflow.ts new file mode 100644 index 00000000000..f2a41746cc4 --- /dev/null +++ b/apps/sim/lib/workflows/application/delete-workflow.ts @@ -0,0 +1,69 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { deleteWorkflowRecord } from '@/lib/workflows/orchestration' + +const logger = createLogger('DeleteWorkflow') + +export interface DeleteWorkflowInput { + workflowId: string + assertedWorkspaceId?: string +} + +export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.delete, + resolveContext: ({ principal, input }: { principal: Principal; input: DeleteWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, context }) { + try { + await assertWorkflowMutable(context.workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const transition = await deleteWorkflowRecord({ + workflowId: context.workflowId, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + }) + requireWorkflowTransition(transition, 'Failed to delete workflow') + if (!transition.workflow) throw new Error('Successful workflow delete returned no workflow') + + logger.info('Deleted workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + archived: transition.archived, + principalKind: principal.kind, + }) + return { + workflowId: context.workflowId, + workflowName: transition.workflow.name, + archived: transition.archived === true, + } + }, + projectAudit: ({ result }) => + result.archived + ? { + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `Archived workflow "${result.workflowName}"`, + metadata: { archived: true }, + } + : [], +}) diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts new file mode 100644 index 00000000000..bffda5ba2b7 --- /dev/null +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -0,0 +1,170 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + performActivateVersion, + performFullDeploy, + performFullUndeploy, +} from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' + +export interface DeployWorkflowInput { + workflowId: string + name?: string + description?: string + requestId: string + idempotencyKey?: string +} + +export interface UndeployWorkflowInput { + workflowId: string + requestId: string +} + +export interface ActivateWorkflowVersionInput { + workflowId: string + version?: number + transition: 'activate' | 'rollback' + requestId: string + idempotencyKey?: string +} + +function throwDeploymentFailure( + result: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): never { + if (!result.errorCode || result.errorCode === 'internal') { + throw new Error(fallback) + } + throw new OrchestrationError(result.errorCode, result.error ?? fallback) +} + +async function requireMutableWorkflow(workflowId: string): Promise { + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} + +export const deployWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deploy, + resolveContext: ({ input }: { input: DeployWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ principal, input, context }) { + await requireMutableWorkflow(context.workflowId) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performFullDeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + captureAnalytics: false, + versionName: input.name, + versionDescription: input.description, + requestId: input.requestId, + idempotencyKey: input.idempotencyKey, + }) + if (!result.success) throwDeploymentFailure(result, 'Failed to deploy workflow') + return { + ...result, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + } + }, +}) + +export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.undeploy, + resolveContext: ({ input }: { input: UndeployWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ principal, input, context }) { + if (!context.workflow.isDeployed) { + throw new OrchestrationError('validation', 'Workflow is not deployed') + } + await requireMutableWorkflow(context.workflowId) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performFullUndeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + projectLegacyAudit: false, + requestId: input.requestId, + }) + if (!result.success) throw new Error('Failed to undeploy workflow') + return { + ...result, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + workflowName: context.workflow.name, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_UNDEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `Undeployed workflow "${result.workflowName}"`, + }), +}) + +export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.activateVersion, + resolveContext: ({ input }: { input: ActivateWorkflowVersionInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ principal, input, context }) { + if (input.transition === 'rollback' && !context.workflow.isDeployed) { + throw new OrchestrationError('validation', 'Workflow is not deployed') + } + await requireMutableWorkflow(context.workflowId) + + let targetVersion = input.version + if (targetVersion === undefined) { + if (input.transition !== 'rollback') { + throw new OrchestrationError('validation', 'Version is required for activation') + } + const previous = await findPreviousDeploymentVersion(context.workflowId) + if (!previous.ok) { + throw new OrchestrationError( + 'validation', + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + ) + } + targetVersion = previous.version + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performActivateVersion({ + workflowId: context.workflowId, + version: targetVersion, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + captureAnalytics: false, + requestId: input.requestId, + idempotencyKey: input.idempotencyKey, + }) + if (!result.success) throwDeploymentFailure(result, 'Failed to activate workflow version') + return { + ...result, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + version: targetVersion, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/execute-workflow.test.ts b/apps/sim/lib/workflows/application/execute-workflow.test.ts new file mode 100644 index 00000000000..26f53714618 --- /dev/null +++ b/apps/sim/lib/workflows/application/execute-workflow.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + executeService: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/executor/execute-service', () => ({ + executeWorkflowService: mocks.executeService, +})) + +import { PersonalApiKeysDisabledError } from '@/lib/core/application' +import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' + +const workflow = { id: 'workflow-1', userId: 'owner-1', workspaceId: 'workspace-1' } +const workflowContext = { + workflowId: 'workflow-1', + workflow, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const baseInput = { + workflowId: 'workflow-1', + requestId: 'request-1', + input: { hello: 'world' }, + mode: 'sync' as const, + requestHeaders: new Headers(), +} + +describe('executeWorkflowOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.executeService.mockResolvedValue({ + ok: true, + executionId: 'run-1', + workflowId: 'workflow-1', + status: 'completed', + aborted: null, + output: {}, + error: null, + hasResponseBlock: false, + }) + }) + + it.each([ + { + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as Principal, + actorUserId: 'user-1', + authenticatesCredentials: true, + }, + { + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'personal-key', + } as Principal, + actorUserId: 'user-1', + authenticatesCredentials: true, + }, + { + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + } as Principal, + actorUserId: 'billing-owner-1', + authenticatesCredentials: false, + }, + { + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + } as Principal, + actorUserId: 'user-1', + authenticatesCredentials: true, + }, + ])( + 'derives execution actor and credential policy from $principal.kind', + async ({ principal, actorUserId, authenticatesCredentials }) => { + await executeWorkflowOperation.execute({ principal, input: baseInput }) + + expect(mocks.executeService).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + userId: actorUserId, + workflowRecord: workflow, + triggerType: 'api', + rateLimitCounter: 'sync', + useAuthenticatedUserAsActor: authenticatesCredentials, + }) + ) + } + ) + + it('uses the async execution quota bucket without performing request-rate limiting', async () => { + await executeWorkflowOperation.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + input: { ...baseInput, mode: 'async', requestedTimeoutSeconds: 600 }, + }) + + expect(mocks.executeService).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'async', requestedTimeoutSeconds: 600 }) + ) + }) + + it('rejects a personal key disabled by canonical workspace policy', async () => { + mocks.resolveWorkflowContext.mockResolvedValueOnce({ + ...workflowContext, + allowPersonalApiKeys: false, + }) + + await expect( + executeWorkflowOperation.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'personal-key' }, + input: baseInput, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.executeService).not.toHaveBeenCalled() + }) + + it('passes through execution infrastructure failures', async () => { + const infrastructureError = new Error('queue unavailable') + mocks.executeService.mockRejectedValueOnce(infrastructureError) + + await expect( + executeWorkflowOperation.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + input: baseInput, + }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/workflows/application/execute-workflow.ts b/apps/sim/lib/workflows/application/execute-workflow.ts new file mode 100644 index 00000000000..aeee3f2de65 --- /dev/null +++ b/apps/sim/lib/workflows/application/execute-workflow.ts @@ -0,0 +1,69 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + type ExecuteWorkflowServiceResult, + executeWorkflowService, +} from '@/lib/workflows/executor/execute-service' + +export interface ExecuteWorkflowInput { + workflowId: string + requestId: string + input: unknown + executionId?: string + includeFileBase64?: boolean + base64MaxBytes?: number + selectedOutputs?: string[] + requestedTimeoutSeconds?: number + abortSignal?: AbortSignal + mode: 'sync' | 'async' | 'stream' + requestHeaders: Headers + includeThinking?: boolean + includeToolCalls?: boolean +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +function authenticatesExecutionCredentials(principal: Principal): boolean { + return principal.kind !== 'workspace_api_key' +} + +export const executeWorkflowOperation = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.execute, + resolveContext: ({ principal, input }: { principal: Principal; input: ExecuteWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ principal, context, input }): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return executeWorkflowService({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + input: input.input, + triggerType: 'api', + requestId: input.requestId, + executionId: input.executionId, + useAuthenticatedUserAsActor: authenticatesExecutionCredentials(principal), + workflowRecord: context.workflow, + includeFileBase64: input.includeFileBase64, + base64MaxBytes: input.base64MaxBytes, + selectedOutputs: input.selectedOutputs, + rateLimitCounter: input.mode === 'async' ? 'async' : 'sync', + requestedTimeoutSeconds: input.requestedTimeoutSeconds, + abortSignal: input.abortSignal, + mode: input.mode, + requestHeaders: input.requestHeaders, + includeThinking: input.includeThinking, + includeToolCalls: input.includeToolCalls, + }) + }, +}) diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts new file mode 100644 index 00000000000..91c03bf3e83 --- /dev/null +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolveWorkflow: vi.fn(), + resolvePermission: vi.fn(), + importTransition: vi.fn(), + buildExport: vi.fn(), + folderLock: vi.fn(), + loadIndex: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflow, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_EXPORTED: 'workflow.exported', + }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/folders/locks', () => ({ + withFolderTreeLock: mocks.folderLock, +})) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadIndex, + resolveFolderPathFromIndex: (index: { idByPath: Map }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) +vi.mock('@/lib/workflows/operations/import-workflow', () => ({ + importWorkflowIntoWorkspaceTransition: mocks.importTransition, +})) +vi.mock('@/lib/workflows/operations/export-workflow', () => ({ + buildWorkflowExportPayload: mocks.buildExport, +})) + +import { exportWorkflow, importWorkflow } from '@/lib/workflows/application/import-export' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' + +const workspaceContext = { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const workflowRecord = { + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'ws-1', + folderId: 'folder-1', + sortOrder: 0, + name: 'Reports', + description: null, + variables: {}, +} +const folderIndex = { + rowById: new Map(), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), +} +const imported = { + id: 'workflow-2', + name: 'Imported', + description: null, + workspaceId: 'ws-1', + folderId: 'folder-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const exportPayload = { + version: '1.0' as const, + exportedAt: '2026-01-01T00:00:00.000Z', + workflow: { + id: 'workflow-1', + name: 'Reports', + description: null, + workspaceId: 'ws-1', + folderId: 'folder-1', + }, + state: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + metadata: { name: 'Reports', exportedAt: '2026-01-01T00:00:00.000Z' }, + }, +} + +describe('workflow import and export application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolveWorkflow.mockResolvedValue({ + ...workspaceContext, + workflowId: 'workflow-1', + workflow: workflowRecord, + }) + mocks.resolvePermission.mockResolvedValue('write') + mocks.folderLock.mockImplementation( + async ( + _workspaceId: string, + _resourceType: string, + callback: (tx: Record) => unknown + ) => callback({}) + ) + mocks.loadIndex.mockResolvedValue(folderIndex) + mocks.importTransition.mockResolvedValue({ success: true, workflow: imported }) + mocks.buildExport.mockResolvedValue(exportPayload) + }) + + it('imports through the unaudited transition and projects one semantic audit', async () => { + const result = await importWorkflow.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'key-1' }, + input: { + workspaceId: 'ws-1', + folderPath: '/Reports', + workflow: { blocks: {}, edges: [] }, + }, + }) + + expect(result).toEqual({ workflow: imported, folderPath: '/Reports' }) + expect(mocks.importTransition).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + folderId: 'folder-1', + userId: 'owner-1', + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + operation: 'workflows.import', + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'ws-1' }, + }), + }) + ) + }) + + it('preserves classified import details and does not audit a failure', async () => { + mocks.importTransition.mockResolvedValue({ + success: false, + status: 400, + error: 'Invalid workflow state', + details: [{ path: ['blocks'] }], + }) + + const error = await importWorkflow + .execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: 'ws-1', workflow: { blocks: null } }, + }) + .catch((failure: unknown) => failure) + + expect(error).toBeInstanceOf(WorkflowImportError) + expect(error).toMatchObject({ + code: 'validation', + details: [{ path: ['blocks'] }], + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('exports from the canonical workflow context and audits authoritative counts', async () => { + const result = await exportWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workflowId: 'workflow-1' }, + }) + + expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) + expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord) + expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow') + expect(mocks.folderLock).not.toHaveBeenCalled() + expect(result).toEqual({ payload: exportPayload, folderPath: '/Reports' }) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'workflow-1', + metadata: expect.objectContaining({ blocksCount: 0, edgesCount: 0 }), + }) + ) + }) + + it('propagates export infrastructure failures without audit', async () => { + const failure = new Error('storage unavailable') + mocks.buildExport.mockRejectedValueOnce(failure) + + await expect( + exportWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1' }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts new file mode 100644 index 00000000000..88692f71514 --- /dev/null +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -0,0 +1,128 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + resolveActiveWorkflowApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' +import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' +import { + type ImportedWorkflow, + importWorkflowIntoWorkspaceTransition, +} from '@/lib/workflows/operations/import-workflow' + +export interface ImportWorkflowInput { + workspaceId: string + folderPath?: string + name?: string + description?: string + workflow: string | Record +} + +export interface ImportWorkflowResult { + workflow: ImportedWorkflow + folderPath: string +} + +export interface ExportWorkflowInput { + workflowId: string +} + +export interface ExportWorkflowResult { + payload: V1WorkflowExportPayload + folderPath: string +} + +function importErrorCode(status: number): OrchestrationErrorCode { + if (status === 400) return 'validation' + if (status === 404) return 'not_found' + if (status === 409) return 'conflict' + if (status === 423) return 'locked' + return 'internal' +} + +export const importWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.import, + resolveContext: ({ input }: { input: ImportWorkflowInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await importWorkflowIntoWorkspaceTransition({ + workspaceId: context.workspaceId, + folderId: resolution.folderId ?? undefined, + name: input.name, + description: input.description, + workflow: input.workflow, + userId: attribution.attributedUserId, + requestId: generateRequestId(), + }) + if (!result.success) { + throw new WorkflowImportError(importErrorCode(result.status), result.error, result.details) + } + return { + workflow: result.workflow, + folderPath: workflowFolderPathForId(resolution.index, result.workflow.folderId), + } + }, + projectAudit({ result }) { + return { + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflow.id, + resourceName: result.workflow.name, + description: `Created workflow "${result.workflow.name}"`, + metadata: { + name: result.workflow.name, + description: result.workflow.description || undefined, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId || undefined, + sortOrder: result.workflow.sortOrder, + }, + } + }, +}) + +export const exportWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.export, + resolveContext: ({ input }: { input: ExportWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ context }): Promise { + const payload = await buildWorkflowExportPayload(context.workflow) + if (!payload) throw new OrchestrationError('not_found', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + return { + payload, + folderPath: workflowFolderPathForId(folderIndex, context.workflow.folderId), + } + }, + projectAudit({ context, result }) { + return { + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflow.id, + resourceName: context.workflow.name, + description: `Exported workflow "${context.workflow.name}" via the API`, + metadata: { + workspaceId: context.workspaceId, + folderPath: result.folderPath, + blocksCount: Object.keys(result.payload.state.blocks).length, + edgesCount: result.payload.state.edges.length, + }, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.ts b/apps/sim/lib/workflows/application/list-workflow-runs.ts new file mode 100644 index 00000000000..6fd4ba4ab04 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflow-runs.ts @@ -0,0 +1,40 @@ +import type { Principal } from '@sim/auth/principal' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + type ListWorkflowExecutionsInput, + listWorkflowExecutions, +} from '@/lib/workflows/executor/execution-queries' + +export interface ListWorkflowRunsInput extends Omit { + workflowId: string +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listRuns, + resolveContext: ({ principal, input }: { principal: Principal; input: ListWorkflowRunsInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ context, input }) { + const result = await listWorkflowExecutions({ + workflowId: context.workflowId, + status: input.status, + trigger: input.trigger, + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + cursor: input.cursor, + order: input.order, + }) + return { ...result, workflowId: context.workflowId, order: input.order } + }, +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-versions.ts b/apps/sim/lib/workflows/application/list-workflow-versions.ts new file mode 100644 index 00000000000..86748c0c090 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflow-versions.ts @@ -0,0 +1,46 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { listWorkflowVersions as listStoredWorkflowVersions } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('ListWorkflowVersions') + +export interface ListWorkflowVersionsInput { + workflowId: string + assertedWorkspaceId?: string + limit?: number + afterVersion?: number +} + +export const listWorkflowVersions = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listVersions, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ListWorkflowVersionsInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const { versions } = await listStoredWorkflowVersions(context.workflowId, { + limit: input.limit === undefined ? undefined : input.limit + 1, + afterVersion: input.afterVersion, + }) + const hasMore = input.limit !== undefined && versions.length > input.limit + const page = input.limit === undefined ? versions : versions.slice(0, input.limit) + logger.info('Listed workflow versions', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + count: page.length, + principalKind: principal.kind, + }) + return { versions: page, hasMore } + }, +}) diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts new file mode 100644 index 00000000000..f278c9317d5 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { + InvalidWorkflowListCursorError, + listWorkspaceWorkflows, + type WorkflowSortBy, + type WorkflowSortOrder, +} from '@/lib/workflows/queries' + +const logger = createLogger('ListWorkflows') + +export interface ListWorkflowsInput { + workspaceId: string + folderPath?: string + deployedOnly: boolean + search?: string + sortBy: WorkflowSortBy + sortOrder: WorkflowSortOrder + cursorKeys?: CursorKey[] + limit: number +} + +export const listWorkflows = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.list, + resolveContext: ({ input }: { input: ListWorkflowsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderId = + input.folderPath === undefined + ? undefined + : input.folderPath === '/' + ? null + : folderIndex.idByPath.get(input.folderPath) + if (input.folderPath !== undefined && folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + let page + try { + page = await listWorkspaceWorkflows({ + workspaceId: context.workspaceId, + folderId, + deployedOnly: input.deployedOnly, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + cursorKeys: input.cursorKeys, + limit: input.limit, + }) + } catch (error) { + if (error instanceof InvalidWorkflowListCursorError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + logger.info('Listed workflows', { + workspaceId: context.workspaceId, + count: page.data.length, + principalKind: principal.kind, + }) + return { + workflows: page.data.map((workflow) => ({ + ...workflow, + workspaceId: workflow.workspaceId ?? context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, workflow.folderId), + })), + nextCursorKeys: page.nextCursorKeys, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts new file mode 100644 index 00000000000..08f6399352e --- /dev/null +++ b/apps/sim/lib/workflows/application/operations.ts @@ -0,0 +1,141 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_WORKFLOW_PRINCIPALS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const + +const HUMAN_WORKFLOW_PRINCIPALS = ['session', 'personal_api_key', 'delegated'] as const + +export const workflowOperations = { + list: defineWorkspaceOperation({ + id: 'workflows.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + read: defineWorkspaceOperation({ + id: 'workflows.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + create: defineWorkspaceOperation({ + id: 'workflows.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + update: defineWorkspaceOperation({ + id: 'workflows.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + delete: defineWorkspaceOperation({ + id: 'workflows.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + listFolders: defineWorkspaceOperation({ + id: 'workflows.folders.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + createFolder: defineWorkspaceOperation({ + id: 'workflows.folders.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + relocateFolder: defineWorkspaceOperation({ + id: 'workflows.folders.relocate', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + deleteFolder: defineWorkspaceOperation({ + id: 'workflows.folders.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + deploy: defineWorkspaceOperation({ + id: 'workflows.deploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + }), + undeploy: defineWorkspaceOperation({ + id: 'workflows.undeploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + }), + activateVersion: defineWorkspaceOperation({ + id: 'workflows.versions.activate', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + }), + listVersions: defineWorkspaceOperation({ + id: 'workflows.versions.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + readVersion: defineWorkspaceOperation({ + id: 'workflows.versions.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + export: defineWorkspaceOperation({ + id: 'workflows.export', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + import: defineWorkspaceOperation({ + id: 'workflows.import', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + execute: defineWorkspaceOperation({ + id: 'workflows.execute', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + listRuns: defineWorkspaceOperation({ + id: 'workflows.runs.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + readRun: defineWorkspaceOperation({ + id: 'workflows.runs.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + cancelRun: defineWorkspaceOperation({ + id: 'workflows.runs.cancel', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + resumeRun: defineWorkspaceOperation({ + id: 'workflows.runs.resume', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), +} as const + +export type WorkflowOperation = (typeof workflowOperations)[keyof typeof workflowOperations] diff --git a/apps/sim/lib/workflows/application/principal-scope.ts b/apps/sim/lib/workflows/application/principal-scope.ts new file mode 100644 index 00000000000..d5e70577ee9 --- /dev/null +++ b/apps/sim/lib/workflows/application/principal-scope.ts @@ -0,0 +1,11 @@ +import type { Principal } from '@sim/auth/principal' + +export function assertedWorkflowWorkspaceId( + principal: Principal, + assertedWorkspaceId?: string +): string | undefined { + return ( + assertedWorkspaceId ?? + (principal.kind === 'workspace_api_key' ? principal.workspaceId : undefined) + ) +} diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts new file mode 100644 index 00000000000..baa9de4de4c --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -0,0 +1,50 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, + FunctionalOutputsUnavailableError, +} from '@/lib/logs/execution/functional-outputs' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +export interface ReadWorkflowRunInput { + workflowId: string + runId: string + includeOutput: boolean + selectedOutputs: string[] +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readRun, + resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowRunInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ context, input }) { + try { + const status = await getWorkflowExecutionStatus({ + workflowId: context.workflowId, + executionId: context.runId, + includeOutput: input.includeOutput, + selectedOutputs: input.selectedOutputs, + }) + if (!status) throw new OrchestrationError('not_found', 'Run not found') + return status + } catch (error) { + if (error instanceof FunctionalOutputsUnavailableError) { + throw new OrchestrationError('conflict', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) + } + throw error + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts new file mode 100644 index 00000000000..bec3ac131af --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -0,0 +1,44 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('ReadWorkflowVersion') + +export interface ReadWorkflowVersionInput { + workflowId: string + assertedWorkspaceId?: string + version: number +} + +export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readVersion, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkflowVersionInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const version = await getWorkflowDeploymentVersion(context.workflowId, input.version) + if (!version?.state) { + throw new OrchestrationError('not_found', 'Deployment version not found') + } + logger.info('Read workflow version', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + version: input.version, + principalKind: principal.kind, + }) + return { version } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow.ts b/apps/sim/lib/workflows/application/read-workflow.ts new file mode 100644 index 00000000000..366ba20b2e0 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow.ts @@ -0,0 +1,47 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' + +const logger = createLogger('ReadWorkflow') + +export interface ReadWorkflowInput { + workflowId: string + assertedWorkspaceId?: string +} + +export const readWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, context }) { + const snapshot = await loadWorkflowReadSnapshot(context.workflowId) + const workflow = snapshot.workflowRecord + if (!workflow || workflow.archivedAt || workflow.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) + logger.info('Read workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + principalKind: principal.kind, + }) + return { + workflow, + workspaceId: context.workspaceId, + inputs, + folderPath: workflowFolderPathForId(folderIndex, workflow.folderId), + } + }, +}) diff --git a/apps/sim/lib/workflows/application/resume-run.ts b/apps/sim/lib/workflows/application/resume-run.ts new file mode 100644 index 00000000000..08f9a94cc78 --- /dev/null +++ b/apps/sim/lib/workflows/application/resume-run.ts @@ -0,0 +1,45 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { executeResumeWorkflow } from '@/lib/workflows/executor/resume-execution' + +export interface ResumeWorkflowRunInput { + workflowId: string + runId: string + contextId: string + resumeInput: unknown +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const resumeWorkflowRun = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.resumeRun, + resolveContext: ({ principal, input }: { principal: Principal; input: ResumeWorkflowRunInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return executeResumeWorkflow({ + workflowId: context.workflowId, + executionId: context.runId, + contextId: input.contextId, + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + resumeInput: input.resumeInput, + isApiCaller: true, + pollingSurface: 'v2', + allowStreaming: false, + }) + }, +}) diff --git a/apps/sim/lib/workflows/application/transition-result.ts b/apps/sim/lib/workflows/application/transition-result.ts new file mode 100644 index 00000000000..8ad59eceafd --- /dev/null +++ b/apps/sim/lib/workflows/application/transition-result.ts @@ -0,0 +1,8 @@ +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' + +export function requireWorkflowTransition< + T extends { success: boolean; error?: string; errorCode?: OrchestrationErrorCode }, +>(result: T, fallbackMessage: string): asserts result is T & { success: true } { + if (result.success) return + throw new OrchestrationError(result.errorCode ?? 'internal', result.error ?? fallbackMessage) +} diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts new file mode 100644 index 00000000000..bea4570979e --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -0,0 +1,89 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { updateWorkflowRecord } from '@/lib/workflows/orchestration' + +const logger = createLogger('UpdateWorkflow') + +export interface UpdateWorkflowInput { + workflowId: string + assertedWorkspaceId?: string + name?: string + description?: string | null + folderPath?: string +} + +export const updateWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.update, + resolveContext: ({ principal, input }: { principal: Principal; input: UpdateWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const resolution = + input.folderPath === undefined + ? undefined + : await resolveWorkflowFolderPath(context.workspaceId, input.folderPath) + + try { + await assertWorkflowMutable(context.workflowId) + if (resolution) await assertFolderMutable(resolution.folderId) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const transition = await updateWorkflowRecord({ + workflowId: context.workflowId, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + workspaceId: context.workspaceId, + currentName: context.workflow.name, + currentFolderId: context.workflow.folderId, + name: input.name, + description: input.description, + folderId: resolution?.folderId, + }) + requireWorkflowTransition(transition, 'Failed to update workflow') + if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') + + const folderIndex = + resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'workflow')) + logger.info('Updated workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + principalKind: principal.kind, + }) + return { + workflow: transition.workflow, + workspaceId: context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, transition.workflow.folderId), + deployment: { + isDeployed: context.workflow.isDeployed, + deployedAt: context.workflow.deployedAt, + runCount: context.workflow.runCount, + lastRunAt: context.workflow.lastRunAt, + }, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts new file mode 100644 index 00000000000..64930d7bce7 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspaceContext: vi.fn(), + resolveWorkflowContext: vi.fn(), + resolveFolderPath: vi.fn(), + folderPathForId: vi.fn(), + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + createTransition: vi.fn(), + updateRecord: vi.fn(), + deleteRecord: vi.fn(), + listRows: vi.fn(), + loadSnapshot: vi.fn(), + loadFolderIndex: vi.fn(), + listVersions: vi.fn(), + readVersion: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_DELETED: 'workflow.deleted', + }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, + FolderLockedError: class FolderLockedError extends Error {}, + WorkflowLockedError: class WorkflowLockedError extends Error {}, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspaceContext, + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/application/workflow-folders', () => ({ + resolveWorkflowFolderPath: mocks.resolveFolderPath, + workflowFolderPathForId: mocks.folderPathForId, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflowTransition: mocks.createTransition, + updateWorkflowRecord: mocks.updateRecord, + deleteWorkflowRecord: mocks.deleteRecord, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolderIndex, +})) + +vi.mock('@/lib/workflows/queries', () => ({ + InvalidWorkflowListCursorError: class InvalidWorkflowListCursorError extends Error {}, + listWorkspaceWorkflows: mocks.listRows, + loadWorkflowReadSnapshot: mocks.loadSnapshot, +})) + +vi.mock('@/lib/workflows/input-format', () => ({ + extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: mocks.listVersions, + getWorkflowDeploymentVersion: mocks.readVersion, +})) + +import { createWorkflow } from '@/lib/workflows/application/create-workflow' +import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' +import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' +import { readWorkflow } from '@/lib/workflows/application/read-workflow' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' +const now = new Date('2026-08-01T00:00:00.000Z') +const workflowRecord = { + id: WORKFLOW_ID, + userId: 'owner-1', + workspaceId: WORKSPACE_ID, + folderId: null, + name: 'Daily digest', + description: null, + variables: {}, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + archivedAt: null, + createdAt: now, + updatedAt: now, +} +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const workflowContext = { + ...workspaceContext, + workflowId: WORKFLOW_ID, + workflow: workflowRecord, +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', +} + +describe('authorized workflow CRUD and version reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.resolveFolderPath.mockResolvedValue({ folderId: null, index: {} }) + mocks.folderPathForId.mockReturnValue('/') + mocks.loadFolderIndex.mockResolvedValue({}) + mocks.createTransition.mockResolvedValue({ + success: true, + workflow: { + id: WORKFLOW_ID, + name: workflowRecord.name, + description: null, + workspaceId: WORKSPACE_ID, + folderId: null, + sortOrder: 0, + createdAt: now, + updatedAt: now, + subBlockValues: {}, + }, + }) + mocks.loadSnapshot.mockResolvedValue({ workflowRecord, normalizedData: { blocks: {} } }) + mocks.deleteRecord.mockResolvedValue({ + success: true, + archived: true, + workflow: { id: WORKFLOW_ID, name: workflowRecord.name, workspaceId: WORKSPACE_ID }, + }) + mocks.listVersions.mockResolvedValue({ versions: [] }) + mocks.readVersion.mockResolvedValue({ + id: 'version-1', + version: 1, + name: null, + description: null, + isActive: true, + createdAt: now, + state: { blocks: {}, edges: [], loops: {}, parallels: {}, version: '1.0' }, + }) + }) + + it('creates for a personal key and projects one authoritative semantic audit', async () => { + await expect( + createWorkflow.execute({ + principal: personalPrincipal, + input: { workspaceId: WORKSPACE_ID, name: workflowRecord.name }, + }) + ).resolves.toMatchObject({ workflow: { id: WORKFLOW_ID } }) + + expect(mocks.createTransition).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: WORKSPACE_ID }) + ) + expect(mocks.recordAudit).toHaveBeenCalledTimes(1) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + action: 'workflow.created', + resourceId: WORKFLOW_ID, + metadata: expect.objectContaining({ + operation: 'workflows.create', + actor: expect.objectContaining({ kind: 'personal_api_key', keyId: 'personal-key-1' }), + }), + }) + ) + }) + + it('uses the billing owner only for the workspace key legacy user column', async () => { + await createWorkflow.execute({ + principal: workspacePrincipal, + input: { workspaceId: WORKSPACE_ID, name: workflowRecord.name }, + }) + + expect(mocks.createTransition).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'billing-owner-1' }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'workspace-key-1', + workspaceId: WORKSPACE_ID, + }, + }), + }) + ) + }) + + it('propagates infrastructure failure without projecting audit', async () => { + const failure = new Error('database unavailable') + mocks.createTransition.mockRejectedValue(failure) + + await expect( + createWorkflow.execute({ + principal: workspacePrincipal, + input: { workspaceId: WORKSPACE_ID, name: workflowRecord.name }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('binds workspace keys to canonical workflow scope before protected reads', async () => { + mocks.resolveWorkflowContext.mockRejectedValue(new Error('canonical mismatch')) + + await expect( + readWorkflow.execute({ + principal: { ...workspacePrincipal, workspaceId: 'workspace-other' }, + input: { workflowId: WORKFLOW_ID }, + }) + ).rejects.toThrow('canonical mismatch') + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + assertedWorkspaceId: 'workspace-other', + }) + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) + + it('does not audit an authoritative delete no-op', async () => { + mocks.deleteRecord.mockResolvedValue({ + success: true, + archived: false, + workflow: { id: WORKFLOW_ID, name: workflowRecord.name, workspaceId: WORKSPACE_ID }, + }) + + await deleteWorkflow.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('supports bounded v2 and unbounded internal version listing', async () => { + await listWorkflowVersions.execute({ + principal: workspacePrincipal, + input: { workflowId: WORKFLOW_ID, limit: 50 }, + }) + expect(mocks.listVersions).toHaveBeenLastCalledWith(WORKFLOW_ID, { + limit: 51, + afterVersion: undefined, + }) + + await expect( + listWorkflowVersions.execute({ + principal: workspacePrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + ).resolves.toEqual({ versions: [], hasMore: false }) + expect(mocks.listVersions).toHaveBeenLastCalledWith(WORKFLOW_ID, { + limit: undefined, + afterVersion: undefined, + }) + }) + + it('reads one version only after canonical workflow authorization', async () => { + await expect( + readWorkflowVersion.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, version: 1 }, + }) + ).resolves.toMatchObject({ version: { id: 'version-1', version: 1 } }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.readVersion) + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-deployments.test.ts b/apps/sim/lib/workflows/application/workflow-deployments.test.ts new file mode 100644 index 00000000000..3863d2f1a40 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-deployments.test.ts @@ -0,0 +1,340 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { + class MockWorkflowLockedError extends Error {} + return { + MockWorkflowLockedError, + mocks: { + activate: vi.fn(), + assertMutable: vi.fn(), + audit: vi.fn(), + deploy: vi.fn(), + findPrevious: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + undeploy: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UNDEPLOYED: 'workflow.undeployed' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertWorkflowMutable: mocks.assertMutable, + WorkflowLockedError: MockWorkflowLockedError, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performActivateVersion: mocks.activate, + performFullDeploy: mocks.deploy, + performFullUndeploy: mocks.undeploy, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + findPreviousDeploymentVersion: mocks.findPrevious, +})) + +import { + activateWorkflowVersion, + deployWorkflow, + undeployWorkflow, +} from '@/lib/workflows/application/deployments' + +const workflow = { + id: 'workflow-1', + name: 'Release workflow', + userId: 'owner-1', + workspaceId: 'workspace-1', + isDeployed: true, +} +const context = { + workflowId: 'workflow-1', + workflow, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const adminPrincipals: Array<{ principal: Principal; actorUserId: string }> = [ + { + principal: { kind: 'session', userId: 'session-user', sessionId: 'session-1' }, + actorUserId: 'session-user', + }, + { + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + actorUserId: 'key-user', + }, + { + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'delegated-user', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, + actorUserId: 'delegated-user', + }, +] + +describe('workflow deployment application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.deploy.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-08-08T00:00:00Z'), + version: 4, + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mocks.undeploy.mockResolvedValue({ success: true, warnings: [] }) + mocks.activate.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-08-08T00:01:00Z'), + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mocks.findPrevious.mockResolvedValue({ ok: true, version: 3 }) + }) + + it.each(adminPrincipals)( + 'admits $principal.kind deploys with canonical actor attribution', + async ({ principal, actorUserId }) => { + await deployWorkflow.execute({ + principal, + input: { + workflowId: 'workflow-1', + name: 'Version 4', + description: 'Production release', + requestId: 'request-1', + idempotencyKey: 'deploy-idempotency-1', + }, + }) + + expect(mocks.deploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + userId: actorUserId, + actorId: actorUserId, + captureAnalytics: false, + versionName: 'Version 4', + versionDescription: 'Production release', + requestId: 'request-1', + idempotencyKey: 'deploy-idempotency-1', + }) + ) + expect(mocks.audit).not.toHaveBeenCalled() + } + ) + + it('denies workspace API keys before canonical lookup for admin transitions', async () => { + await expect( + deployWorkflow.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + input: { workflowId: 'workflow-1', requestId: 'request-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + expect(mocks.deploy).not.toHaveBeenCalled() + }) + + it('requires current admin permission before deployment', async () => { + mocks.resolvePermission.mockResolvedValueOnce('write') + + await expect( + deployWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', requestId: 'request-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.assertMutable).not.toHaveBeenCalled() + expect(mocks.deploy).not.toHaveBeenCalled() + }) + + it('undeploys without legacy audit and projects one semantic audit entry', async () => { + await undeployWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + input: { workflowId: 'workflow-1', requestId: 'request-2' }, + }) + + expect(mocks.undeploy).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + userId: 'key-user', + actorId: 'key-user', + projectLegacyAudit: false, + requestId: 'request-2', + }) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'key-user', + action: 'workflow.undeployed', + resourceType: 'workflow', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ operation: 'workflows.undeploy' }), + }) + ) + }) + + it('activates an explicit version with analytics disabled in orchestration', async () => { + await activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 2, + transition: 'activate', + requestId: 'request-3', + idempotencyKey: 'activation-1', + }, + }) + + expect(mocks.findPrevious).not.toHaveBeenCalled() + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + actorId: 'user-1', + captureAnalytics: false, + requestId: 'request-3', + idempotencyKey: 'activation-1', + }) + ) + }) + + it('resolves the previous active version for an implicit rollback', async () => { + const result = await activateWorkflowVersion.execute({ + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + input: { workflowId: 'workflow-1', transition: 'rollback', requestId: 'request-4' }, + }) + + expect(mocks.findPrevious).toHaveBeenCalledWith('workflow-1') + expect(mocks.activate).toHaveBeenCalledWith(expect.objectContaining({ version: 3 })) + expect(result.version).toBe(3) + }) + + it('rejects undeploy and rollback when the canonical workflow is not deployed', async () => { + mocks.resolveContext.mockResolvedValue({ + ...context, + workflow: { ...workflow, isDeployed: false }, + }) + const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + + await expect( + undeployWorkflow.execute({ + principal, + input: { workflowId: 'workflow-1', requestId: 'request-5' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + activateWorkflowVersion.execute({ + principal, + input: { + workflowId: 'workflow-1', + version: 1, + transition: 'rollback', + requestId: 'request-6', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.undeploy).not.toHaveBeenCalled() + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('allows explicit internal activation to redeploy an undeployed workflow', async () => { + mocks.resolveContext.mockResolvedValue({ + ...context, + workflow: { ...workflow, isDeployed: false }, + }) + + await activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 1, + transition: 'activate', + requestId: 'request-activation', + }, + }) + + expect(mocks.findPrevious).not.toHaveBeenCalled() + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1', version: 1 }) + ) + }) + + it('maps lock failures and propagates manager infrastructure failures', async () => { + mocks.assertMutable.mockRejectedValueOnce(new MockWorkflowLockedError('Workflow is locked')) + + await expect( + deployWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', requestId: 'request-7' }, + }) + ).rejects.toMatchObject({ code: 'locked', message: 'Workflow is locked' }) + + const infrastructureError = new Error('deployment manager unavailable') + mocks.activate.mockRejectedValueOnce(infrastructureError) + await expect( + activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 1, + transition: 'activate', + requestId: 'request-8', + }, + }) + ).rejects.toBe(infrastructureError) + }) + + it('does not expose an internal deployment failure message', async () => { + mocks.deploy.mockResolvedValueOnce({ + success: false, + errorCode: 'internal', + error: 'driver connection string', + }) + + await expect( + deployWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', requestId: 'request-9' }, + }) + ).rejects.toThrow('Failed to deploy workflow') + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-folders.test.ts b/apps/sim/lib/workflows/application/workflow-folders.test.ts new file mode 100644 index 00000000000..c779babfd01 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-folders.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + relocate: vi.fn(), + delete: vi.fn(), + loadIndex: vi.fn(), + listRows: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { FOLDER: 'folder' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPathTransition: mocks.create, + deleteFolderByPathTransition: mocks.delete, + relocateFolderByPathTransition: mocks.relocate, +})) +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mocks.listRows, + loadActiveFolderPathIndex: mocks.loadIndex, + resolveFolderPathFromIndex: (index: { idByPath: Map }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) + +import { + createWorkflowFolder, + deleteWorkflowFolder, + listWorkflowFolders, +} from '@/lib/workflows/application/workflow-folders' + +const folder = { + id: 'folder-1', + resourceType: 'workflow' as const, + name: 'Reports', + userId: 'owner-1', + workspaceId: 'ws-1', + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, +} +const index = { + rowById: new Map([[folder.id, folder]]), + pathById: new Map([[folder.id, '/Reports']]), + idByPath: new Map([['/Reports', folder.id]]), +} + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'personal-1' }, + { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'workspace-1' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + }, +] + +describe('workflow folder application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue({ + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('write') + mocks.loadIndex.mockResolvedValue(index) + mocks.create.mockResolvedValue({ success: true, folder, path: '/Reports' }) + mocks.delete.mockResolvedValue({ + success: true, + folderId: folder.id, + folderName: folder.name, + path: '/Reports', + deletedItems: { folders: 1, workflows: 2 }, + }) + }) + + it.each(principals.map((principal) => [principal.kind, principal] as const))( + 'allows the %s principal through canonical workspace authorization', + async (_kind, principal) => { + await createWorkflowFolder.execute({ + principal, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + + expect(mocks.create).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: 'ws-1', + userId: principal.kind === 'workspace_api_key' ? 'owner-1' : 'user-1', + path: '/Reports', + }) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + } + ) + + it('rejects a workspace key outside the canonical workspace before mutation', async () => { + await expect( + createWorkflowFolder.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws-2', keyId: 'workspace-2' }, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.create).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('keeps workspace-key audit attribution non-human', async () => { + await deleteWorkflowFolder.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'workspace-1' }, + input: { workspaceId: 'ws-1', path: '/Reports', recursive: true }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'workspace-1', + workspaceId: 'ws-1', + }, + }), + }) + ) + }) + + it('does not audit a rejected transition', async () => { + mocks.create.mockResolvedValue({ + success: false, + error: 'Folder is locked', + errorCode: 'locked', + }) + + await expect( + createWorkflowFolder.execute({ + principal: principals[0], + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + ).rejects.toMatchObject({ code: 'locked' }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates context infrastructure failures without mutation or audit', async () => { + const failure = new Error('database unavailable') + mocks.resolveContext.mockRejectedValueOnce(failure) + + await expect( + listWorkflowFolders.execute({ + principal: principals[0], + input: { + workspaceId: 'ws-1', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + ).rejects.toBe(failure) + expect(mocks.listRows).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-folders.ts b/apps/sim/lib/workflows/application/workflow-folders.ts new file mode 100644 index 00000000000..c8e71faadf7 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-folders.ts @@ -0,0 +1,246 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { folder } from '@sim/db/schema' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { + createFolderAtPathTransition, + deleteFolderByPathTransition, + relocateFolderByPathTransition, +} from '@/lib/folders/orchestration' +import type { FolderPathIndex } from '@/lib/folders/paths' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { + listActiveFolderRows, + loadActiveFolderPathIndex, + resolveFolderPathFromIndex, +} from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' + +type WorkflowFolderRecord = typeof folder.$inferSelect +type WorkflowFolderIndex = FolderPathIndex + +export interface ListWorkflowFoldersInput { + workspaceId: string + parentPath?: string + search?: string + sortBy: 'name' | 'createdAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' +} + +export interface WorkflowFolderResult { + folder: WorkflowFolderRecord + index: WorkflowFolderIndex +} + +export interface ListWorkflowFoldersResult { + folders: WorkflowFolderRecord[] + index: WorkflowFolderIndex +} + +export interface CreateWorkflowFolderInput { + workspaceId: string + path: string +} + +export interface RelocateWorkflowFolderInput { + workspaceId: string + path: string + destinationPath: string +} + +export interface DeleteWorkflowFolderInput { + workspaceId: string + path: string + recursive: boolean +} + +export interface DeleteWorkflowFolderResult { + path: string + folderId: string + folderName: string + deletedItems: { + folders: number + workflows: number + } +} + +function throwFolderMutationFailure(result: { + error?: string + errorCode?: OrchestrationErrorCode +}): never { + const code = result.errorCode ?? 'internal' + throw new OrchestrationError( + code, + code === 'internal' ? 'Internal server error' : (result.error ?? 'Folder mutation failed') + ) +} + +export async function resolveWorkflowFolderPath( + workspaceId: string, + path: string +): Promise<{ folderId: string | null; index: WorkflowFolderIndex }> { + const resolution = await withFolderTreeLock(workspaceId, 'workflow', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', tx) + const folderId = resolveFolderPathFromIndex(index, path) + return folderId === undefined + ? { found: false as const } + : { found: true as const, folderId, index } + }) + if (!resolution.found) throw new OrchestrationError('not_found', 'Folder not found') + return { folderId: resolution.folderId, index: resolution.index } +} + +export function workflowFolderPathForId( + index: WorkflowFolderIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Workflow references an inactive or missing folder') + return path +} + +export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listFolders, + resolveContext: ({ input }: { input: ListWorkflowFoldersInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ input, context }): Promise { + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const parentId = + input.parentPath === undefined + ? undefined + : resolveFolderPathFromIndex(index, input.parentPath) + if (input.parentPath !== undefined && parentId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const folders = await listActiveFolderRows(context.workspaceId, 'workflow', { + parentId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }) + return { folders, index } + }, +}) + +export const createWorkflowFolder = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.createFolder, + resolveContext: ({ input }: { input: CreateWorkflowFolderInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await createFolderAtPathTransition({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + }) + if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + return { folder: result.folder, index } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created workflow folder "${input.path}"`, + metadata: { path: input.path, folderResourceType: 'workflow' }, + } + }, +}) + +export const relocateWorkflowFolder = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.relocateFolder, + resolveContext: ({ input }: { input: RelocateWorkflowFolderInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await relocateFolderByPathTransition({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + destinationPath: input.destinationPath, + }) + if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + return { folder: result.folder, index } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Moved workflow folder to "${input.destinationPath}"`, + metadata: { + sourcePath: input.path, + destinationPath: input.destinationPath, + folderResourceType: 'workflow', + }, + } + }, +}) + +export const deleteWorkflowFolder = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deleteFolder, + resolveContext: ({ input }: { input: DeleteWorkflowFolderInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await deleteFolderByPathTransition({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + recursive: input.recursive, + }) + if ( + !result.success || + !result.deletedItems || + !result.folderId || + !result.folderName || + !result.path + ) { + throwFolderMutationFailure(result) + } + return { + path: result.path, + folderId: result.folderId, + folderName: result.folderName, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folderId, + resourceName: result.folderName, + description: `Deleted workflow folder "${result.path}"`, + metadata: { + folderResourceType: 'workflow', + path: result.path, + affected: { + workflows: result.deletedItems.workflows, + subfolders: Math.max(result.deletedItems.folders - 1, 0), + }, + }, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/workflow-import-error.ts b/apps/sim/lib/workflows/application/workflow-import-error.ts new file mode 100644 index 00000000000..780d5d4083e --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-import-error.ts @@ -0,0 +1,13 @@ +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export class WorkflowImportError extends OrchestrationError { + constructor( + code: OrchestrationErrorCode, + message: string, + readonly details?: unknown + ) { + super(code, message) + this.name = 'WorkflowImportError' + } +} diff --git a/apps/sim/lib/workflows/application/workflow-run-control.test.ts b/apps/sim/lib/workflows/application/workflow-run-control.test.ts new file mode 100644 index 00000000000..262f99361b6 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-run-control.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockWorkflowExecutionNotFoundError, mocks } = vi.hoisted(() => { + class MockWorkflowExecutionNotFoundError extends Error {} + return { + MockWorkflowExecutionNotFoundError, + mocks: { + audit: vi.fn(), + cancel: vi.fn(), + resolvePermission: vi.fn(), + resolveRunContext: vi.fn(), + resume: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.audit })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowRunApplicationContext: mocks.resolveRunContext, +})) + +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: mocks.cancel, + WorkflowExecutionNotFoundError: MockWorkflowExecutionNotFoundError, +})) + +vi.mock('@/lib/workflows/executor/resume-execution', () => ({ + executeResumeWorkflow: mocks.resume, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run' +import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' + +const runContext = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + runId: 'parent-run-1', +} + +const principals: Array<{ principal: Principal; actorUserId: string }> = [ + { + principal: { kind: 'session', userId: 'session-user', sessionId: 'session-1' }, + actorUserId: 'session-user', + }, + { + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + actorUserId: 'key-user', + }, + { + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + actorUserId: 'billing-owner-1', + }, + { + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'delegated-user', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, + actorUserId: 'delegated-user', + }, +] + +describe('workflow run-control application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveRunContext.mockResolvedValue(runContext) + mocks.cancel.mockResolvedValue({ + success: true, + executionId: 'parent-run-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + mocks.resume.mockResolvedValue({ + kind: 'queued', + executionId: 'resumed-run-2', + queuePosition: 1, + }) + }) + + it.each(principals)( + 'authorizes $principal.kind cancellation in canonical run scope', + async ({ principal, actorUserId }) => { + await cancelWorkflowRun.execute({ + principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'parent-run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: + principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? 'workspace-1' + : undefined, + }) + expect(mocks.cancel).toHaveBeenCalledWith({ + executionId: 'parent-run-1', + workflowId: 'workflow-1', + userId: actorUserId, + workspaceId: 'workspace-1', + captureAnalytics: false, + }) + expect(mocks.audit).not.toHaveBeenCalled() + } + ) + + it.each(principals)( + 'authorizes $principal.kind resume and preserves the parent/new run distinction', + async ({ principal, actorUserId }) => { + const result = await resumeWorkflowRun.execute({ + principal, + input: { + workflowId: 'workflow-1', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: { approved: true }, + }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'parent-run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: + principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? 'workspace-1' + : undefined, + }) + expect(mocks.resume).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + executionId: 'parent-run-1', + contextId: 'context-1', + workspaceId: 'workspace-1', + userId: actorUserId, + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + allowStreaming: false, + }) + expect(result).toMatchObject({ executionId: 'resumed-run-2' }) + expect(mocks.audit).not.toHaveBeenCalled() + } + ) + + it('stops cancellation and resume before authorization when workflow/run scope disagrees', async () => { + mocks.resolveRunContext.mockRejectedValue(new OrchestrationError('not_found', 'Run not found')) + const principal = principals[0].principal + + await expect( + cancelWorkflowRun.execute({ + principal, + input: { workflowId: 'wrong-workflow', runId: 'parent-run-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + resumeWorkflowRun.execute({ + principal, + input: { + workflowId: 'wrong-workflow', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: {}, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.cancel).not.toHaveBeenCalled() + expect(mocks.resume).not.toHaveBeenCalled() + }) + + it('requires current write permission for session cancellation and resume', async () => { + mocks.resolvePermission.mockResolvedValue('read') + const principal = principals[0].principal + + await expect( + cancelWorkflowRun.execute({ + principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + resumeWorkflowRun.execute({ + principal, + input: { + workflowId: 'workflow-1', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: {}, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.cancel).not.toHaveBeenCalled() + expect(mocks.resume).not.toHaveBeenCalled() + }) + + it('maps stale cancellation manager state to semantic absence', async () => { + mocks.cancel.mockRejectedValueOnce(new MockWorkflowExecutionNotFoundError()) + + await expect( + cancelWorkflowRun.execute({ + principal: principals[0].principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Run not found' }) + }) + + it('propagates cancellation and resume infrastructure failures', async () => { + const cancelFailure = new Error('cancellation store unavailable') + const resumeFailure = new Error('resume manager unavailable') + mocks.cancel.mockRejectedValueOnce(cancelFailure) + + await expect( + cancelWorkflowRun.execute({ + principal: principals[2].principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + ).rejects.toBe(cancelFailure) + + mocks.resume.mockRejectedValueOnce(resumeFailure) + await expect( + resumeWorkflowRun.execute({ + principal: principals[2].principal, + input: { + workflowId: 'workflow-1', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: {}, + }, + }) + ).rejects.toBe(resumeFailure) + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts new file mode 100644 index 00000000000..173bbab87ce --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + list: vi.fn(), + resolvePermission: vi.fn(), + resolveRunContext: vi.fn(), + resolveWorkflowContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, + resolveActiveWorkflowRunApplicationContext: mocks.resolveRunContext, +})) + +vi.mock('@/lib/workflows/executor/execution-queries', () => ({ + listWorkflowExecutions: mocks.list, +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mocks.getStatus, +})) + +import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' +import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' +import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' + +const workflowContext = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const runContext = { ...workflowContext, runId: 'run-1' } + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-workspace' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, +] + +describe('workflow run application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.resolveRunContext.mockResolvedValue(runContext) + mocks.list.mockResolvedValue({ data: [], nextCursor: null }) + mocks.getStatus.mockResolvedValue({ + executionId: 'run-1', + workflowId: 'workflow-1', + status: 'completed', + }) + }) + + it.each(principals)( + 'allows $kind to list runs through the canonical workflow', + async (principal) => { + await listWorkflowRuns.execute({ + principal, + input: { workflowId: 'workflow-1', limit: 25, order: 'desc' }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: + principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? 'workspace-1' + : undefined, + }) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1', limit: 25, order: 'desc' }) + ) + } + ) + + it('resolves a run canonically before reading its status', async () => { + await readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: true, + selectedOutputs: ['block-1.value'], + }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.getStatus).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + executionId: 'run-1', + includeOutput: true, + selectedOutputs: ['block-1.value'], + }) + }) + + it('stops before authorization and data access when canonical run scope disagrees', async () => { + mocks.resolveRunContext.mockRejectedValueOnce( + Object.assign(new Error('Run not found'), { code: 'not_found' }) + ) + + await expect( + readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'other-workflow', + runId: 'run-1', + includeOutput: false, + selectedOutputs: [], + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getStatus).not.toHaveBeenCalled() + }) + + it('maps unavailable functional outputs to a semantic conflict', async () => { + mocks.getStatus.mockRejectedValueOnce(new FunctionalOutputsUnavailableError()) + + await expect( + readWorkflowRun.execute({ + principal: principals[0], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: false, + selectedOutputs: ['block-1'], + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('propagates run repository infrastructure failures', async () => { + const infrastructureError = new Error('database unavailable') + mocks.list.mockRejectedValueOnce(infrastructureError) + + await expect( + listWorkflowRuns.execute({ + principal: principals[0], + input: { workflowId: 'workflow-1', limit: 25, order: 'desc' }, + }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index ed58b1d040a..a22152e4421 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' @@ -100,6 +101,8 @@ export interface PrepareDeploymentV2Payload { deploymentVersionId: string version: number userId: string + actor?: PrincipalActor + captureAnalytics?: false requestId: string checkpoints: DeploymentPreparationCheckpoints } @@ -632,6 +635,7 @@ async function emitPostActivationSideEffects(params: { deploymentVersionId: params.payload.deploymentVersionId, version: params.payload.version, previousVersionId: params.operation.previousActiveVersionId || undefined, + ...(params.payload.actor ? { actor: params.payload.actor } : {}), }, }) params.context.signal.throwIfAborted() @@ -640,23 +644,25 @@ async function emitPostActivationSideEffects(params: { if (!params.checkpoints.analyticsCaptured) { params.context.signal.throwIfAborted() - const workspaceId = (params.workflow.workspaceId as string) || '' - const isVersionActivation = params.operation.action === 'activate' - captureServerEvent( - params.payload.userId, - isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', - { - workflow_id: params.payload.workflowId, - workspace_id: workspaceId, - ...(isVersionActivation ? { version: params.payload.version } : {}), - }, - { - groups: workspaceId ? { workspace: workspaceId } : undefined, - ...(isVersionActivation - ? {} - : { setOnce: { first_workflow_deployed_at: new Date().toISOString() } }), - } - ) + if (params.payload.captureAnalytics !== false) { + const workspaceId = (params.workflow.workspaceId as string) || '' + const isVersionActivation = params.operation.action === 'activate' + captureServerEvent( + params.payload.userId, + isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', + { + workflow_id: params.payload.workflowId, + workspace_id: workspaceId, + ...(isVersionActivation ? { version: params.payload.version } : {}), + }, + { + groups: workspaceId ? { workspace: workspaceId } : undefined, + ...(isVersionActivation + ? {} + : { setOnce: { first_workflow_deployed_at: new Date().toISOString() } }), + } + ) + } await params.checkpoint({ analyticsCaptured: true }) } @@ -1314,6 +1320,7 @@ function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2P const deploymentVersionId = parseRequiredString(record.deploymentVersionId, 'deploymentVersionId') const version = parseRequiredPositiveInteger(record.version, 'version') const userId = parseRequiredString(record.userId, 'userId') + const actor = parseOptionalPrincipalActor(record.actor) const requestId = parseRequiredString(record.requestId, 'requestId') const checkpoints = parseDeploymentPreparationCheckpoints(record.checkpoints) @@ -1325,11 +1332,49 @@ function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2P deploymentVersionId, version, userId, + ...(actor ? { actor } : {}), + ...(record.captureAnalytics === false ? { captureAnalytics: false as const } : {}), requestId, checkpoints, } } +function parseOptionalPrincipalActor(value: unknown): PrincipalActor | undefined { + if (value === undefined) return undefined + const record = parsePayloadRecord(value) + const kind = parseRequiredString(record.kind, 'actor.kind') + if (kind === 'session') { + return { kind, userId: parseRequiredString(record.userId, 'actor.userId') } + } + if (kind === 'personal_api_key') { + return { + kind, + keyId: parseRequiredString(record.keyId, 'actor.keyId'), + userId: parseRequiredString(record.userId, 'actor.userId'), + } + } + if (kind === 'workspace_api_key') { + return { + kind, + keyId: parseRequiredString(record.keyId, 'actor.keyId'), + workspaceId: parseRequiredString(record.workspaceId, 'actor.workspaceId'), + } + } + if (kind === 'delegated') { + const serviceId = parseRequiredString(record.serviceId, 'actor.serviceId') + if (serviceId !== 'copilot' && serviceId !== 'executor' && serviceId !== 'realtime') { + throw new Error(`Invalid deployment outbox actor service: ${serviceId}`) + } + return { + kind, + serviceId, + subjectUserId: parseRequiredString(record.subjectUserId, 'actor.subjectUserId'), + delegationId: parseRequiredString(record.delegationId, 'actor.delegationId'), + } + } + throw new Error(`Invalid deployment outbox actor kind: ${kind}`) +} + function parseDeploymentPreparationCheckpoints(value: unknown): DeploymentPreparationCheckpoints { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} const record = value as Record diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index b505539c146..155b385351d 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -692,7 +692,7 @@ export class PauseResumeManager { .limit(1) .then((rows) => rows[0]) - const resumeExecutionId = executionId + const resumeExecutionId = generateId() const now = new Date() if (activeResume) { diff --git a/apps/sim/lib/workflows/executor/resume-execution.ts b/apps/sim/lib/workflows/executor/resume-execution.ts new file mode 100644 index 00000000000..b30ef77cc55 --- /dev/null +++ b/apps/sim/lib/workflows/executor/resume-execution.ts @@ -0,0 +1,418 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { + assertBillingAttributionSnapshot, + type BillingAttributionSnapshot, +} from '@/lib/billing/core/billing-attribution' +import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' +import { toTriggerMaxDurationSeconds } from '@/lib/core/execution-limits' +import { generateRequestId } from '@/lib/core/utils/request' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { RESUME_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' + +const logger = createLogger('WorkflowResumeExecution') + +const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' +const INVALID_PAUSED_ATTRIBUTION_ERROR = + 'Paused execution billing attribution is missing or invalid' +const PAUSED_EXECUTION_BINDING_ERROR = + 'Paused execution snapshot does not match the requested workflow or execution' +const PAUSED_ATTRIBUTION_BINDING_ERROR = + 'Paused execution billing attribution does not match its workspace or actor' + +interface PausedExecutionSnapshotSource { + workflowId: string + executionId: string + executionSnapshot: unknown +} + +interface PausedExecutionSnapshotBinding { + snapshot: ExecutionSnapshot + billingAttribution: BillingAttributionSnapshot +} + +export interface ExecuteResumeWorkflowOptions { + workflowId: string + executionId: string + contextId: string + workspaceId: string + userId: string + resumeInput: unknown + isApiCaller: boolean + pollingSurface: 'legacy' | 'v2' + allowStreaming?: boolean + requestSignal?: AbortSignal + requestHeaders?: Headers +} + +export type ResumeWorkflowExecutionResult = + | { + kind: 'queued' + executionId: string + queuePosition: number + } + | { + kind: 'stream' + executionId: string + stream: ReadableStream + } + | { + kind: 'sync' + executionId: string + success: boolean + status: string + output: unknown + error: unknown + metadata?: { + duration?: number + startTime?: string + endTime?: string + } + } + | { + kind: 'async' + executionId: string + jobId: string + } + | { + kind: 'started' + executionId: string + } + +export class ResumeWorkflowExecutionError extends Error { + constructor( + readonly statusCode: number, + message: string, + readonly safeForPublicApi: boolean + ) { + super(message) + this.name = 'ResumeWorkflowExecutionError' + } +} + +function loadPausedExecutionSnapshot( + pausedExecution: PausedExecutionSnapshotSource, + expected: { workflowId: string; executionId: string; workspaceId: string } +): PausedExecutionSnapshotBinding { + if ( + !isRecordLike(pausedExecution.executionSnapshot) || + typeof pausedExecution.executionSnapshot.snapshot !== 'string' + ) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let snapshot: ExecutionSnapshot + try { + snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) + } catch { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + if (!isRecordLike(snapshot.metadata)) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) + } catch { + throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) + } + + if ( + pausedExecution.workflowId !== expected.workflowId || + pausedExecution.executionId !== expected.executionId || + snapshot.metadata.workflowId !== expected.workflowId || + snapshot.metadata.executionId !== expected.executionId + ) { + throw new Error(PAUSED_EXECUTION_BINDING_ERROR) + } + + if ( + snapshot.metadata.workspaceId !== expected.workspaceId || + billingAttribution.workspaceId !== expected.workspaceId || + snapshot.metadata.userId !== billingAttribution.actorUserId + ) { + throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) + } + + return { snapshot, billingAttribution } +} + +/** Executes a resume transition without coupling application behavior to an HTTP response. */ +export async function executeResumeWorkflow({ + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, + allowStreaming = true, + requestSignal, + requestHeaders, +}: ExecuteResumeWorkflowOptions): Promise { + const requestId = generateRequestId() + const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ + workflowId, + executionId, + }) + if (!pausedExecution) { + throw new ResumeWorkflowExecutionError(404, 'Paused execution not found', true) + } + + let snapshotBinding: PausedExecutionSnapshotBinding + try { + snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { + workflowId, + executionId, + workspaceId, + }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { + workflowId, + executionId, + error: message, + }) + throw new ResumeWorkflowExecutionError(500, message, false) + } + + const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding + const resumeExecutionId = generateId() + + logger.info(`[${requestId}] Preprocessing resume execution`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + userId, + actorUserId: billingAttribution.actorUserId, + }) + + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType: 'manual', + executionId: resumeExecutionId, + requestId, + checkRateLimit: false, + checkDeployment: false, + skipConcurrencyReservation: true, + logPreprocessingErrors: false, + workspaceId, + billingAttribution, + }) + + if (!preprocessResult.success) { + const statusCode = preprocessResult.error?.statusCode || 400 + const message = + preprocessResult.error?.message || 'Failed to validate resume execution. Please try again.' + logger.warn(`[${requestId}] Preprocessing failed for resume`, { + workflowId, + parentExecutionId: executionId, + error: message, + statusCode, + }) + throw new ResumeWorkflowExecutionError(statusCode, message, statusCode < 500) + } + + logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + actorUserId: preprocessResult.actorUserId, + }) + + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId, + workflowId, + contextId, + resumeInput, + userId, + allowedPauseKinds: ['human'], + }) + + if (enqueueResult.status === 'queued') { + return { + kind: 'queued', + executionId: enqueueResult.resumeExecutionId, + queuePosition: enqueueResult.queuePosition, + } + } + + const resumeArgs = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecution: enqueueResult.pausedExecution, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + } + + const persistedExecutionMode = persistedSnapshot.metadata.executionMode ?? 'sync' + const executionMode = isApiCaller + ? persistedExecutionMode === 'stream' && !allowStreaming + ? 'async' + : persistedExecutionMode + : undefined + + if (isApiCaller && executionMode === 'stream') { + if (!requestSignal || !requestHeaders) { + throw new Error('Streaming resume execution requires request signal and headers') + } + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: persistedSnapshot.selectedOutputs, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking: persistedSnapshot.metadata.includeThinking === true, + includeToolCalls: persistedSnapshot.metadata.includeToolCalls === true, + }, + executionId: enqueueResult.resumeExecutionId, + workspaceId, + workflowId, + userId: enqueueResult.userId, + allowLargeValueWorkflowScope: true, + requestSignal, + requestHeaders, + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + PauseResumeManager.startResumeExecution({ + ...resumeArgs, + onStream, + onBlockComplete, + abortSignal, + }), + }) + return { kind: 'stream', executionId: enqueueResult.resumeExecutionId, stream } + } + + if (isApiCaller && executionMode === 'sync') { + const result = await PauseResumeManager.startResumeExecution(resumeArgs) + return { + kind: 'sync', + executionId: enqueueResult.resumeExecutionId, + success: result.success, + status: result.status ?? (result.success ? 'completed' : 'failed'), + output: result.output, + error: result.error, + metadata: result.metadata + ? { + duration: result.metadata.duration, + startTime: result.metadata.startTime, + endTime: result.metadata.endTime, + } + : undefined, + } + } + + if (isApiCaller && executionMode === 'async') { + const correlation: AsyncExecutionCorrelation = { + executionId, + requestId, + source: 'workflow', + workflowId, + triggerType: 'resume', + } + const resumePayload: ResumeExecutionPayload = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecutionId: enqueueResult.pausedExecution.id, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + workflowId, + parentExecutionId: executionId, + executionTimeoutMs: preprocessResult.executionTimeout.async, + billingAttribution: preprocessResult.billingAttribution, + } + + let jobId: string + try { + const jobQueue = await getJobQueue() + const executeInline = shouldExecuteInline() + jobId = await jobQueue.enqueue('resume-execution', resumePayload, { + ...(pollingSurface === 'v2' + ? { jobId: `${RESUME_EXECUTION_JOB_ID_PREFIX}${enqueueResult.resumeEntryId}` } + : {}), + metadata: { + executionId, + workflowId, + workspaceId, + userId, + resumeExecutionId: enqueueResult.resumeExecutionId, + correlation, + }, + maxDurationSeconds: toTriggerMaxDurationSeconds(preprocessResult.executionTimeout.async), + ...(executeInline + ? { + runner: (_queuedPayload: unknown, signal: AbortSignal) => + executeResumeJob(resumePayload, signal), + } + : {}), + }) + logger.info('Enqueued async resume execution', { + jobId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + } catch (error) { + logger.error('Failed to dispatch async resume execution', { + error: toError(error).message, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + await PauseResumeManager.markResumeAttemptFailed({ + resumeEntryId: enqueueResult.resumeEntryId, + pausedExecutionId: enqueueResult.pausedExecution.id, + parentExecutionId: executionId, + contextId: enqueueResult.contextId, + failureReason: 'Failed to queue async resume execution', + }) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) + throw new ResumeWorkflowExecutionError( + 503, + 'Failed to queue resume execution. Please try again.', + true + ) + } + + return { kind: 'async', executionId: enqueueResult.resumeExecutionId, jobId } + } + + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { + logger.error( + 'Failed to start resume execution', + projectResolvedSecretDiagnosticError(error, undefined, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + ) + }) + return { kind: 'started', executionId: enqueueResult.resumeExecutionId } + } catch (error) { + if (error instanceof ResumeWorkflowExecutionError) throw error + logger.error( + 'Resume request failed', + projectResolvedSecretDiagnosticError(error, undefined, { + workflowId, + executionId, + contextId, + }) + ) + const statusCode = + isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : undefined + if (statusCode !== undefined) { + throw new ResumeWorkflowExecutionError(statusCode, toError(error).message, statusCode < 500) + } + throw error + } +} diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 53dede6d709..5103b939c9a 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -17,7 +17,12 @@ import { import { workflowStateSchema } from '@/lib/api/contracts/workflows' import { serializeZodIssues } from '@/lib/api/server' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { + type PerformCreateWorkflowParams, + type PerformCreateWorkflowResult, + performCreateWorkflow, + performCreateWorkflowTransition, +} 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' @@ -64,6 +69,7 @@ export interface ImportedWorkflow { description: string | null workspaceId: string folderId: string | null + sortOrder: number createdAt: Date updatedAt: Date } @@ -166,8 +172,9 @@ function resolveImportedMetadata( * `workspaceId`; this performs only resource-level checks (workspace exists, * folder ownership/lock state). */ -export async function importWorkflowIntoWorkspace( - params: ImportWorkflowParams +async function executeImportWorkflowIntoWorkspace( + params: ImportWorkflowParams, + createWorkflow: (params: PerformCreateWorkflowParams) => Promise ): Promise { const { workspaceId, folderId, userId, requestId } = params @@ -264,7 +271,7 @@ export async function importWorkflowIntoWorkspace( params.description ) - const created = await performCreateWorkflow({ + const created = await createWorkflow({ name, description, workspaceId, @@ -362,8 +369,23 @@ export async function importWorkflowIntoWorkspace( description: created.workflow.description ?? null, workspaceId, folderId: created.workflow.folderId ?? null, + sortOrder: created.workflow.sortOrder, createdAt: created.workflow.createdAt, updatedAt: created.workflow.updatedAt, }, } } + +/** Existing transport behavior, including its legacy workflow-created audit. */ +export async function importWorkflowIntoWorkspace( + params: ImportWorkflowParams +): Promise { + return executeImportWorkflowIntoWorkspace(params, performCreateWorkflow) +} + +/** Authoritative import transition without route- or service-local audit projection. */ +export async function importWorkflowIntoWorkspaceTransition( + params: ImportWorkflowParams +): Promise { + return executeImportWorkflowIntoWorkspace(params, performCreateWorkflowTransition) +} diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 5a4f5e51e17..1476840245d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' @@ -101,6 +102,8 @@ export interface PerformFullDeployParams { * Defaults to `userId`. Use `'admin-api'` for admin-initiated actions. */ actorId?: string + actor?: PrincipalActor + captureAnalytics?: false } /** @@ -222,6 +225,8 @@ async function performStableFullDeploy(params: { deploymentVersionId: operation.deploymentVersionId, version: operation.version, userId: params.params.userId, + actor: params.params.actor, + captureAnalytics: params.params.captureAnalytics, requestId: params.requestId, checkpoints: {}, }) @@ -497,6 +502,7 @@ export interface PerformFullUndeployParams { requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string + projectLegacyAudit?: boolean } export interface PerformFullUndeployResult { @@ -558,15 +564,17 @@ export async function performFullUndeploy( // Telemetry is best-effort } - recordAudit({ - workspaceId: (workflowData.workspaceId as string) || null, - actorId: actorId, - action: AuditAction.WORKFLOW_UNDEPLOYED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: (workflowData.name as string) || undefined, - description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: (workflowData.workspaceId as string) || null, + actorId: actorId, + action: AuditAction.WORKFLOW_UNDEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflowData.name as string) || undefined, + description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`, + }) + } await notifySocketDeploymentChanged(workflowId) const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) @@ -593,6 +601,8 @@ export interface PerformActivateVersionParams { requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string + actor?: PrincipalActor + captureAnalytics?: false } export interface PerformActivateVersionResult { @@ -709,6 +719,8 @@ export async function performActivateVersion( version, userId, actorId, + actor: params.actor, + captureAnalytics: params.captureAnalytics, requestId, idempotencyKey, }) @@ -732,6 +744,8 @@ async function performStableVersionActivation(params: { version: number userId: string actorId: string + actor?: PrincipalActor + captureAnalytics?: false requestId: string idempotencyKey: string }): Promise { @@ -762,6 +776,8 @@ async function performStableVersionActivation(params: { deploymentVersionId: operation.deploymentVersionId, version: operation.version, userId: params.userId, + actor: params.actor, + captureAnalytics: params.captureAnalytics, requestId: params.requestId, checkpoints: {}, }) diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index dc8d99a1d9f..7b7485d397f 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -10,8 +10,13 @@ export { performRevertToVersion, } from './deploy' export { + deleteWorkflowRecord, + type PerformCreateWorkflowParams, + type PerformCreateWorkflowResult, performCreateWorkflow, + performCreateWorkflowTransition, performDeleteWorkflow, performRestoreWorkflow, performUpdateWorkflow, + updateWorkflowRecord, } from './workflow-lifecycle' diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 07588af4460..e8c5d0d1ea3 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -98,6 +98,12 @@ export interface PerformDeleteWorkflowResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + archived?: boolean + workflow?: { + id: string + name: string + workspaceId: string | null + } } export interface PerformRestoreWorkflowParams { @@ -188,170 +194,196 @@ async function workflowNameExistsInFolder(params: { return Boolean(duplicateWorkflow) } -export async function performCreateWorkflow( +export async function performCreateWorkflowTransition( params: PerformCreateWorkflowParams ): Promise { const requestId = params.requestId ?? generateRequestId() const workflowId = params.id || generateId() const folderId = params.folderId || null - try { - if (!(await isFolderInWorkspace(folderId, params.workspaceId))) { - return { success: false, error: 'Target folder not found', errorCode: 'validation' } - } + if (!(await isFolderInWorkspace(folderId, params.workspaceId))) { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } - const name = params.deduplicate - ? await deduplicateWorkflowName(params.name, params.workspaceId, folderId) - : params.name + const name = params.deduplicate + ? await deduplicateWorkflowName(params.name, params.workspaceId, folderId) + : params.name - if (!params.deduplicate) { - const duplicate = await workflowNameExistsInFolder({ - workspaceId: params.workspaceId, - name, - folderId, - }) - if (duplicate) { - return { - success: false, - error: `A workflow named "${name}" already exists in this folder`, - errorCode: 'conflict', - } + if (!params.deduplicate) { + const duplicate = await workflowNameExistsInFolder({ + workspaceId: params.workspaceId, + name, + folderId, + }) + if (duplicate) { + return { + success: false, + error: `A workflow named "${name}" already exists in this folder`, + errorCode: 'conflict', } } + } - const sortOrder = - params.sortOrder !== undefined - ? params.sortOrder - : await nextWorkflowSortOrder(params.workspaceId, folderId) - const now = new Date() - const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() - - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId: params.userId, - workspaceId: params.workspaceId, - folderId, - sortOrder, - name, - description: params.description, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) - - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + const sortOrder = + params.sortOrder !== undefined + ? params.sortOrder + : await nextWorkflowSortOrder(params.workspaceId, folderId) + const now = new Date() + const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() + + await db.transaction(async (tx) => { + await tx.insert(workflow).values({ + id: workflowId, + userId: params.userId, + workspaceId: params.workspaceId, + folderId, + sortOrder, + name, + description: params.description, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + runCount: 0, + variables: {}, }) - logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) + await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + }) - recordAudit({ + logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) + + return { + success: true, + workflow: { + id: workflowId, + name, + description: params.description, workspaceId: params.workspaceId, - actorId: params.userId, - action: AuditAction.WORKFLOW_CREATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: name, - description: `Created workflow "${name}"`, - metadata: { - name, - description: params.description || undefined, - workspaceId: params.workspaceId, - folderId: folderId || undefined, - sortOrder, - }, - }) + folderId, + sortOrder, + createdAt: now, + updatedAt: now, + startBlockId, + subBlockValues, + }, + } +} - return { - success: true, - workflow: { - id: workflowId, - name, - description: params.description, +export async function performCreateWorkflow( + params: PerformCreateWorkflowParams +): Promise { + const requestId = params.requestId ?? generateRequestId() + try { + const result = await performCreateWorkflowTransition({ ...params, requestId }) + if (result.success && result.workflow) { + recordAudit({ workspaceId: params.workspaceId, - folderId, - sortOrder, - createdAt: now, - updatedAt: now, - startBlockId, - subBlockValues, - }, + actorId: params.userId, + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflow.id, + resourceName: result.workflow.name, + description: `Created workflow "${result.workflow.name}"`, + metadata: { + name: result.workflow.name, + description: params.description || undefined, + workspaceId: params.workspaceId, + folderId: result.workflow.folderId || undefined, + sortOrder: result.workflow.sortOrder, + }, + }) } + return result } catch (error) { logger.error(`[${requestId}] Failed to create workflow`, { error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } -export async function performUpdateWorkflow( +export async function updateWorkflowRecord( params: PerformUpdateWorkflowParams ): Promise { const requestId = params.requestId ?? generateRequestId() + const targetName = params.name ?? params.currentName + const targetFolderId = + params.folderId !== undefined ? params.folderId || null : params.currentFolderId || null + + if ( + params.folderId !== undefined && + !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) + ) { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } - try { - const targetName = params.name ?? params.currentName - const targetFolderId = - params.folderId !== undefined ? params.folderId || null : params.currentFolderId || null - - if ( - params.folderId !== undefined && - !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) - ) { - return { success: false, error: 'Target folder not found', errorCode: 'validation' } - } - - if (params.name !== undefined || params.folderId !== undefined) { - const duplicate = await workflowNameExistsInFolder({ - workspaceId: params.workspaceId, - name: targetName, - folderId: targetFolderId, - excludeWorkflowId: params.workflowId, - }) - if (duplicate) { - return { - success: false, - error: `A workflow named "${targetName}" already exists in this folder`, - errorCode: 'conflict', - } + if (params.name !== undefined || params.folderId !== undefined) { + const duplicate = await workflowNameExistsInFolder({ + workspaceId: params.workspaceId, + name: targetName, + folderId: targetFolderId, + excludeWorkflowId: params.workflowId, + }) + if (duplicate) { + return { + success: false, + error: `A workflow named "${targetName}" already exists in this folder`, + errorCode: 'conflict', } } + } - const updateData: Record = { updatedAt: new Date() } - if (params.name !== undefined) updateData.name = params.name - if (params.description !== undefined) updateData.description = params.description - if (params.folderId !== undefined) updateData.folderId = params.folderId - if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder - if (params.locked !== undefined) updateData.locked = params.locked - if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded - - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, params.workflowId)) - .returning({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - locked: workflow.locked, - forkSyncExcluded: workflow.forkSyncExcluded, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - archivedAt: workflow.archivedAt, - }) + const updateData: Record = { updatedAt: new Date() } + if (params.name !== undefined) updateData.name = params.name + if (params.description !== undefined) updateData.description = params.description + if (params.folderId !== undefined) updateData.folderId = params.folderId + if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder + if (params.locked !== undefined) updateData.locked = params.locked + if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded + + const [updatedWorkflow] = await db + .update(workflow) + .set(updateData) + .where( + and( + eq(workflow.id, params.workflowId), + eq(workflow.workspaceId, params.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .returning({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + sortOrder: workflow.sortOrder, + locked: workflow.locked, + forkSyncExcluded: workflow.forkSyncExcluded, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + archivedAt: workflow.archivedAt, + }) - if (!updatedWorkflow) { - return { success: false, error: 'Workflow not found', errorCode: 'not_found' } - } + if (!updatedWorkflow) { + return { success: false, error: 'Workflow not found', errorCode: 'not_found' } + } - logger.info(`[${requestId}] Successfully updated workflow ${params.workflowId}`, { - updates: updateData, - }) + logger.info(`[${requestId}] Successfully updated workflow ${params.workflowId}`, { + updates: updateData, + }) + + return { success: true, workflow: updatedWorkflow } +} + +export async function performUpdateWorkflow( + params: PerformUpdateWorkflowParams +): Promise { + const requestId = params.requestId ?? generateRequestId() + + try { + const result = await updateWorkflowRecord({ ...params, requestId }) + const updatedWorkflow = result.workflow + if (!result.success || !updatedWorkflow) return result if (params.locked !== undefined && params.locked !== (params.currentLocked ?? false)) { const workspaceId = updatedWorkflow.workspaceId @@ -408,24 +440,17 @@ export async function performUpdateWorkflow( ) } - return { success: true, workflow: updatedWorkflow } + return result } catch (error) { logger.error(`[${requestId}] Failed to update workflow ${params.workflowId}`, { error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } -/** - * Performs a full workflow deletion: enforces the last-workflow guard, - * archives the workflow via `archiveWorkflow`, and records an audit entry. - * Both the workflow API DELETE handler and the copilot delete_workflow tool - * must use this function. - */ -export async function performDeleteWorkflow( +export async function deleteWorkflowRecord( params: PerformDeleteWorkflowParams ): Promise { - const { workflowId, userId, skipLastWorkflowGuard = false } = params - const actorId = params.actorId ?? userId + const { workflowId, skipLastWorkflowGuard = false } = params const requestId = params.requestId ?? generateRequestId() const [workflowRecord] = await db @@ -459,21 +484,43 @@ export async function performDeleteWorkflow( } logger.info(`[${requestId}] Successfully archived workflow ${workflowId}`) + return { + success: true, + archived: archiveResult.archived, + workflow: { + id: archiveResult.workflow.id, + name: archiveResult.workflow.name, + workspaceId: archiveResult.workflow.workspaceId, + }, + } +} + +/** + * Performs a full workflow deletion: enforces the last-workflow guard, + * archives the workflow via `archiveWorkflow`, and records an audit entry. + * Both the workflow API DELETE handler and the copilot delete_workflow tool + * must use this function. + */ +export async function performDeleteWorkflow( + params: PerformDeleteWorkflowParams +): Promise { + const { workflowId, userId } = params + const actorId = params.actorId ?? userId + const result = await deleteWorkflowRecord(params) + if (!result.success || !result.archived || !result.workflow) return result recordAudit({ - workspaceId: workflowRecord.workspaceId || null, - actorId: actorId, + workspaceId: result.workflow.workspaceId || null, + actorId, action: AuditAction.WORKFLOW_DELETED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, - resourceName: workflowRecord.name, - description: `Archived workflow "${workflowRecord.name}"`, - metadata: { - archived: archiveResult.archived, - }, + resourceName: result.workflow.name, + description: `Archived workflow "${result.workflow.name}"`, + metadata: { archived: true }, }) - return { success: true } + return result } export async function performRestoreWorkflow(