chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,167 @@
import { createLogger } from '@sim/logger'
import {
assertWorkflowMutable,
authorizeWorkflowByWorkspacePermission,
WorkflowLockedError,
} from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { applyAutoLayout } from '@/lib/workflows/autolayout'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_LAYOUT_PADDING,
DEFAULT_VERTICAL_SPACING,
} from '@/lib/workflows/autolayout/constants'
import {
loadWorkflowFromNormalizedTables,
type NormalizedWorkflowData,
} from '@/lib/workflows/persistence/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('AutoLayoutAPI')
/**
* POST /api/workflows/[id]/autolayout
* Apply autolayout to an existing workflow
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const startTime = Date.now()
const { id: workflowId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const parsed = await parseRequest(workflowAutoLayoutContract, request, context)
if (!parsed.success) return parsed.response
const layoutOptions = parsed.data.body
logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, {
userId,
})
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
const workflowData = authorization.workflow
if (!workflowData) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const canUpdate = authorization.allowed
if (!canUpdate) {
logger.warn(
`[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
)
}
await assertWorkflowMutable(workflowId)
let currentWorkflowData: NormalizedWorkflowData | null
if (layoutOptions.blocks && layoutOptions.edges) {
logger.info(`[${requestId}] Using provided blocks with live measurements`)
currentWorkflowData = {
blocks: layoutOptions.blocks,
edges: layoutOptions.edges,
loops: layoutOptions.loops || {},
parallels: layoutOptions.parallels || {},
isFromNormalizedTables: false,
}
} else {
logger.info(`[${requestId}] Loading blocks from database`)
currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
}
if (!currentWorkflowData) {
logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
}
const autoLayoutOptions = {
horizontalSpacing: layoutOptions.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: layoutOptions.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING,
padding: {
x: layoutOptions.padding?.x ?? DEFAULT_LAYOUT_PADDING.x,
y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y,
},
alignment: layoutOptions.alignment,
gridSize: layoutOptions.gridSize,
}
const layoutResult = applyAutoLayout(
currentWorkflowData.blocks,
currentWorkflowData.edges,
autoLayoutOptions
)
if (!layoutResult.success || !layoutResult.blocks) {
logger.error(`[${requestId}] Auto layout failed:`, {
error: layoutResult.error,
})
return NextResponse.json(
{
error: 'Auto layout failed',
details: layoutResult.error || 'Unknown error',
},
{ status: 500 }
)
}
const elapsed = Date.now() - startTime
const blockCount = Object.keys(layoutResult.blocks).length
logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, {
blockCount,
workflowId,
})
return NextResponse.json({
success: true,
message: `Autolayout applied successfully to ${blockCount} blocks`,
data: {
blockCount,
elapsed: `${elapsed}ms`,
layoutedBlocks: layoutResult.blocks,
},
})
} catch (error) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error)
return NextResponse.json(
{
error: 'Autolayout failed',
details: getErrorMessage(error, 'Unknown error'),
},
{ status: 500 }
)
}
}
)
@@ -0,0 +1,100 @@
/**
* Tests for workflow chat status route auth and access.
*
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
hybridAuthMockFns,
resetDbChainMock,
workflowAuthzMockFns,
workflowsUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('drizzle-orm', () => ({
and: vi.fn((...args: unknown[]) => ({ type: 'and', args })),
eq: vi.fn(),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { GET } from '@/app/api/workflows/[id]/chat/status/route'
describe('Workflow Chat Status Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false })
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
expect(response.status).toBe(401)
})
it('returns 403 when user lacks workspace access', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
message: 'Access denied',
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
workspacePermission: null,
})
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
expect(response.status).toBe(403)
})
it('returns deployment details when authorized', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: true,
status: 200,
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
workspacePermission: 'read',
})
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'chat-1',
identifier: 'assistant',
title: 'Support Bot',
description: 'desc',
customizations: { theme: 'dark' },
authType: 'public',
allowedEmails: [],
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
password: 'secret',
isActive: true,
},
])
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.isDeployed).toBe(true)
expect(data.deployment.id).toBe('chat-1')
expect(data.deployment.hasPassword).toBe(true)
expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }])
})
})
@@ -0,0 +1,87 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getChatDeploymentStatusContract } from '@/lib/api/contracts/deployments'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatStatusAPI')
/**
* GET endpoint to check if a workflow has an active chat deployment
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const parsed = await parseRequest(getChatDeploymentStatusContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const requestId = generateRequestId()
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return createErrorResponse('Unauthorized', 401)
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: id,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed) {
return createErrorResponse(
authorization.message || 'Access denied',
authorization.status || 403
)
}
// Find any active chat deployments for this workflow
const deploymentResults = await db
.select({
id: chat.id,
identifier: chat.identifier,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
password: chat.password,
isActive: chat.isActive,
})
.from(chat)
.where(and(eq(chat.workflowId, id), isNull(chat.archivedAt)))
.limit(1)
const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive
const deploymentInfo =
deploymentResults.length > 0
? {
id: deploymentResults[0].id,
identifier: deploymentResults[0].identifier,
title: deploymentResults[0].title,
description: deploymentResults[0].description,
customizations: deploymentResults[0].customizations,
authType: deploymentResults[0].authType,
allowedEmails: deploymentResults[0].allowedEmails,
outputConfigs: deploymentResults[0].outputConfigs,
hasPassword: Boolean(deploymentResults[0].password),
}
: null
return createSuccessResponse({
isDeployed,
deployment: deploymentInfo,
})
} catch (error: any) {
logger.error(`[${requestId}] Error checking chat deployment status:`, error)
return createErrorResponse(error.message || 'Failed to check chat deployment status', 500)
}
}
)
@@ -0,0 +1,271 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
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 { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
import {
PublicApiNotAllowedError,
validatePublicApiAllowed,
} from '@/ee/access-control/utils/permission-check'
const logger = createLogger('WorkflowDeployAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 120
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id } = await params
try {
const { error, workflow: workflowData } = await validateWorkflowPermissions(
id,
requestId,
'read'
)
if (error) {
return createErrorResponse(error.message, error.status)
}
if (!workflowData.isDeployed) {
logger.info(`[${requestId}] Workflow is not deployed: ${id}`)
return createSuccessResponse({
isDeployed: false,
deployedAt: null,
apiKey: null,
needsRedeployment: false,
isPublicApi: workflowData.isPublicApi ?? false,
})
}
const needsRedeployment = await checkNeedsRedeployment(id)
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
const responseApiKeyInfo = workflowData.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
return createSuccessResponse({
apiKey: responseApiKeyInfo,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
needsRedeployment,
isPublicApi: workflowData.isPublicApi ?? false,
})
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
return createErrorResponse(error.message || 'Failed to fetch deployment information', 500)
}
}
)
export const POST = withRouteHandler(
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)
}
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,
workflowName: workflowData!.name || undefined,
requestId,
request,
})
if (!result.success) {
return createErrorResponse(
result.error || 'Failed to deploy workflow',
statusForOrchestrationError(result.errorCode)
)
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
captureServerEvent(
actorUserId,
'workflow_deployed',
{ workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' },
{
groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined,
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
}
)
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
return createSuccessResponse({
apiKey: responseApiKeyInfo,
isDeployed: true,
deployedAt: result.deployedAt,
warnings: result.warnings,
})
} catch (error: unknown) {
if (error instanceof WorkflowLockedError) {
return createErrorResponse(error.message, error.status)
}
const message = getErrorMessage(error, 'Failed to deploy workflow')
logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error })
return createErrorResponse(message, 500)
}
}
)
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(updatePublicApiContract, request, context, {
validationErrorResponse: () =>
createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { isPublicApi } = parsed.data.body
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
await assertWorkflowMutable(id)
if (isPublicApi) {
try {
await validatePublicApiAllowed(session?.user?.id, workflowData?.workspaceId ?? undefined)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return createErrorResponse('Public API access is disabled', 403)
}
throw err
}
}
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
const wsId = workflowData?.workspaceId
recordAudit({
workspaceId: wsId ?? null,
actorId: session!.user.id,
action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name ?? undefined,
description: `${isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${workflowData?.name ?? id}"`,
metadata: { isPublicApi },
request,
})
captureServerEvent(
session!.user.id,
'workflow_public_api_toggled',
{ workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi },
wsId ? { groups: { workspace: wsId } } : undefined
)
return createSuccessResponse({ isPublicApi })
} catch (error: unknown) {
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)
}
}
)
export const DELETE = withRouteHandler(
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,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
const wsId = workflowData?.workspaceId
captureServerEvent(
session!.user.id,
'workflow_undeployed',
{ workflow_id: id, workspace_id: wsId ?? '' },
wsId ? { groups: { workspace: wsId } } : undefined
)
return createSuccessResponse({
isDeployed: false,
deployedAt: null,
apiKey: null,
warnings: result.warnings,
})
} catch (error: unknown) {
if (error instanceof WorkflowLockedError) {
return createErrorResponse(error.message, error.status)
}
const message = getErrorMessage(error, 'Failed to undeploy workflow')
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error })
return createErrorResponse(message, 500)
}
}
)
@@ -0,0 +1,202 @@
/**
* Tests for the workflow deployed-state API route.
* Covers internal-JWT authorization (acting user required + workspace read
* permission) and the unchanged session path.
*
* @vitest-environment node
*/
import {
workflowAuthzMockFns,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyInternalToken } = vi.hoisted(() => ({
mockVerifyInternalToken: vi.fn(),
}))
vi.mock('@/lib/auth/internal', () => ({
verifyInternalToken: mockVerifyInternalToken,
}))
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { GET } from './route'
const mockAuthorizeWorkflowByWorkspacePermission =
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState
const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions
const DEPLOYED_STATE = {
blocks: { 'block-1': { id: 'block-1', type: 'starter' } },
edges: [],
loops: {},
parallels: {},
variables: {},
}
function createRequest(options?: { bearerToken?: string }) {
const headers: Record<string, string> = {}
if (options?.bearerToken) {
headers.Authorization = `Bearer ${options.bearerToken}`
}
return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers })
}
const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) })
describe('GET /api/workflows/[id]/deployed', () => {
beforeEach(() => {
vi.clearAllMocks()
mockVerifyInternalToken.mockResolvedValue({ valid: false })
mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE)
})
describe('internal JWT path', () => {
it('returns 200 when the token carries a user with read permission', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
workspacePermission: 'read',
})
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
expect(response.status).toBe(200)
const data = await response.json()
expect(data.deployedState).toEqual(DEPLOYED_STATE)
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
workflowId: 'workflow-123',
userId: 'user-123',
action: 'read',
})
expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled()
})
it('returns 403 when the acting user lacks read permission', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to read this workflow',
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
workspacePermission: null,
})
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
})
it('returns 403 when the token carries no acting user (fail closed)', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined })
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Forbidden')
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
})
it('returns 404 when the workflow does not exist', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 404,
message: 'Workflow not found',
workflow: null,
workspacePermission: null,
})
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Workflow not found')
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
})
})
describe('session path', () => {
it('returns 200 when session permissions validate', async () => {
mockValidateWorkflowPermissions.mockResolvedValue({
error: null,
session: { user: { id: 'user-123' } },
workflow: { id: 'workflow-123' },
})
const response = await GET(createRequest(), routeParams())
expect(response.status).toBe(200)
const data = await response.json()
expect(data.deployedState).toEqual(DEPLOYED_STATE)
expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith(
'workflow-123',
expect.any(String),
'read'
)
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})
it('propagates validateWorkflowPermissions errors unchanged', async () => {
mockValidateWorkflowPermissions.mockResolvedValue({
error: { message: 'Unauthorized', status: 401 },
session: null,
workflow: null,
})
const response = await GET(createRequest(), routeParams())
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('falls back to session validation when the bearer token is not a valid internal token', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: false })
mockValidateWorkflowPermissions.mockResolvedValue({
error: { message: 'Unauthorized', status: 401 },
session: null,
workflow: null,
})
const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams())
expect(response.status).toBe(401)
expect(mockValidateWorkflowPermissions).toHaveBeenCalled()
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})
})
it('returns null deployedState when loading the snapshot fails', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
workspacePermission: 'admin',
})
mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment'))
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
expect(response.status).toBe(200)
const data = await response.json()
expect(data.deployedState).toBeNull()
})
})
@@ -0,0 +1,108 @@
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import type { NextRequest, NextResponse } from 'next/server'
import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments'
import { parseRequest } from '@/lib/api/server'
import { verifyInternalToken } from '@/lib/auth/internal'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeployedStateAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
function addNoCacheHeaders(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
return response
}
/**
* GET /api/workflows/[id]/deployed
* Returns the active deployed state snapshot for a workflow.
*
* Internal (server-to-server) calls must carry the acting user in the internal
* JWT payload (`generateInternalToken(userId)` — the executor's
* `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that
* user with the same workspace-read semantics as the sibling
* `/api/workflows/[id]` route. Internal calls without a user id are rejected
* (fail closed). Session calls are authorized via
* `validateWorkflowPermissions` as before.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const parsed = await parseRequest(getDeployedWorkflowStateContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
try {
const authHeader = request.headers.get('authorization')
let isInternalCall = false
let internalCallUserId: string | undefined
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
isInternalCall = verification.valid
internalCallUserId = verification.userId
}
if (isInternalCall) {
if (!internalCallUserId) {
logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`)
return addNoCacheHeaders(createErrorResponse('Forbidden', 403))
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: id,
userId: internalCallUserId,
action: 'read',
})
if (!authorization.workflow) {
logger.warn(`[${requestId}] Workflow ${id} not found for internal call`)
return addNoCacheHeaders(createErrorResponse('Workflow not found', 404))
}
if (!authorization.allowed) {
logger.warn(
`[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}`
)
return addNoCacheHeaders(
createErrorResponse(authorization.message || 'Access denied', authorization.status)
)
}
} else {
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
const response = createErrorResponse(error.message, error.status)
return addNoCacheHeaders(response)
}
}
let deployedState = null
try {
const data = await loadDeployedWorkflowState(id)
deployedState = {
blocks: data.blocks,
edges: data.edges,
loops: data.loops,
parallels: data.parallels,
variables: data.variables,
}
} catch (error) {
logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error })
deployedState = null
}
const response = createSuccessResponse({ deployedState })
return addNoCacheHeaders(response)
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)
const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
return addNoCacheHeaders(response)
}
}
)
@@ -0,0 +1,70 @@
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import type { NextRequest } from 'next/server'
import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performRevertToVersion } from '@/lib/workflows/orchestration'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('RevertToDeploymentVersionAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const POST = withRouteHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
) => {
const requestId = generateRequestId()
const { id, version } = await params
try {
const {
error,
session,
workflow: workflowRecord,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
await assertWorkflowMutable(id)
const versionValidation = workflowDeploymentVersionParamSchema.safeParse(version)
if (!versionValidation.success) {
return createErrorResponse('Invalid version', 400)
}
const result = await performRevertToVersion({
workflowId: id,
version: versionValidation.data,
userId: session!.user.id,
workflow: (workflowRecord ?? {}) as Record<string, unknown>,
request,
actorName: session!.user.name ?? undefined,
actorEmail: session!.user.email ?? undefined,
})
if (!result.success) {
return createErrorResponse(
result.error || 'Failed to revert',
result.errorCode === 'not_found' ? 404 : 500
)
}
return createSuccessResponse({
message: 'Reverted to deployment version',
lastSaved: result.lastSaved,
})
} catch (error: any) {
if (error instanceof WorkflowLockedError) {
return createErrorResponse(error.message, error.status)
}
logger.error('Error reverting to deployment version', error)
return createErrorResponse(error.message || 'Failed to revert', 500)
}
}
)
@@ -0,0 +1,188 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
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 { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import {
getWorkflowDeploymentVersion,
updateDeploymentVersionMetadata,
} from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeploymentVersionAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 120
export const GET = withRouteHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
) => {
const requestId = generateRequestId()
const { id, version } = await params
try {
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
return createErrorResponse(error.message, error.status)
}
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)
}
return createSuccessResponse({ deployedState: row.state })
} catch (error: any) {
logger.error(
`[${requestId}] Error fetching deployment version ${version} for workflow ${id}`,
error
)
return createErrorResponse(error.message || 'Failed to fetch deployment version', 500)
}
}
)
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(updateDeploymentVersionMetadataContract, request, context, {
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error, 'Invalid request body'), 400),
})
if (!parsed.success) return parsed.response
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,
workflow: workflowData,
} = 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,
workflow: workflowData as Record<string, unknown>,
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) {
const activationUpdateData: { name?: string; description?: string | null } = {}
if (name !== undefined) {
activationUpdateData.name = name
}
if (description !== undefined) {
activationUpdateData.description = description
}
const [updated] = await db
.update(workflowDeploymentVersion)
.set(activationUpdateData)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
})
if (updated) {
updatedName = updated.name
updatedDescription = updated.description
logger.info(
`[${requestId}] Updated deployment version ${version} metadata during activation`,
{ name: activationUpdateData.name, description: activationUpdateData.description }
)
}
}
const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId
captureServerEvent(
actorUserId,
'deployment_version_activated',
{ workflow_id: id, workspace_id: wsId ?? '', version: versionNum },
wsId ? { groups: { workspace: wsId } } : undefined
)
return createSuccessResponse({
success: true,
deployedAt: activateResult.deployedAt,
warnings: activateResult.warnings,
...(updatedName !== undefined && { name: updatedName }),
...(updatedDescription !== undefined && { description: updatedDescription }),
})
}
// Handle name/description updates (shared with the update_deployment_version copilot tool)
const updated = await updateDeploymentVersionMetadata({
workflowId: id,
version: versionNum,
name,
description,
})
if (!updated) {
return createErrorResponse('Deployment version not found', 404)
}
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
name,
description,
})
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)
}
}
)
@@ -0,0 +1,41 @@
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 { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeploymentsListAPI')
export const dynamic = 'force-dynamic'
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 { versions: rows } = await listWorkflowVersions(id)
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)
}
}
)
@@ -0,0 +1,137 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { FolderLockedError } from '@sim/platform-authz/workflow'
import { type NextRequest, NextResponse } from 'next/server'
import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
const logger = createLogger('WorkflowDuplicateAPI')
// POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const { id: sourceWorkflowId } = await context.params
const requestId = generateRequestId()
const startTime = Date.now()
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(
`[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
try {
const parsed = await parseRequest(duplicateWorkflowContract, req, context)
if (!parsed.success) return parsed.response
const { name, description, workspaceId, folderId, newId } = parsed.data.body
logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
const result = await duplicateWorkflow({
sourceWorkflowId,
userId,
name,
description,
workspaceId,
folderId,
requestId,
newWorkflowId: newId,
})
try {
PlatformEvents.workflowDuplicated({
sourceWorkflowId,
newWorkflowId: result.id,
workspaceId,
})
} catch {
// Telemetry should not fail the operation
}
captureServerEvent(
userId,
'workflow_duplicated',
{
source_workflow_id: sourceWorkflowId,
new_workflow_id: result.id,
workspace_id: workspaceId ?? '',
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
const elapsed = Date.now() - startTime
logger.info(
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
)
recordAudit({
workspaceId: workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_DUPLICATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: result.id,
resourceName: result.name,
description: `Duplicated workflow from ${sourceWorkflowId}`,
metadata: {
sourceWorkflowId,
newWorkflowId: result.id,
folderId: folderId || undefined,
},
request: req,
})
return NextResponse.json(result, { status: 201 })
} catch (error) {
if (error instanceof Error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
if (error.message === 'Source workflow not found') {
logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`)
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
}
if (error.message === 'Source workflow not found or access denied') {
logger.warn(
`[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (error.message === 'Cross-workspace workflow duplication is not supported') {
logger.warn(
`[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}`
)
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error.message === 'Folder is locked') {
return NextResponse.json({ error: error.message }, { status: 423 })
}
if (error.message === 'Target folder not found') {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
const elapsed = Date.now() - startTime
logger.error(
`[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`,
error
)
return NextResponse.json({ error: 'Failed to duplicate workflow' }, { status: 500 })
}
}
)
@@ -0,0 +1,453 @@
/**
* Tests that internal JWT callers receive the standard response format
* even when the child workflow has a Response block.
*
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType } from '@/lib/auth/hybrid'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { storeLargeValue } from '@/lib/execution/payloads/store'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
import type { ExecutionResult } from '@/lib/workflows/types'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
const {
mockAddLargeValueReference,
mockDownloadFile,
mockRegisterLargeValueOwner,
mockUploadFile,
uploadedFiles,
mockIsWorkspaceApiExecutionEntitled,
} = vi.hoisted(() => ({
mockAddLargeValueReference: vi.fn(),
mockDownloadFile: vi.fn(),
mockRegisterLargeValueOwner: vi.fn(),
mockUploadFile: vi.fn(),
uploadedFiles: new Map<string, Buffer>(),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/billing/core/api-access', () => ({
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
const MATERIALIZATION_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
addLargeValueReference: mockAddLargeValueReference,
registerLargeValueOwner: mockRegisterLargeValueOwner,
}))
function buildExecutionResult(overrides: Partial<ExecutionResult> = {}): ExecutionResult {
return {
success: true,
output: { data: { issues: [] }, status: 200, headers: {} },
logs: [
{
blockId: 'response-1',
blockType: 'response',
blockName: 'Response',
success: true,
output: { data: { issues: [] }, status: 200, headers: {} },
startedAt: '2026-01-01T00:00:00Z',
endedAt: '2026-01-01T00:00:01Z',
},
],
metadata: {
duration: 500,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
...overrides,
}
}
describe('Response block gating by auth type', () => {
let resultWithResponseBlock: ExecutionResult
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
uploadedFiles.clear()
mockAddLargeValueReference.mockResolvedValue(undefined)
mockRegisterLargeValueOwner.mockResolvedValue(true)
mockUploadFile.mockImplementation(async ({ customKey, file }) => {
uploadedFiles.set(customKey, file)
return { key: customKey }
})
mockDownloadFile.mockImplementation(
async ({ key }) => uploadedFiles.get(key) ?? Buffer.from('{}')
)
resultWithResponseBlock = buildExecutionResult()
})
it('should detect a Response block in execution result', () => {
expect(workflowHasResponseBlock(resultWithResponseBlock)).toBe(true)
})
it('should not detect a Response block when none exists', () => {
const resultWithoutResponseBlock = buildExecutionResult({
output: { result: 'hello' },
logs: [
{
blockId: 'agent-1',
blockType: 'agent',
blockName: 'Agent',
success: true,
output: { result: 'hello' },
startedAt: '2026-01-01T00:00:00Z',
endedAt: '2026-01-01T00:00:01Z',
},
],
})
expect(workflowHasResponseBlock(resultWithoutResponseBlock)).toBe(false)
})
it('should skip Response block formatting for internal JWT callers', () => {
const authType = AuthType.INTERNAL_JWT
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
expect(hasResponseBlock).toBe(true)
// This mirrors the route.ts condition:
// if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(...))
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(false)
})
it('should apply Response block formatting for API key callers', async () => {
const authType = AuthType.API_KEY
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(true)
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
expect(response.status).toBe(200)
})
it('should apply Response block formatting for session callers', () => {
const authType = AuthType.SESSION
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(true)
})
it('should return raw user data via createHttpResponseFromBlock', async () => {
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
const body = await response.json()
// Response block returns the user-defined data directly (no success/executionId wrapper)
expect(body).toEqual({ issues: [] })
expect(body.success).toBeUndefined()
expect(body.executionId).toBeUndefined()
})
it('should respect custom status codes from Response block', async () => {
const result = buildExecutionResult({
output: { data: { error: 'Not found' }, status: 404, headers: {} },
})
const response = await createHttpResponseFromBlock(result)
expect(response.status).toBe(404)
})
it('should materialize manifest data for Response block HTTP output', async () => {
const rows = Array.from({ length: 100 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
}))
const output = await compactExecutionPayload(
{
data: { rows },
status: 200,
headers: {},
},
{
...MATERIALIZATION_CONTEXT,
requireDurable: true,
preserveRoot: true,
thresholdBytes: 1024,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({ output }),
MATERIALIZATION_CONTEXT
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.rows).toEqual(rows)
expect(body.success).toBeUndefined()
})
it('should materialize Response block manifests from an allowed source execution', async () => {
const rows = [{ key: 'SIM-1' }, { key: 'SIM-2' }]
const manifest = await createLargeArrayManifest(rows, {
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
})
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
const body = await response.json()
expect(body.rows).toEqual(rows)
})
it('should reject Response block manifests from non-source same-workflow executions', async () => {
const manifest = await createLargeArrayManifest([{ key: 'SIM-stale' }], {
...MATERIALIZATION_CONTEXT,
executionId: 'stale-execution-1',
})
await expect(
createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
).rejects.toThrow('Large execution value is not available in this execution')
})
it('should materialize Response block manifests inherited by the source snapshot', async () => {
const rows = [{ key: 'SIM-inherited' }]
const manifest = await createLargeArrayManifest(rows, {
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
})
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1', 'original-execution-1'],
}
)
const body = await response.json()
expect(body.rows).toEqual(rows)
})
it('should recursively materialize refs inside Response block manifest rows', async () => {
const text = 'nested'.repeat(2 * 1024 * 1024)
const nestedOutput = await compactExecutionPayload(
{ text },
{
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
requireDurable: true,
preserveRoot: true,
}
)
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
const manifest = await createLargeArrayManifest([{ nested: nestedRef }], {
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
})
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { rows: manifest },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
const body = await response.json()
expect(body.rows).toEqual([{ nested: text }])
})
it('should recursively materialize refs inside stored Response block objects', async () => {
const text = 'nested'.repeat(2 * 1024 * 1024)
const nestedOutput = await compactExecutionPayload(
{ text },
{
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
requireDurable: true,
preserveRoot: true,
}
)
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
const storedValue = {
wrapper: {
nested: nestedRef,
padding: 'x'.repeat(2048),
},
}
const storedJson = JSON.stringify(storedValue)
const storedOutput = await storeLargeValue(
storedValue,
storedJson,
Buffer.byteLength(storedJson),
{
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
requireDurable: true,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: storedOutput,
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueExecutionIds: ['source-execution-1'],
}
)
const body = await response.json()
expect(body.wrapper.nested).toEqual(text)
})
it('should memoize repeated materialized objects while resolving nested refs', async () => {
const text = 'nested'.repeat(2 * 1024 * 1024)
const nestedOutput = await compactExecutionPayload(
{ text },
{
...MATERIALIZATION_CONTEXT,
executionId: 'original-execution-1',
requireDurable: true,
preserveRoot: true,
}
)
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
const sourceValue = { nested: nestedRef }
const sourceJson = JSON.stringify(sourceValue)
const sourceRef = await storeLargeValue(
sourceValue,
sourceJson,
Buffer.byteLength(sourceJson),
{
...MATERIALIZATION_CONTEXT,
executionId: 'source-execution-1',
requireDurable: true,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({
output: {
data: { first: sourceRef, second: sourceRef },
status: 200,
headers: {},
},
}),
{
...MATERIALIZATION_CONTEXT,
largeValueKeys: sourceRef.key ? [sourceRef.key] : [],
}
)
const body = await response.json()
expect(body).toEqual({
first: { nested: text },
second: { nested: text },
})
})
it('should materialize large string refs for Response block HTTP output', async () => {
const text = 'x'.repeat(9 * 1024 * 1024)
const output = await compactExecutionPayload(
{
data: { text },
status: 200,
headers: {},
},
{
...MATERIALIZATION_CONTEXT,
requireDurable: true,
preserveRoot: true,
}
)
const response = await createHttpResponseFromBlock(
buildExecutionResult({ output }),
MATERIALIZATION_CONTEXT
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.text).toBe(text)
})
it('should reject Response block HTTP output that is too large to inline', async () => {
const output = await compactExecutionPayload(
{
data: {
text: 'x'.repeat(17 * 1024 * 1024),
},
status: 200,
headers: {},
},
{
...MATERIALIZATION_CONTEXT,
requireDurable: true,
preserveRoot: true,
}
)
await expect(
createHttpResponseFromBlock(buildExecutionResult({ output }), MATERIALIZATION_CONTEXT)
).rejects.toMatchObject({
code: EXECUTION_RESOURCE_LIMIT_CODE,
})
})
})
@@ -0,0 +1,473 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
executionPreprocessingMock,
executionPreprocessingMockFns,
hybridAuthMockFns,
loggingSessionMock,
requestUtilsMockFns,
workflowAuthzMockFns,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockEnqueue,
mockExecuteWorkflowCore,
mockHandlePostExecutionPauseState,
mockIsWorkspaceApiExecutionEntitled,
} = vi.hoisted(() => ({
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
mockExecuteWorkflowCore: vi.fn(),
mockHandlePostExecutionPauseState: vi.fn(),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/billing/core/api-access', () => ({
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
const mockAuthorizeWorkflowByWorkspacePermission =
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
vi.mock('@/lib/workflows/executor/execution-core', () => ({
executeWorkflowCore: mockExecuteWorkflowCore,
}))
vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
handlePostExecutionPauseState: mockHandlePostExecutionPauseState,
}))
vi.mock('@/lib/execution/payloads/store', () => ({
storeLargeValue: vi.fn(async (_value, _json, size: number) => ({
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'string',
size,
})),
}))
vi.mock('@/lib/core/async-jobs', () => ({
getJobQueue: vi.fn().mockResolvedValue({
enqueue: mockEnqueue,
startJob: vi.fn(),
completeJob: vi.fn(),
markJobFailed: vi.fn(),
}),
shouldExecuteInline: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'),
getOllamaUrl: vi.fn().mockReturnValue('http://localhost:11434'),
}))
vi.mock('@/lib/execution/call-chain', () => ({
SIM_VIA_HEADER: 'x-sim-via',
parseCallChain: vi.fn().mockReturnValue([]),
validateCallChain: vi.fn().mockReturnValue(null),
buildNextCallChain: vi.fn().mockReturnValue(['workflow-1']),
}))
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/background/workflow-execution', () => ({
executeWorkflowJob: vi.fn(),
}))
vi.mock('@sim/utils/id', () => ({
generateId: vi.fn(() => 'execution-123'),
generateShortId: vi.fn(() => 'mock-short-id'),
isValidUuid: vi.fn((v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
),
}))
import { storeLargeValue } from '@/lib/execution/payloads/store'
import { POST } from './route'
describe('workflow execute async route', () => {
beforeEach(() => {
vi.clearAllMocks()
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678')
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false)
hybridAuthMockFns.mockHasExternalApiCredentials.mockReturnValue(true)
mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: 'session-user-1',
authType: 'session',
})
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
workflow: {
id: 'workflow-1',
userId: 'owner-1',
workspaceId: 'workspace-1',
},
})
mockPreprocessExecution.mockResolvedValue({
success: true,
actorUserId: 'actor-1',
workflowRecord: {
id: 'workflow-1',
userId: 'owner-1',
workspaceId: 'workspace-1',
},
})
workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null)
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: { ok: true },
metadata: {
duration: 100,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
})
mockHandlePostExecutionPauseState.mockResolvedValue(undefined)
})
it('queues async execution with matching correlation metadata', async () => {
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-Execution-Mode': 'async',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(202)
expect(body.executionId).toBe('execution-123')
expect(body.jobId).toBe('job-123')
expect(mockEnqueue).toHaveBeenCalledWith(
'workflow-execution',
expect.objectContaining({
workflowId: 'workflow-1',
userId: 'actor-1',
workspaceId: 'workspace-1',
executionId: 'execution-123',
executionMode: 'async',
}),
expect.objectContaining({
metadata: expect.objectContaining({
workflowId: 'workflow-1',
userId: 'actor-1',
workspaceId: 'workspace-1',
correlation: expect.objectContaining({
executionId: 'execution-123',
requestId: 'req-12345678',
source: 'workflow',
workflowId: 'workflow-1',
triggerType: 'manual',
}),
}),
})
)
})
it('rejects cross-site session requests before authorization work', async () => {
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'Sec-Fetch-Site': 'cross-site',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toBe('Access denied')
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
expect(mockEnqueue).not.toHaveBeenCalled()
})
it('allows same-site session requests (multi-subdomain Run, e.g. www.<domain>)', async () => {
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-Execution-Mode': 'async',
'Sec-Fetch-Site': 'same-site',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
expect(response.status).toBe(202)
expect(mockEnqueue).toHaveBeenCalled()
})
it('rejects oversized request bodies before authorization work', async () => {
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'Content-Length': String(10 * 1024 * 1024 + 1),
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error).toContain('Workflow execution request body')
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})
it('authenticates before rejecting oversized request bodies', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
authType: 'api_key',
})
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'Content-Length': String(10 * 1024 * 1024 + 1),
'X-API-Key': 'invalid',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(401)
expect(body.error).toBe('Unauthorized')
expect(mockCheckHybridAuth).toHaveBeenCalled()
})
it('returns 499 when a non-SSE execution is cancelled by client disconnect', async () => {
const abortController = new AbortController()
mockExecuteWorkflowCore.mockImplementationOnce(
async ({ abortSignal }: { abortSignal: AbortSignal }) => {
abortController.abort()
expect(abortSignal.aborted).toBe(true)
return {
success: false,
status: 'cancelled',
output: { partial: true },
metadata: {
duration: 100,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
}
}
)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-1/execute', {
method: 'POST',
body: JSON.stringify({ input: { hello: 'world' } }),
signal: abortController.signal,
})
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(499)
expect(body.error).toBe('Client cancelled request')
})
it('rejects large MCP bridge outputs instead of returning large-value refs', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'internal-user-1',
authType: 'internal_jwt',
})
mockExecuteWorkflowCore.mockResolvedValueOnce({
success: true,
status: 'completed',
output: 'x'.repeat(10 * 1024 * 1024 + 1),
metadata: {
duration: 100,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
})
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-Sim-MCP-Tool-Call': 'true',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error).toContain('Workflow execution response')
expect(storeLargeValue).not.toHaveBeenCalled()
})
it('does not trust client-spoofed MCP bridge headers on API key executions', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'api-user-1',
authType: 'api_key',
apiKeyType: 'personal',
})
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValueOnce(true)
workflowsUtilsMockFns.mockCreateHttpResponseFromBlock.mockResolvedValueOnce(
Response.json({ response: 'plain text body' })
)
mockExecuteWorkflowCore.mockResolvedValueOnce({
success: true,
status: 'completed',
output: { response: 'plain text body' },
metadata: {
duration: 100,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
})
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-API-Key': 'valid',
'X-Sim-MCP-Tool-Call': 'true',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toEqual({ response: 'plain text body' })
expect(workflowsUtilsMockFns.mockCreateHttpResponseFromBlock).toHaveBeenCalled()
})
it('keeps trusted internal MCP bridge executions on the JSON envelope path', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'internal-user-1',
authType: 'internal_jwt',
})
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValueOnce(true)
mockExecuteWorkflowCore.mockResolvedValueOnce({
success: true,
status: 'completed',
output: { response: 'plain text body' },
metadata: {
duration: 100,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
})
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-Sim-MCP-Tool-Call': 'true',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toMatchObject({
success: true,
output: { response: 'plain text body' },
})
expect(workflowsUtilsMockFns.mockCreateHttpResponseFromBlock).not.toHaveBeenCalled()
expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
expect.objectContaining({
snapshot: expect.objectContaining({
input: { hello: 'world' },
}),
})
)
})
it('preserves authenticated-user actor semantics for trusted MCP bridge calls', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'api-user-1',
authType: 'internal_jwt',
})
mockExecuteWorkflowCore.mockResolvedValueOnce({
success: true,
status: 'completed',
output: { ok: true },
metadata: {
duration: 100,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
})
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-Sim-MCP-Tool-Call': 'true',
'X-Sim-MCP-Tool-Actor': 'authenticated-user',
}
)
const params = Promise.resolve({ id: 'workflow-1' })
const response = await POST(req, { params })
expect(response.status).toBe(200)
expect(mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'api-user-1',
useAuthenticatedUserAsActor: true,
})
)
const executionCall = mockExecuteWorkflowCore.mock.calls[0][0]
const snapshot =
typeof executionCall.snapshot === 'string'
? JSON.parse(executionCall.snapshot)
: executionCall.snapshot
expect(snapshot.metadata.enforceCredentialAccess).toBe(true)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,355 @@
/**
* @vitest-environment node
*/
import {
databaseMock,
hybridAuthMockFns,
posthogServerMock,
workflowAuthzMockFns,
workflowsUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockMarkExecutionCancelled,
mockAbortManualExecution,
mockBeginPausedCancellation,
mockBlockQueuedResumesForCancellation,
mockClearPausedCancellationIntent,
mockCompletePausedCancellation,
mockGetPausedCancellationStatus,
mockFinalizeExecutionStream,
mockReadExecutionMetaState,
mockWriteEvent,
mockWriteTerminalEvent,
} = vi.hoisted(() => ({
mockMarkExecutionCancelled: vi.fn(),
mockAbortManualExecution: vi.fn(),
mockBeginPausedCancellation: vi.fn(),
mockBlockQueuedResumesForCancellation: vi.fn(),
mockClearPausedCancellationIntent: vi.fn(),
mockCompletePausedCancellation: vi.fn(),
mockGetPausedCancellationStatus: vi.fn(),
mockFinalizeExecutionStream: vi.fn(),
mockReadExecutionMetaState: vi.fn(),
mockWriteEvent: vi.fn(),
mockWriteTerminalEvent: vi.fn(),
}))
vi.mock('@/lib/execution/cancellation', () => ({
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
}))
vi.mock('@/lib/execution/manual-cancellation', () => ({
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
}))
vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
PauseResumeManager: {
beginPausedCancellation: (...args: unknown[]) => mockBeginPausedCancellation(...args),
blockQueuedResumesForCancellation: (...args: unknown[]) =>
mockBlockQueuedResumesForCancellation(...args),
clearPausedCancellationIntent: (...args: unknown[]) =>
mockClearPausedCancellationIntent(...args),
completePausedCancellation: (...args: unknown[]) => mockCompletePausedCancellation(...args),
getPausedCancellationStatus: (...args: unknown[]) => mockGetPausedCancellationStatus(...args),
},
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/lib/execution/event-buffer', () => ({
finalizeExecutionStream: (...args: unknown[]) => mockFinalizeExecutionStream(...args),
readExecutionMetaState: (...args: unknown[]) => mockReadExecutionMetaState(...args),
createExecutionEventWriter: () => ({
write: (...args: unknown[]) => mockWriteEvent(...args),
writeTerminal: (...args: unknown[]) => mockWriteTerminalEvent(...args),
close: vi.fn().mockResolvedValue(undefined),
}),
}))
import { POST } from './route'
const makeRequest = () =>
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
})
const makeParams = () => ({ params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }) })
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
})
mockAbortManualExecution.mockReturnValue(false)
mockBeginPausedCancellation.mockResolvedValue(false)
mockBlockQueuedResumesForCancellation.mockResolvedValue(false)
mockClearPausedCancellationIntent.mockResolvedValue(undefined)
mockCompletePausedCancellation.mockResolvedValue(false)
mockGetPausedCancellationStatus.mockResolvedValue(null)
mockFinalizeExecutionStream.mockResolvedValue(true)
mockReadExecutionMetaState.mockResolvedValue({ status: 'missing' })
mockWriteEvent.mockResolvedValue({ eventId: 1 })
mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 })
})
it('returns success when cancellation was durably recorded', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
pausedCancelled: false,
reason: 'recorded',
})
})
it('returns unsuccessful response when Redis is unavailable', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: false,
reason: 'redis_unavailable',
})
})
it('returns unsuccessful response when Redis persistence fails', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_write_failed',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: false,
reason: 'redis_write_failed',
})
})
it('returns success when local fallback aborts execution without Redis durability', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockAbortManualExecution.mockReturnValue(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: true,
pausedCancelled: false,
reason: 'redis_unavailable',
})
})
it('returns success when a paused HITL execution is cancelled directly in the database', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
pausedCancelled: true,
reason: 'recorded',
})
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
expect(mockWriteTerminalEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'execution:cancelled',
executionId: 'ex-1',
workflowId: 'wf-1',
}),
'cancelled'
)
expect(mockFinalizeExecutionStream).not.toHaveBeenCalled()
})
it('publishes paused cancellation event even when Redis cancellation is recorded', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
executionId: 'ex-1',
durablyRecorded: true,
pausedCancelled: true,
})
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
expect(mockWriteTerminalEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'execution:cancelled',
executionId: 'ex-1',
workflowId: 'wf-1',
}),
'cancelled'
)
expect(mockFinalizeExecutionStream).not.toHaveBeenCalled()
})
it('does not confirm paused cancellation when terminal event publication fails', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
mockWriteTerminalEvent.mockRejectedValue(new Error('Redis unavailable'))
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: false,
reason: 'paused_event_publish_failed',
})
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
expect(mockCompletePausedCancellation).not.toHaveBeenCalled()
expect(mockWriteTerminalEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'execution:cancelled',
executionId: 'ex-1',
workflowId: 'wf-1',
}),
'cancelled'
)
expect(mockFinalizeExecutionStream).not.toHaveBeenCalled()
})
it('returns 401 when auth fails', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
success: false,
error: 'Unauthorized',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(401)
})
it('returns 403 when workflow access is denied', async () => {
mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' })
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
message: 'Access denied',
status: 403,
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(403)
})
it('updates execution log status in DB when durably recorded', async () => {
const mockWhere = vi.fn().mockResolvedValue(undefined)
const mockSet = vi.fn(() => ({ where: mockWhere }))
databaseMock.db.update.mockReturnValueOnce({ set: mockSet })
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
await POST(makeRequest(), makeParams())
expect(databaseMock.db.update).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith({
status: 'cancelled',
endedAt: expect.any(Date),
})
})
it('updates execution log status in DB when locally aborted', async () => {
const mockWhere = vi.fn().mockResolvedValue(undefined)
const mockSet = vi.fn(() => ({ where: mockWhere }))
databaseMock.db.update.mockReturnValueOnce({ set: mockSet })
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockAbortManualExecution.mockReturnValue(true)
await POST(makeRequest(), makeParams())
expect(databaseMock.db.update).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith({
status: 'cancelled',
endedAt: expect.any(Date),
})
})
it('does not update execution log status in DB when only paused execution was cancelled', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
await POST(makeRequest(), makeParams())
expect(databaseMock.db.update).not.toHaveBeenCalled()
})
it('returns success even if direct DB update fails', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
databaseMock.db.update.mockReturnValueOnce({
set: vi.fn(() => ({
where: vi.fn(() => {
throw new Error('DB connection failed')
}),
})),
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
})
})
@@ -0,0 +1,326 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
type ExecutionCancellationRecordResult,
markExecutionCancelled,
} from '@/lib/execution/cancellation'
import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
import { captureServerEvent } from '@/lib/posthog/server'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
const logger = createLogger('CancelExecutionAPI')
const PAUSED_CANCELLATION_DB_ATTEMPTS = 3
const PAUSED_CANCELLATION_DB_RETRY_MS = 200
async function completePausedCancellationWithRetry(
executionId: string,
workflowId: string
): Promise<boolean> {
for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) {
try {
const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId)
if (cancelled) {
logger.info('Paused execution cancelled in database', { executionId, attempt })
return true
}
logger.warn('Paused execution cancellation could not be completed in database', {
executionId,
attempt,
})
return false
} catch (error) {
logger.warn('Failed to complete paused execution cancellation in database', {
executionId,
attempt,
error,
})
if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) {
await sleep(PAUSED_CANCELLATION_DB_RETRY_MS)
}
}
}
return false
}
async function ensurePausedCancellationEventPublished(
executionId: string,
workflowId: string,
context: { workspaceId?: string; userId?: string } = {}
): Promise<boolean> {
const metaState = await readExecutionMetaState(executionId)
if (metaState.status === 'found' && metaState.meta.status === 'cancelled') {
return true
}
const writer = createExecutionEventWriter(executionId, {
workspaceId: context.workspaceId,
workflowId,
userId: context.userId,
})
try {
await writer.writeTerminal(
{
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { duration: 0 },
},
'cancelled'
)
return true
} catch (error) {
logger.warn('Failed to publish paused execution cancellation event', {
executionId,
error,
})
return false
} finally {
await writer.close().catch((error) => {
logger.warn('Failed to close paused cancellation event writer', {
executionId,
error,
})
})
}
}
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => {
const parsed = await parseRequest(cancelWorkflowExecutionContract, req, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'write',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
if (
auth.apiKeyType === 'workspace' &&
workflowAuthorization.workflow?.workspaceId !== auth.workspaceId
) {
return NextResponse.json(
{ error: 'API key is not authorized for this workspace' },
{ status: 403 }
)
}
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
let pausedCancellationStarted = false
let pausedCancelled = false
try {
pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation(
executionId,
workflowId
)
} catch (error) {
logger.warn('Failed to begin paused execution cancellation in database', {
executionId,
error,
})
}
const pendingPausedCancellation = pausedCancellationStarted
? null
: await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId)
const isPausedCancellationPath =
pausedCancellationStarted || pendingPausedCancellation !== null
const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath
? { durablyRecorded: false, reason: 'redis_unavailable' }
: await markExecutionCancelled(executionId)
const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId)
if (pausedCancellationStarted) {
logger.info('Paused execution cancellation reserved in database', { executionId })
} else if (cancellation.durablyRecorded) {
logger.info('Execution marked as cancelled in Redis', { executionId })
} else if (locallyAborted) {
logger.info('Execution cancelled via local in-process fallback', { executionId })
} else if (!pausedCancellationStarted) {
logger.warn('Execution cancellation was not durably recorded', {
executionId,
reason: cancellation.reason,
})
}
if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) {
await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch(
(error) => {
logger.warn('Failed to block queued paused resumes after cancellation', {
executionId,
error,
})
}
)
} else if (!isPausedCancellationPath) {
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
(error) => {
logger.warn(
'Failed to clear paused cancellation intent after unsuccessful cancellation',
{
executionId,
error,
}
)
}
)
}
let pausedCancellationPublished = false
let pausedCancellationPublishFailed = false
if (pausedCancellationStarted) {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId,
{
workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
userId: auth.userId,
}
)
pausedCancellationPublishFailed = !pausedCancellationPublished
if (pausedCancellationPublished) {
pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId)
}
} else {
if (pendingPausedCancellation === 'cancelled') {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId,
{
workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
userId: auth.userId,
}
)
pausedCancellationPublishFailed = !pausedCancellationPublished
pausedCancelled = pausedCancellationPublished
} else if (pendingPausedCancellation === 'cancelling') {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId,
{
workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
userId: auth.userId,
}
)
pausedCancellationPublishFailed = !pausedCancellationPublished
if (pausedCancellationPublished) {
pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId)
}
}
}
if (
pausedCancellationPublishFailed &&
(pausedCancellationStarted || pendingPausedCancellation === 'cancelling')
) {
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
(error) => {
logger.warn('Failed to clear paused cancellation intent after publish failure', {
executionId,
error,
})
}
)
}
if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) {
try {
await db
.update(workflowExecutionLogs)
.set({ status: 'cancelled', endedAt: new Date() })
.where(
and(
eq(workflowExecutionLogs.executionId, executionId),
eq(workflowExecutionLogs.status, 'running')
)
)
} catch (dbError) {
logger.warn('Failed to update execution log status directly', {
executionId,
error: dbError,
})
}
}
const success =
(isPausedCancellationPath
? pausedCancelled && pausedCancellationPublished
: cancellation.durablyRecorded) || locallyAborted
if (success) {
const workspaceId = workflowAuthorization.workflow?.workspaceId
captureServerEvent(
auth.userId,
'workflow_execution_cancelled',
{ workflow_id: workflowId, workspace_id: workspaceId ?? '' },
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}
const durablyRecorded = isPausedCancellationPath
? pausedCancellationPublished
: pausedCancelled || cancellation.durablyRecorded
const reason = pausedCancellationPublishFailed
? 'paused_event_publish_failed'
: !pausedCancelled && isPausedCancellationPath
? 'paused_database_cancel_failed'
: pausedCancelled && !pausedCancellationPublished
? 'paused_event_publish_failed'
: pausedCancelled || isPausedCancellationPath
? 'recorded'
: cancellation.reason
return NextResponse.json({
success,
executionId,
redisAvailable:
isPausedCancellationPath || pausedCancelled
? pausedCancellationPublished
: cancellation.reason !== 'redis_unavailable',
durablyRecorded,
locallyAborted,
pausedCancelled,
reason,
})
} catch (error) {
logger.error('Failed to cancel execution', {
workflowId,
executionId,
error: toError(error).message,
})
return NextResponse.json(
{ error: toError(error).message || 'Failed to cancel execution' },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,232 @@
import { db } from '@sim/db'
import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getWorkflowExecutionContract,
type WorkflowExecutionStatusResponse,
} from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import type { PausePoint } from '@/executor/types'
const logger = createLogger('WorkflowExecutionStatusAPI')
type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
interface TraceSpanShape {
blockId?: string
output?: Record<string, unknown>
children?: TraceSpanShape[]
}
interface ExecutionDataShape {
finalOutput?: { error?: string } & Record<string, unknown>
error?: { message?: string } | string
completionFailure?: string
traceSpans?: TraceSpanShape[]
}
function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map<string, unknown> {
const map = new Map<string, unknown>()
const visit = (list?: TraceSpanShape[]): void => {
if (!list) return
for (const span of list) {
if (span.blockId && span.output !== undefined && !map.has(span.blockId)) {
map.set(span.blockId, span.output)
}
if (span.children) visit(span.children)
}
}
visit(spans)
return map
}
function resolvePath(value: unknown, path: string[]): unknown {
let current: unknown = value
for (const segment of path) {
if (current == null || typeof current !== 'object') return undefined
current = (current as Record<string, unknown>)[segment]
}
return current
}
function pickSelectedOutputs(
selectedOutputs: string[],
blockOutputs: Map<string, unknown>
): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const selector of selectedOutputs) {
const [head, ...rest] = selector.split('.')
if (!head) continue
if (!blockOutputs.has(head)) continue
const blockValue = blockOutputs.get(head)
out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest)
}
return out
}
function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null {
const active = points.filter((p) => p.resumeStatus === 'paused')
if (active.length === 0) return null
return active.reduce<PausePoint | null>((best, current) => {
if (!best) return current
if (!current.resumeAt) return best
if (!best.resumeAt) return current
return current.resumeAt < best.resumeAt ? current : best
}, null)
}
function normalizePausePoints(raw: unknown): PausePoint[] {
if (!raw) return []
if (Array.isArray(raw)) return raw as PausePoint[]
if (typeof raw === 'object') return Object.values(raw as Record<string, PausePoint>)
return []
}
function extractError(executionData: unknown): string | null {
if (!executionData || typeof executionData !== 'object') return null
const data = executionData as ExecutionDataShape
if (typeof data.error === 'string') return data.error
if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') {
return data.error.message
}
if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error
if (typeof data.completionFailure === 'string') return data.completionFailure
return null
}
export const GET = withRouteHandler(
async (
request: NextRequest,
context: { params: Promise<{ id: string; executionId: string }> }
) => {
const parsed = await parseRequest(getWorkflowExecutionContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
const { includeOutput, selectedOutputs } = parsed.data.query
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
const [logRow] = await db
.select({
executionId: workflowExecutionLogs.executionId,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
status: workflowExecutionLogs.status,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
costTotal: workflowExecutionLogs.costTotal,
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.executionId, executionId),
eq(workflowExecutionLogs.workflowId, workflowId)
)
)
.limit(1)
if (!logRow) {
return NextResponse.json({ error: 'Execution not found' }, { status: 404 })
}
const [pausedRow] = await db
.select({
id: pausedExecutions.id,
status: pausedExecutions.status,
pausePoints: pausedExecutions.pausePoints,
resumedCount: pausedExecutions.resumedCount,
pausedAt: pausedExecutions.pausedAt,
nextResumeAt: pausedExecutions.nextResumeAt,
})
.from(pausedExecutions)
.where(eq(pausedExecutions.executionId, executionId))
.limit(1)
const isCurrentlyPaused =
!!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed')
let status: WorkflowExecutionStatusResponse['status']
if (isCurrentlyPaused) {
status = 'paused'
} else {
status = logRow.status as LogStatus
}
let paused: WorkflowExecutionStatusResponse['paused'] = null
if (isCurrentlyPaused && pausedRow) {
const points = normalizePausePoints(pausedRow.pausePoints)
const earliest = pickEarliestPausePoint(points)
paused = {
pausedAt: pausedRow.pausedAt.toISOString(),
resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null,
pauseKind: earliest?.pauseKind ?? null,
blockedOnBlockId: earliest?.blockId ?? null,
pausedExecutionId: pausedRow.id,
pausePointCount: points.length,
resumedCount: pausedRow.resumedCount,
}
}
const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
// Heavy execution data may live in object storage; resolve the pointer
// before reading error / finalOutput / traceSpans (no-op for inline rows).
const executionData = (await materializeExecutionData(
logRow.executionData as Record<string, unknown> | null,
{
workspaceId: logRow.workspaceId,
workflowId: logRow.workflowId,
executionId: logRow.executionId,
}
)) as ExecutionDataShape | undefined
const error = status === 'failed' ? extractError(executionData) : null
const finalOutput =
includeOutput && status === 'completed' && executionData
? (executionData.finalOutput ?? null)
: null
const blockOutputs =
selectedOutputs.length > 0
? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans))
: null
const response: WorkflowExecutionStatusResponse = {
executionId: logRow.executionId,
workflowId: logRow.workflowId ?? workflowId,
status,
trigger: logRow.trigger,
level: logRow.level,
startedAt: logRow.startedAt.toISOString(),
endedAt: logRow.endedAt?.toISOString() ?? null,
totalDurationMs: logRow.totalDurationMs ?? null,
paused,
cost,
error,
finalOutput,
blockOutputs,
}
logger.debug('Fetched execution status', {
workflowId,
executionId,
status,
paused: !!paused,
})
return NextResponse.json(response)
}
)
@@ -0,0 +1,266 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
const {
mockAuthorizeWorkflowByWorkspacePermission,
mockGetSession,
mockReadExecutionEventsState,
mockReadExecutionMetaState,
} = vi.hoisted(() => ({
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockGetSession: vi.fn(),
mockReadExecutionEventsState: vi.fn(),
mockReadExecutionMetaState: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@sim/platform-authz/workflow', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))
vi.mock('@/lib/execution/event-buffer', () => ({
readExecutionEventsState: mockReadExecutionEventsState,
readExecutionMetaState: mockReadExecutionMetaState,
}))
import { GET } from './route'
function completedEntry(eventId: number): ExecutionEventEntry {
return {
eventId,
executionId: 'exec-1',
event: {
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
success: true,
output: {},
duration: 10,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
finalBlockLogs: [],
},
},
}
}
describe('execution stream reconnect route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockReadExecutionMetaState.mockResolvedValue({
status: 'found',
meta: { status: 'active', workflowId: 'wf-1' },
})
mockReadExecutionEventsState.mockResolvedValue({ status: 'ok', events: [] })
})
it('drains final events after terminal meta before sending DONE', async () => {
mockReadExecutionMetaState
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'active', workflowId: 'wf-1' },
})
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'complete', workflowId: 'wf-1' },
})
mockReadExecutionEventsState
.mockResolvedValueOnce({ status: 'ok', events: [] })
.mockResolvedValueOnce({ status: 'ok', events: [completedEntry(4)] })
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
const body = await response.text()
const completedIndex = body.indexOf('"type":"execution:completed"')
const doneIndex = body.indexOf('data: [DONE]')
expect(completedIndex).toBeGreaterThanOrEqual(0)
expect(doneIndex).toBeGreaterThan(completedIndex)
expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(1, 'exec-1', 3)
expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(2, 'exec-1', 3)
})
it('errors when terminal metadata has no terminal event to replay', async () => {
mockReadExecutionMetaState
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'active', workflowId: 'wf-1' },
})
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'complete', workflowId: 'wf-1' },
})
mockReadExecutionEventsState
.mockResolvedValueOnce({ status: 'ok', events: [] })
.mockResolvedValueOnce({ status: 'ok', events: [] })
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow(
'Execution reached terminal metadata without a terminal event'
)
})
it('allows replay event id gaps from reserved but unused writer ids', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'ok',
events: [completedEntry(101)],
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('"eventId":101')
expect(body).toContain('data: [DONE]')
})
it('errors when replay events are not strictly increasing', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'ok',
events: [completedEntry(3)],
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow(
'Execution event replay order violation: previous 3, received 3'
)
})
it('returns unavailable when metadata cannot be read', async () => {
mockReadExecutionMetaState.mockResolvedValueOnce({
status: 'unavailable',
error: 'redis unavailable',
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({
error: 'Run buffer temporarily unavailable',
})
})
it('stops after replaying a terminal event even when metadata is still active', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'ok',
events: [completedEntry(4)],
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('"type":"execution:completed"')
expect(body).toContain('data: [DONE]')
expect(mockReadExecutionEventsState).toHaveBeenCalledTimes(1)
expect(mockReadExecutionMetaState).toHaveBeenCalledTimes(1)
})
it('errors the stream when replay events cannot be read', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'unavailable',
error: 'redis read failed',
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow('Execution events unavailable: redis read failed')
})
it('errors the stream when requested events were pruned', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'pruned',
earliestEventId: 10,
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow(
'Execution events pruned before requested event id'
)
})
})
@@ -0,0 +1,230 @@
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { type NextRequest, NextResponse } from 'next/server'
import { streamWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
type ExecutionEventEntry,
type ExecutionStreamStatus,
readExecutionEventsState,
readExecutionMetaState,
} from '@/lib/execution/event-buffer'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
import { formatSSEEvent } from '@/lib/workflows/executor/execution-events'
const logger = createLogger('ExecutionStreamReconnectAPI')
const POLL_INTERVAL_MS = 500
const MAX_POLL_DURATION_MS = 55 * 60 * 1000 // 55 minutes (just under Redis 1hr TTL)
function isTerminalStatus(status: ExecutionStreamStatus): boolean {
return status === 'complete' || status === 'error' || status === 'cancelled'
}
function isTerminalEvent(event: ExecutionEvent): boolean {
return (
event.type === 'execution:completed' ||
event.type === 'execution:error' ||
event.type === 'execution:cancelled' ||
event.type === 'execution:paused'
)
}
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => {
const parsed = await parseRequest(streamWorkflowExecutionContract, req, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
const { from: fromEventId } = parsed.data.query
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: session.user.id,
action: 'read',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
const metaResult = await readExecutionMetaState(executionId)
if (metaResult.status === 'unavailable') {
return NextResponse.json({ error: 'Run buffer temporarily unavailable' }, { status: 503 })
}
if (metaResult.status === 'missing') {
return NextResponse.json({ error: 'Run buffer not found or expired' }, { status: 404 })
}
const { meta } = metaResult
if (meta.workflowId && meta.workflowId !== workflowId) {
return NextResponse.json({ error: 'Run does not belong to this workflow' }, { status: 403 })
}
logger.info('Reconnection stream requested', {
workflowId,
executionId,
fromEventId,
metaStatus: meta.status,
})
const encoder = new TextEncoder()
let closed = false
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let lastEventId = fromEventId
const pollDeadline = Date.now() + MAX_POLL_DURATION_MS
const enqueue = (text: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(text))
} catch {
closed = true
}
}
const readEventsOrThrow = async (
afterEventId: number
): Promise<ExecutionEventEntry[]> => {
const result = await readExecutionEventsState(executionId, afterEventId)
if (result.status === 'unavailable') {
throw new Error(`Execution events unavailable: ${result.error}`)
}
if (result.status === 'pruned') {
throw new Error(
`Execution events pruned before requested event id: earliest retained event is ${result.earliestEventId}`
)
}
let previousEventId = afterEventId
for (const entry of result.events) {
if (entry.eventId <= previousEventId) {
throw new Error(
`Execution event replay order violation: previous ${previousEventId}, received ${entry.eventId}`
)
}
previousEventId = entry.eventId
}
return result.events
}
const enqueueEvents = (events: ExecutionEventEntry[]) => {
let sawTerminalEvent = false
for (const entry of events) {
if (closed) break
entry.event.eventId = entry.eventId
enqueue(formatSSEEvent(entry.event))
lastEventId = entry.eventId
sawTerminalEvent ||= isTerminalEvent(entry.event)
}
return sawTerminalEvent
}
const closeWithDone = () => {
enqueue('data: [DONE]\n\n')
if (!closed) controller.close()
}
const closeAfterTerminalEvent = (events: ExecutionEventEntry[]) => {
if (!enqueueEvents(events)) {
throw new Error('Execution reached terminal metadata without a terminal event')
}
closeWithDone()
}
try {
const events = await readEventsOrThrow(lastEventId)
if (enqueueEvents(events)) {
closeWithDone()
return
}
const currentMeta = await readExecutionMetaState(executionId)
if (currentMeta.status === 'unavailable') {
throw new Error(`Execution metadata unavailable: ${currentMeta.error}`)
}
if (currentMeta.status === 'missing' || isTerminalStatus(currentMeta.meta.status)) {
const finalEvents = await readEventsOrThrow(lastEventId)
closeAfterTerminalEvent(finalEvents)
return
}
while (!closed && Date.now() < pollDeadline) {
await sleep(POLL_INTERVAL_MS)
if (closed) return
const newEvents = await readEventsOrThrow(lastEventId)
if (enqueueEvents(newEvents)) {
closeWithDone()
return
}
const polledMeta = await readExecutionMetaState(executionId)
if (polledMeta.status === 'unavailable') {
throw new Error(`Execution metadata unavailable: ${polledMeta.error}`)
}
if (polledMeta.status === 'missing' || isTerminalStatus(polledMeta.meta.status)) {
const finalEvents = await readEventsOrThrow(lastEventId)
closeAfterTerminalEvent(finalEvents)
return
}
}
if (!closed) {
logger.warn('Reconnection stream poll deadline reached', { executionId })
throw new Error('Execution stream ended before a terminal event was available')
}
} catch (error) {
logger.error('Error in reconnection stream', {
executionId,
error: toError(error).message,
})
if (!closed) {
try {
controller.error(error)
} catch {}
}
}
},
cancel() {
closed = true
logger.info('Client disconnected from reconnection stream', { executionId })
},
})
return new NextResponse(stream, {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
} catch (error: any) {
logger.error('Failed to start reconnection stream', {
workflowId,
executionId,
error: error.message,
})
return NextResponse.json(
{ error: error.message || 'Failed to start reconnection stream' },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Override global db mock with the configurable chain mock
vi.mock('@sim/db', () => dbChainMock)
const { mockValidateWorkflowAccess, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
mockValidateWorkflowAccess: vi.fn(),
mockGetWorkspaceBilledAccountUserId: vi.fn(),
}))
vi.mock('@/app/api/workflows/middleware', () => ({
validateWorkflowAccess: mockValidateWorkflowAccess,
}))
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
vi.mock('@/lib/logs/execution/logging-session', () => ({
LoggingSession: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
markAsFailed: vi.fn().mockResolvedValue(undefined),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
safeComplete: vi.fn().mockResolvedValue(undefined),
})),
}))
vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
buildTraceSpans: vi.fn().mockReturnValue([]),
}))
import { POST } from './route'
const makeRequest = (workflowId: string, body: unknown) =>
new NextRequest(`http://localhost/api/workflows/${workflowId}/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const validResult = { success: true, output: { value: 42 } }
describe('POST /api/workflows/[id]/log cross-tenant guard', () => {
const OWNER_WORKFLOW_ID = 'wf-owner'
const ATTACKER_WORKFLOW_ID = 'wf-attacker'
const VICTIM_EXECUTION_ID = 'exec-victim-uuid'
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockValidateWorkflowAccess.mockResolvedValue({ error: null })
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
// Default: no existing log (fresh execution)
dbChainMockFns.limit.mockResolvedValue([])
})
it('returns 404 when executionId belongs to a different workflow', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])
const res = await POST(
makeRequest(ATTACKER_WORKFLOW_ID, {
executionId: VICTIM_EXECUTION_ID,
result: validResult,
}),
{ params: Promise.resolve({ id: ATTACKER_WORKFLOW_ID }) }
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBe('Execution not found')
})
it('proceeds when executionId belongs to the same workflow', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])
const res = await POST(
makeRequest(OWNER_WORKFLOW_ID, {
executionId: VICTIM_EXECUTION_ID,
result: validResult,
}),
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
)
expect(res.status).not.toBe(404)
expect(res.status).not.toBe(403)
})
it('proceeds when executionId has no existing log row (fresh execution)', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const res = await POST(
makeRequest(OWNER_WORKFLOW_ID, {
executionId: 'brand-new-execution-id',
result: validResult,
}),
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
)
expect(res.status).not.toBe(404)
expect(res.status).not.toBe(403)
})
})
@@ -0,0 +1,132 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { workflowLogContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('WorkflowLogAPI')
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id } = await context.params
try {
const accessValidation = await validateWorkflowAccess(request, id, false)
if (accessValidation.error) {
logger.warn(
`[${requestId}] Workflow access validation failed: ${accessValidation.error.message}`
)
return createErrorResponse(accessValidation.error.message, accessValidation.error.status)
}
const parsed = await parseRequest(workflowLogContract, request, context)
if (!parsed.success) return parsed.response
const { logs, executionId, result } = parsed.data.body
if (result) {
if (!executionId) {
logger.warn(`[${requestId}] Missing executionId for result logging`)
return createErrorResponse('executionId is required when logging results', 400)
}
const [existingLog] = await db
.select({ workflowId: workflowExecutionLogs.workflowId })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
if (existingLog && existingLog.workflowId !== id) {
logger.warn(
`[${requestId}] executionId ${executionId} belongs to workflow ${existingLog.workflowId}, not ${id}`
)
return createErrorResponse('Execution not found', 404)
}
logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, {
executionId,
success: result.success,
})
const isChatExecution = result.metadata?.source === 'chat'
const triggerType = isChatExecution ? 'chat' : 'manual'
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
const workspaceId = accessValidation.workflow.workspaceId
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
return createErrorResponse('Workflow has no associated workspace', 500)
}
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (!billedAccountUserId) {
logger.error(
`[${requestId}] Unable to resolve billed account for workspace ${workspaceId}`
)
return createErrorResponse('Unable to resolve billing account for this workspace', 500)
}
await loggingSession.safeStart({
userId: billedAccountUserId,
workspaceId,
variables: {},
})
const resultWithOutput = {
...result,
output: result.output ?? {},
}
const { traceSpans, totalDuration } = buildTraceSpans(resultWithOutput as ExecutionResult)
if (result.success === false) {
const message = result.error || 'Workflow run failed'
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || result.metadata?.duration || 0,
error: { message },
traceSpans,
})
} else {
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || result.metadata?.duration || 0,
finalOutput: result.output || {},
traceSpans,
})
}
return createSuccessResponse({
message: 'Run logs persisted successfully',
})
}
if (!logs || !Array.isArray(logs) || logs.length === 0) {
logger.warn(`[${requestId}] No logs provided for workflow: ${id}`)
return createErrorResponse('No logs provided', 400)
}
logger.info(`[${requestId}] Persisting ${logs.length} logs for workflow: ${id}`, {
executionId,
})
return createSuccessResponse({ message: 'Logs persisted successfully' })
} catch (error: any) {
logger.error(`[${requestId}] Error persisting logs for workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to persist logs', 500)
}
}
)
@@ -0,0 +1,36 @@
import { type NextRequest, NextResponse } from 'next/server'
import { pausedWorkflowExecutionByIdContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(
async (
request: NextRequest,
context: { params: Promise<{ id: string; executionId: string }> }
) => {
const parsed = await parseRequest(pausedWorkflowExecutionByIdContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
const detail = await PauseResumeManager.getPausedExecutionDetail({
workflowId,
executionId,
})
if (!detail) {
return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 })
}
return NextResponse.json(detail)
}
)
@@ -0,0 +1,31 @@
import { type NextRequest, NextResponse } from 'next/server'
import { pausedWorkflowExecutionsContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const parsed = await parseRequest(pausedWorkflowExecutionsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
const { status: statusFilter } = parsed.data.query
const pausedExecutions = await PauseResumeManager.listPausedExecutions({
workflowId,
status: statusFilter,
})
return NextResponse.json({ pausedExecutions })
}
)
@@ -0,0 +1,91 @@
import { createLogger } from '@sim/logger'
import {
assertFolderMutable,
FolderLockedError,
WorkflowLockedError,
} from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { restoreWorkflowContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performRestoreWorkflow } from '@/lib/workflows/orchestration'
import { getWorkflowById } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreWorkflowAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const parsed = await parseRequest(restoreWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const workflowData = await getWorkflowById(workflowId, { includeArchived: true })
if (!workflowData) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (workflowData.workspaceId) {
const permission = await getUserEntityPermissions(
auth.userId,
'workspace',
workflowData.workspaceId
)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
} else if (workflowData.userId !== auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (workflowData.locked) {
throw new WorkflowLockedError('Workflow is locked')
}
await assertFolderMutable(workflowData.folderId)
const result = await performRestoreWorkflow({
workflowId,
userId: auth.userId,
requestId,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
logger.info(`[${requestId}] Restored workflow ${workflowId}`)
captureServerEvent(
auth.userId,
'workflow_restored',
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
)
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Internal server error') },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,938 @@
/**
* Integration tests for workflow by ID API route
* Tests the new centralized permissions system
*
* @vitest-environment node
*/
import {
auditMock,
envMock,
hybridAuthMockFns,
telemetryMock,
workflowAuthzMockFns,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows'
const mockLoadWorkflowFromNormalizedTables =
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables
const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById
const mockAuthorizeWorkflowByWorkspacePermission =
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow
const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow
const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({
mockDbUpdate: vi.fn(),
mockDbSelect: vi.fn(),
mockDbTransaction: vi.fn(),
}))
/**
* Helper to set mock auth state consistently across getSession and hybrid auth.
*/
function mockGetSession(session: { user: { id: string } } | null) {
if (session) {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: session.user.id,
})
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: session.user.id,
})
} else {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: false })
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
}
}
vi.mock('@/lib/core/config/env', () => envMock)
vi.mock('@/lib/core/telemetry', () => telemetryMock)
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
vi.mock('@sim/db', () => ({
db: {
update: () => mockDbUpdate(),
select: () => mockDbSelect(),
transaction: mockDbTransaction,
},
workflow: {},
}))
import { DELETE, GET, PUT } from './route'
describe('Workflow By ID API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'),
})
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
mockPerformUpdateWorkflow.mockImplementation(async (params) => ({
success: true,
workflow: {
id: params.workflowId,
name: params.name ?? params.currentName,
description: params.description ?? null,
workspaceId: params.workspaceId,
folderId: params.folderId ?? params.currentFolderId ?? null,
sortOrder: params.sortOrder ?? null,
locked: params.locked ?? null,
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
},
}))
mockDbTransaction.mockImplementation(async (callback) =>
callback({
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
})
)
})
describe('GET /api/workflows/[id]', () => {
it('should return 401 when user is not authenticated', async () => {
mockGetSession(null)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when workflow does not exist', async () => {
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent')
const params = Promise.resolve({ id: 'nonexistent' })
const response = await GET(req, { params })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Workflow not found')
})
it.concurrent('should allow access when user has admin workspace permission', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const mockNormalizedData = {
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.data.id).toBe('workflow-123')
})
it('omits null workflow description from state metadata so response validates', async () => {
const mockWorkflow = {
id: 'workflow-null-description',
userId: 'user-123',
name: 'No Description Workflow',
description: null,
workspaceId: 'workspace-456',
folderId: null,
sortOrder: 0,
color: '#3972F6',
lastSynced: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isDeployed: false,
deployedAt: null,
isPublicApi: false,
locked: false,
runCount: 0,
lastRunAt: null,
archivedAt: null,
variables: {},
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
mockLoadWorkflowFromNormalizedTables.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-null-description')
const params = Promise.resolve({ id: 'workflow-null-description' })
const response = await GET(req, { params })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.state.metadata).toEqual({ name: 'No Description Workflow' })
expect(getWorkflowResponseDataSchema.safeParse(data.data).success).toBe(true)
})
it.concurrent('should allow access when user has workspace permissions', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const mockNormalizedData = {
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'read',
})
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.data.id).toBe('workflow-123')
})
it('should deny access when user has no workspace permissions', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to read this workflow',
workflow: mockWorkflow,
workspacePermission: null,
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
})
it.concurrent('should use normalized tables when available', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const mockNormalizedData = {
blocks: { 'block-1': { id: 'block-1', type: 'starter' } },
edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks)
expect(data.data.state.edges).toEqual(mockNormalizedData.edges)
})
})
describe('DELETE /api/workflows/[id]', () => {
it('should allow admin to delete workflow', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-123',
})
)
})
it('should allow admin to delete workspace workflow', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
})
it('should prevent deletion of the last workflow in workspace', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
mockPerformDeleteWorkflow.mockResolvedValue({
success: false,
error: 'Cannot delete the only workflow in the workspace',
errorCode: 'validation',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
})
it('should allow user with write permission to delete workflow', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: 'workflow-123', action: 'write' })
)
})
it.concurrent('should deny deletion for read-only users', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to write this workflow',
workflow: mockWorkflow,
workspacePermission: 'read',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
})
})
describe('PUT /api/workflows/[id]', () => {
function mockDuplicateCheck(results: Array<{ id: string }> = []) {
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(results),
}),
}),
})
}
it('should allow user with write permission to update workflow', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const updateData = { name: 'Updated Workflow' }
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify(updateData),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.name).toBe('Updated Workflow')
})
it('should allow users with write permission to update workflow', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const updateData = { name: 'Updated Workflow' }
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify(updateData),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.name).toBe('Updated Workflow')
})
it('should deny update for users with only read permission', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const updateData = { name: 'Updated Workflow' }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to write this workflow',
workflow: mockWorkflow,
workspacePermission: 'read',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify(updateData),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
})
it.concurrent('should validate request data', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
const invalidData = { name: '' }
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify(invalidData),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Validation error')
})
it('should reject rename when duplicate name exists in same folder', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Original Name',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockPerformUpdateWorkflow.mockResolvedValueOnce({
success: false,
error: 'A workflow named "Duplicate Name" already exists in this folder',
errorCode: 'conflict',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ name: 'Duplicate Name' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder')
})
it('should reject rename when duplicate name exists at root level', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Original Name',
folderId: null,
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockPerformUpdateWorkflow.mockResolvedValueOnce({
success: false,
error: 'A workflow named "Duplicate Name" already exists in this folder',
errorCode: 'conflict',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ name: 'Duplicate Name' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder')
})
it('should allow rename when no duplicate exists in same folder', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Original Name',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
const updatedWorkflow = { ...mockWorkflow, name: 'Unique Name', updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ name: 'Unique Name' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.name).toBe('Unique Name')
})
it('should allow same name in different folders', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'My Workflow',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() }
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
// No duplicate in target folder
mockDuplicateCheck([])
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ folderId: 'folder-2' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.workflow.folderId).toBe('folder-2')
})
it('should reject moving to a folder where same name already exists', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'My Workflow',
folderId: 'folder-1',
workspaceId: 'workspace-456',
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockPerformUpdateWorkflow.mockResolvedValueOnce({
success: false,
error: 'A workflow named "My Workflow" already exists in this folder',
errorCode: 'conflict',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ folderId: 'folder-2' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A workflow named "My Workflow" already exists in this folder')
})
it('should skip duplicate check when only updating non-name/non-folder fields', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
const updatedWorkflow = {
...mockWorkflow,
description: 'Updated description',
updatedAt: new Date(),
}
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDbUpdate.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
}),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
body: JSON.stringify({ description: 'Updated description' }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await PUT(req, { params })
expect(response.status).toBe(200)
// db.select should NOT have been called since no name/folder change
expect(mockDbSelect).not.toHaveBeenCalled()
})
})
describe('Error handling', () => {
it.concurrent('should handle database errors gracefully', async () => {
mockGetSession({ user: { id: 'user-123' } })
mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout'))
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(500)
const data = await response.json()
expect(data.error).toBe('Internal server error')
})
})
})
+355
View File
@@ -0,0 +1,355 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
assertFolderMutable,
assertWorkflowMutable,
authorizeWorkflowByWorkspacePermission,
FolderLockedError,
WorkflowLockedError,
} from '@sim/platform-authz/workflow'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateWorkflowContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowByIdAPI')
/**
* GET /api/workflows/[id]
* Fetch a single workflow by ID
* Uses hybrid approach: try normalized tables first, fallback to JSON blob
*/
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const startTime = Date.now()
const { id: workflowId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const isInternalCall = auth.authType === AuthType.INTERNAL_JWT
const userId = auth.userId || null
let workflowData = await getWorkflowById(workflowId)
if (!workflowData) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) {
return NextResponse.json(
{ error: 'API key is not authorized for this workspace' },
{ status: 403 }
)
}
if (isInternalCall && !userId) {
// Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId.
// These are already authenticated via internal JWT; allow read access.
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
} else if (!userId) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} else {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.workflow) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
workflowData = authorization.workflow
if (!authorization.allowed) {
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status }
)
}
}
const snapshot = await db.transaction(async (tx) => {
await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`)
const [normalizedData, [workflowRecord]] = await Promise.all([
loadWorkflowFromNormalizedTables(workflowId, tx),
tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1),
])
return { normalizedData, workflowRecord }
})
const responseWorkflowData = snapshot.workflowRecord ?? workflowData
// Stamp `workflowId` from the path param on each variable so the
// global client-side variables store can filter by workflow without
// requiring persisted variables to carry a redundant `workflowId`.
// The persisted blob may or may not include `workflowId` depending on
// when the variable was last written; the path param is authoritative.
const persistedVariables =
(responseWorkflowData.variables as Record<string, Record<string, unknown>>) || {}
const stampedVariables: Record<string, Record<string, unknown>> = {}
for (const [variableId, variable] of Object.entries(persistedVariables)) {
if (variable && typeof variable === 'object') {
stampedVariables[variableId] = { ...variable, workflowId }
}
}
const workflowStateMetadata = {
name: responseWorkflowData.name,
...(typeof responseWorkflowData.description === 'string'
? { description: responseWorkflowData.description }
: {}),
}
if (snapshot.normalizedData) {
const finalWorkflowData = {
...responseWorkflowData,
state: {
blocks: snapshot.normalizedData.blocks,
edges: snapshot.normalizedData.edges,
loops: snapshot.normalizedData.loops,
parallels: snapshot.normalizedData.parallels,
lastSaved: Date.now(),
isDeployed: responseWorkflowData.isDeployed || false,
deployedAt: responseWorkflowData.deployedAt,
metadata: workflowStateMetadata,
},
variables: stampedVariables,
}
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
}
const emptyWorkflowData = {
...responseWorkflowData,
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
lastSaved: Date.now(),
isDeployed: responseWorkflowData.isDeployed || false,
deployedAt: responseWorkflowData.deployedAt,
metadata: workflowStateMetadata,
},
variables: stampedVariables,
}
return NextResponse.json({ data: emptyWorkflowData }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
/**
* DELETE /api/workflows/[id]
* Delete a workflow by ID
*/
export const DELETE = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const startTime = Date.now()
const { id: workflowId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
const workflowData = authorization.workflow || (await getWorkflowById(workflowId))
if (!workflowData) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const canDelete = authorization.allowed
if (!canDelete) {
logger.warn(
`[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
)
}
await assertWorkflowMutable(workflowId)
const result = await performDeleteWorkflow({
workflowId,
userId,
requestId,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
captureServerEvent(
userId,
'workflow_deleted',
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
)
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
/**
* PUT /api/workflows/[id]
* Update workflow metadata (name, description, folderId)
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const startTime = Date.now()
const { id: workflowId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const parsed = await parseRequest(updateWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const updates = parsed.data.body
// Fetch the workflow to check ownership/access
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
const workflowData = authorization.workflow || (await getWorkflowById(workflowId))
if (!workflowData) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const canUpdate = authorization.allowed
if (!canUpdate) {
logger.warn(
`[${requestId}] User ${userId} denied permission to update workflow ${workflowId}`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
)
}
if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') {
logger.warn(
`[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}`
)
return NextResponse.json(
{ error: 'Admin access required to lock workflows' },
{ status: 403 }
)
}
const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked')
if (hasNonLockUpdate) {
await assertWorkflowMutable(workflowId)
}
if (updates.folderId !== undefined) {
await assertFolderMutable(updates.folderId)
}
if (!workflowData.workspaceId) {
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const result = await performUpdateWorkflow({
workflowId,
userId,
workspaceId: workflowData.workspaceId,
currentName: workflowData.name,
currentFolderId: workflowData.folderId,
currentLocked: workflowData.locked,
...updates,
requestId,
})
if (!result.success || !result.workflow) {
const status =
result.errorCode === 'not_found'
? 404
: result.errorCode === 'conflict'
? 409
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json({ error: result.error }, { status })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, {
updates,
})
return NextResponse.json({ workflow: result.workflow }, { status: 200 })
} catch (error: any) {
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,324 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
assertWorkflowMutable,
authorizeWorkflowByWorkspacePermission,
WorkflowLockedError,
} from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowStateAPI')
/**
* GET /api/workflows/[id]/state
* Fetch the current workflow state from normalized tables.
* Used by the client after server-side edits (edit_workflow) to stay in sync.
*/
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const { id: workflowId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const snapshot = await db.transaction(async (tx) => {
await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`)
const [normalized, [workflowRecord]] = await Promise.all([
loadWorkflowFromNormalizedTables(workflowId, tx),
tx
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1),
])
return { normalized, variables: workflowRecord?.variables }
})
if (!snapshot.normalized) {
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
}
// Stamp `workflowId` from the path param on each variable so the
// global client-side variables store can filter by workflow without
// requiring clients to thread the path param through. The read
// contract requires this server-stamped field.
const persistedVariables =
(snapshot.variables as Record<string, Record<string, unknown>>) || {}
const variables: Record<string, Record<string, unknown>> = {}
for (const [variableId, variable] of Object.entries(persistedVariables)) {
if (variable && typeof variable === 'object') {
variables[variableId] = { ...variable, workflowId }
}
}
return NextResponse.json({
blocks: snapshot.normalized.blocks,
edges: snapshot.normalized.edges,
loops: snapshot.normalized.loops || {},
parallels: snapshot.normalized.parallels || {},
variables,
})
} catch (error) {
logger.error('Failed to fetch workflow state', {
workflowId,
error: toError(error).message,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
/**
* PUT /api/workflows/[id]/state
* Save complete workflow state to normalized database tables
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const startTime = Date.now()
const { id: workflowId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const parsed = await parseRequest(putWorkflowNormalizedStateContract, request, context)
if (!parsed.success) return parsed.response
const state = parsed.data.body
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
const workflowData = authorization.workflow
if (!workflowData) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const canUpdate = authorization.allowed
if (!canUpdate) {
logger.warn(
`[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
)
}
await assertWorkflowMutable(workflowId)
// Note: prior versions cross-checked that each variable's `workflowId`
// equalled the path param. The write contract does not carry `workflowId`
// per variable (the path param is the source of truth), so the check
// is unreachable and was removed.
// Sanitize custom tools in agent blocks before saving
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
state.blocks as Record<string, BlockState>
)
// Save to normalized tables
// Ensure all required fields are present for WorkflowState type
// Filter out blocks without type or name before saving
const filteredBlocks = Object.entries(sanitizedBlocks).reduce(
(acc, [blockId, block]: [string, BlockState]) => {
if (block.type && block.name) {
// Ensure all required fields are present
acc[blockId] = {
...block,
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles:
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 0,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
}
}
return acc
},
{} as typeof state.blocks
)
const typedBlocks = filteredBlocks as Record<string, BlockState>
const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks)
const validationWarnings = validatedEdges.dropped.map(
({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`
)
const canonicalLoops = generateLoopBlocks(typedBlocks)
const canonicalParallels = generateParallelBlocks(typedBlocks)
const workflowState = {
blocks: filteredBlocks,
edges: validatedEdges.valid,
loops: canonicalLoops,
parallels: canonicalParallels,
lastSaved: state.lastSaved || Date.now(),
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,
}
const saveResult = await db.transaction(async (tx) => {
await tx
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
.for('update')
const result = await saveWorkflowToNormalizedTables(
workflowId,
workflowState as WorkflowState,
tx
)
if (!result.success) return result
// Update workflow's lastSynced timestamp and variables if provided
const updateData: {
lastSynced: Date
updatedAt: Date
variables?: typeof state.variables
} = {
lastSynced: new Date(),
updatedAt: new Date(),
}
// If variables are provided in the state, update them in the workflow record
if (state.variables !== undefined) {
updateData.variables = state.variables
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
return result
})
if (!saveResult.success) {
logger.error(
`[${requestId}] Failed to save workflow ${workflowId} state:`,
saveResult.error
)
return NextResponse.json(
{ error: 'Failed to save workflow state', details: saveResult.error },
{ status: 500 }
)
}
// Extract and persist custom tools to database
try {
const workspaceId = workflowData.workspaceId
if (workspaceId) {
const { saved, errors } = await extractAndPersistCustomTools(
workflowState,
workspaceId,
userId
)
if (saved > 0) {
logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, {
workflowId,
})
}
if (errors.length > 0) {
logger.warn(`[${requestId}] Some custom tools failed to persist`, {
errors,
workflowId,
})
}
} else {
logger.warn(
`[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`,
{
workflowId,
}
)
}
} catch (error) {
logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`)
try {
const notifyResponse = await fetch(`${getSocketServerUrl()}/api/workflow-updated`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId }),
})
if (!notifyResponse.ok) {
logger.warn(
`[${requestId}] Failed to notify Socket.IO server about workflow ${workflowId} update`
)
}
} catch (notificationError) {
logger.warn(
`[${requestId}] Error notifying Socket.IO server about workflow ${workflowId} update`,
notificationError
)
}
return NextResponse.json(
{ success: true, warnings: [...warnings, ...validationWarnings] },
{ status: 200 }
)
} catch (error: any) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const elapsed = Date.now() - startTime
logger.error(
`[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`,
error
)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,45 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { getWorkflowStatusContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowStatusAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const parsed = await parseRequest(getWorkflowStatusContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
try {
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
}
const needsRedeployment = validation.workflow.isDeployed
? await checkNeedsRedeployment(id)
: false
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,
isPublished: validation.workflow.isPublished,
needsRedeployment,
})
} catch (error) {
logger.error(`[${requestId}] Error getting status for workflow: ${id}`, error)
return createErrorResponse('Failed to get status', 500)
}
}
)
@@ -0,0 +1,361 @@
/**
* Tests for workflow variables API route
* Tests the optimized permissions and caching system
*
* @vitest-environment node
*/
import {
auditMock,
hybridAuthMockFns,
workflowAuthzMockFns,
workflowsUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getWorkflowVariablesContract } from '@/lib/api/contracts/workflows'
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { GET, POST } from '@/app/api/workflows/[id]/variables/route'
describe('Workflow Variables API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/workflows/[id]/variables', () => {
it('should return 401 when user is not authenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when workflow does not exist', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 404,
message: 'Workflow not found',
workflow: null,
workspacePermission: null,
})
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent/variables')
const params = Promise.resolve({ id: 'nonexistent' })
const response = await GET(req, { params })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Workflow not found')
})
it('should allow access when user has workspace permission', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
workspaceId: 'workspace-456',
variables: {
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.data).toEqual({
'var-1': {
id: 'var-1',
name: 'test',
type: 'string',
value: 'hello',
workflowId: 'workflow-123',
},
})
const parsed = getWorkflowVariablesContract.response.schema.parse(data)
expect(parsed.data['var-1'].workflowId).toBe('workflow-123')
})
it('should allow access when user has workspace permissions', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
workspaceId: 'workspace-456',
variables: {
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'read',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
// GET stamps `workflowId` from the path param on each variable.
expect(data.data).toEqual({
'var-1': {
id: 'var-1',
name: 'test',
type: 'string',
value: 'hello',
workflowId: 'workflow-123',
},
})
})
it('should deny access when user has no workspace permissions', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
workspaceId: 'workspace-456',
variables: {},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to read this workflow',
workflow: mockWorkflow,
workspacePermission: null,
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
})
it('should include proper cache headers', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
workspaceId: 'workspace-456',
variables: {
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'admin',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(200)
expect(response.headers.get('Cache-Control')).toBe('max-age=30, stale-while-revalidate=300')
expect(response.headers.get('ETag')).toMatch(/^"variables-workflow-123-\d+"$/)
})
})
describe('POST /api/workflows/[id]/variables', () => {
it('should allow user with write permission to update variables', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
workspaceId: 'workspace-456',
variables: {},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
const variables = {
'var-1': {
id: 'var-1',
workflowId: 'workflow-123',
name: 'test',
type: 'string',
value: 'hello',
},
}
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
method: 'POST',
body: JSON.stringify({ variables }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
})
it('should deny access for users without permissions', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'other-user',
workspaceId: 'workspace-456',
variables: {},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to write this workflow',
workflow: mockWorkflow,
workspacePermission: null,
})
const variables = {
'var-1': {
id: 'var-1',
workflowId: 'workflow-123',
name: 'test',
type: 'string',
value: 'hello',
},
}
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
method: 'POST',
body: JSON.stringify({ variables }),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await POST(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
})
it('should validate request data schema', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
workspaceId: 'workspace-456',
variables: {},
}
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: true,
status: 200,
workflow: mockWorkflow,
workspacePermission: 'write',
})
const invalidData = { variables: [{ name: 'test' }] }
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
method: 'POST',
body: JSON.stringify(invalidData),
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await POST(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Validation error')
})
})
describe('Error handling', () => {
it('should handle database errors gracefully', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce(
new Error('Database connection failed')
)
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
const params = Promise.resolve({ id: 'workflow-123' })
const response = await GET(req, { params })
expect(response.status).toBe(500)
const data = await response.json()
expect(data.error).toBe('Database connection failed')
})
})
})
@@ -0,0 +1,176 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
assertWorkflowMutable,
authorizeWorkflowByWorkspacePermission,
WorkflowLockedError,
} from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { workflowVariablesContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Variable } from '@/stores/variables/types'
const logger = createLogger('WorkflowVariablesAPI')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const workflowId = (await context.params).id
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
const workflowData = authorization.workflow
if (!workflowData) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const isAuthorized = authorization.allowed
if (!isAuthorized) {
logger.warn(
`[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
)
}
await assertWorkflowMutable(workflowId)
const parsed = await parseRequest(workflowVariablesContract, req, context)
if (!parsed.success) return parsed.response
const { variables } = parsed.data.body
// Note: prior versions cross-checked that each variable's `workflowId`
// equalled the path param. The write contract does not carry `workflowId`
// per variable (the path param is the source of truth), so the check
// is unreachable and was removed.
// Variables are already in Record format - use directly
// The frontend is the source of truth for what variables should exist
await db
.update(workflow)
.set({
variables,
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))
recordAudit({
workspaceId: workflowData.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowData.name ?? undefined,
description: `Updated workflow variables`,
metadata: {
variableCount: Object.keys(variables).length,
variableNames: Object.values(variables).map((v) => v.name),
workflowName: workflowData.name ?? undefined,
},
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error(`[${requestId}] Error updating workflow variables`, error)
return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 })
}
}
)
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const workflowId = (await params).id
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
const workflowData = authorization.workflow
if (!workflowData) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const isAuthorized = authorization.allowed
if (!isAuthorized) {
logger.warn(
`[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
)
}
// Return variables if they exist. Stamp `workflowId` from the path
// param on each entry so the global client-side variables store can
// filter by workflow; the read contract requires this stamped field.
const persistedVariables =
(workflowData.variables as Record<string, Record<string, unknown>>) || {}
const variables: Record<string, Variable> = {}
for (const [variableId, variable] of Object.entries(persistedVariables)) {
if (variable && typeof variable === 'object') {
variables[variableId] = { ...variable, workflowId } as Variable
}
}
// Add cache headers to prevent frequent reloading
const variableHash = JSON.stringify(variables).length
const headers = new Headers({
'Cache-Control': 'max-age=30, stale-while-revalidate=300', // Cache for 30 seconds, stale for 5 min
ETag: `"variables-${workflowId}-${variableHash}"`,
})
return NextResponse.json(
{ data: variables },
{
status: 200,
headers,
}
)
} catch (error) {
logger.error(`[${requestId}] Workflow variables fetch error`, error)
const errorMessage = getErrorMessage(error, 'Unknown error')
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
)
@@ -0,0 +1,130 @@
/**
* Tests for workflow access middleware — focused on the workspace-scoped
* API key boundary check in the `requireDeployment=false` branch.
*
* @vitest-environment node
*/
import {
hybridAuthMockFns,
workflowAuthzMock,
workflowAuthzMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock)
vi.mock('@/lib/api-key/service', () => ({
authenticateApiKeyFromHeader: vi.fn(),
updateApiKeyLastUsed: vi.fn(),
}))
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
function makeRequest() {
return new NextRequest(new URL('https://example.com/api/workflows/wf-1/log'))
}
describe('validateWorkflowAccess (requireDeployment=false)', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
id: 'wf-1',
workspaceId: 'ws-A',
isDeployed: true,
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'wf-1', workspaceId: 'ws-A' },
})
})
it('rejects a workspace-scoped API key issued for a different workspace', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'workspace',
workspaceId: 'ws-B',
})
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
expect(result.error).toEqual({
message: 'API key is not authorized for this workspace',
status: 403,
})
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})
it('allows a workspace-scoped API key issued for the matching workspace', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'workspace',
workspaceId: 'ws-A',
})
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
expect(result.error).toBeUndefined()
expect(result.workflow).toBeDefined()
expect(result.auth?.workspaceId).toBe('ws-A')
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
workflowId: 'wf-1',
userId: 'user-1',
action: 'read',
})
})
it('allows a personal API key regardless of workspaceId on the auth result', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'personal',
workspaceId: 'ws-B',
})
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
expect(result.error).toBeUndefined()
expect(result.workflow).toBeDefined()
})
it('allows session auth (no apiKeyType) when workspace permission grants access', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'session',
})
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
expect(result.error).toBeUndefined()
expect(result.workflow).toBeDefined()
})
it('still enforces workspace-permission rejection for personal keys', async () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
authType: 'api_key',
apiKeyType: 'personal',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
message: 'Access denied',
})
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
expect(result.error).toEqual({ message: 'Access denied', status: 403 })
})
})
+144
View File
@@ -0,0 +1,144 @@
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import type { NextRequest } from 'next/server'
import {
type ApiKeyAuthResult,
authenticateApiKeyFromHeader,
updateApiKeyLastUsed,
} from '@/lib/api-key/service'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { getWorkflowById } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowMiddleware')
export interface ValidationResult {
error?: { message: string; status: number }
workflow?: any
auth?: AuthResult
}
export async function validateWorkflowAccess(
request: NextRequest,
workflowId: string,
requireDeployment = true
): Promise<ValidationResult> {
try {
const workflow = await getWorkflowById(workflowId)
if (!workflow) {
return {
error: {
message: 'Workflow not found',
status: 404,
},
}
}
if (!workflow.workspaceId) {
return {
error: {
message:
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
status: 403,
},
}
}
if (!requireDeployment) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return {
error: {
message: auth.error || 'Unauthorized',
status: 401,
},
}
}
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
return {
error: {
message: 'API key is not authorized for this workspace',
status: 403,
},
}
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed) {
return {
error: {
message: authorization.message || 'Access denied',
status: authorization.status,
},
}
}
return { workflow, auth }
}
if (requireDeployment) {
if (!workflow.isDeployed) {
return {
error: {
message: 'Workflow is not deployed',
status: 403,
},
}
}
let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {
apiKeyHeader = value
break
}
}
if (!apiKeyHeader) {
return {
error: {
message: 'Unauthorized: API key required',
status: 401,
},
}
}
let validResult: ApiKeyAuthResult | null = null
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
workspaceId: workflow.workspaceId as string,
keyTypes: ['workspace', 'personal'],
})
if (workspaceResult.success) {
validResult = workspaceResult
}
if (!validResult) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
}
if (validResult.keyId) {
await updateApiKeyLastUsed(validResult.keyId)
}
}
return { workflow }
} catch (error) {
logger.error('Validation error:', { error })
return {
error: {
message: 'Internal server error',
status: 500,
},
}
}
}
@@ -0,0 +1,99 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
assertFolderInWorkspace,
assertFolderMutable,
assertWorkflowMutable,
FolderLockedError,
FolderNotFoundError,
WorkflowLockedError,
} from '@sim/platform-authz/workflow'
import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowReorderAPI')
export const PUT = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
try {
const parsed = await parseRequest(reorderWorkflowsContract, req, {})
if (!parsed.success) return parsed.response
const { workspaceId, updates } = parsed.data.body
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || permission === 'read') {
logger.warn(
`[${requestId}] User ${userId} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
const workflowIds = updates.map((u) => u.id)
const existingWorkflows = await db
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
.from(workflow)
.where(inArray(workflow.id, workflowIds))
const validIds = new Set(
existingWorkflows.filter((w) => w.workspaceId === workspaceId).map((w) => w.id)
)
const validUpdates = updates.filter((u) => validIds.has(u.id))
if (validUpdates.length === 0) {
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
}
for (const update of validUpdates) {
await assertWorkflowMutable(update.id)
if (update.folderId !== undefined) {
await assertFolderInWorkspace(update.folderId, workspaceId)
await assertFolderMutable(update.folderId)
}
}
await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
sortOrder: update.sortOrder,
updatedAt: new Date(),
}
if (update.folderId !== undefined) {
updateData.folderId = update.folderId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, update.id))
}
})
logger.info(
`[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
} catch (error) {
if (
error instanceof WorkflowLockedError ||
error instanceof FolderLockedError ||
error instanceof FolderNotFoundError
) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error(`[${requestId}] Error reordering workflows`, error)
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
}
})
+182
View File
@@ -0,0 +1,182 @@
/**
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
hybridAuthMockFns,
permissionsMock,
permissionsMockFns,
workflowAuthzMockFns,
workflowsApiUtilsMock,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
} from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockWorkflowCreated, mockDbSelect, mockDbInsert } = vi.hoisted(() => ({
mockWorkflowCreated: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
}))
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
vi.mock('@sim/db', () => ({
db: {
select: (...args: unknown[]) => mockDbSelect(...args),
insert: (...args: unknown[]) => mockDbInsert(...args),
transaction: vi.fn(async (fn: (tx: Record<string, unknown>) => Promise<void>) => {
const tx = {
select: (...args: unknown[]) => mockDbSelect(...args),
insert: (...args: unknown[]) => mockDbInsert(...args),
}
await fn(tx)
}),
},
}))
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: {
workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args),
},
}))
vi.mock('@/lib/workflows/defaults', () => ({
buildDefaultWorkflowArtifacts: vi.fn().mockReturnValue({
workflowState: { blocks: {}, edges: [], loops: {}, parallels: {} },
subBlockValues: {},
startBlockId: 'start-block-id',
}),
}))
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
import { POST } from '@/app/api/workflows/route'
describe('Workflows API Route - POST ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('workflow-new-id'),
})
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-123',
userName: 'Test User',
userEmail: 'test@example.com',
})
mockGetUserEntityPermissions.mockResolvedValue('write')
workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined)
workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables.mockResolvedValue({
success: true,
})
})
it('rejects creating a workflow inside a locked folder', async () => {
const { FolderLockedError } = await import('@sim/platform-authz/workflow')
workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
new FolderLockedError('Folder is locked')
)
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
workspaceId: 'workspace-123',
folderId: 'locked-folder',
})
const response = await POST(req)
expect(response.status).toBe(423)
expect(mockDbInsert).not.toHaveBeenCalled()
})
it('uses top insertion against mixed siblings (folders + workflows)', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [
[],
[{ minOrder: 5 }],
[{ minOrder: 2 }],
]
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) =>
Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled),
})),
}),
}))
let insertedValues: Record<string, unknown> | null = null
mockDbInsert.mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedValues = values
return Promise.resolve(undefined)
}),
})
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
workspaceId: 'workspace-123',
folderId: null,
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.sortOrder).toBe(1)
expect(insertedValues).not.toBeNull()
expect(insertedValues?.sortOrder).toBe(1)
})
it('defaults to sortOrder 0 when there are no siblings', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [[], [], []]
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) =>
Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled),
})),
}),
}))
let insertedValues: Record<string, unknown> | null = null
mockDbInsert.mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedValues = values
return Promise.resolve(undefined)
}),
})
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
workspaceId: 'workspace-123',
folderId: null,
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.sortOrder).toBe(0)
expect(insertedValues?.sortOrder).toBe(0)
})
})
+192
View File
@@ -0,0 +1,192 @@
import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
import { type NextRequest, NextResponse } from 'next/server'
import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performCreateWorkflow } from '@/lib/workflows/orchestration'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
const startTime = Date.now()
const url = new URL(request.url)
const query = workflowListQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries()))
if (!query.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: query.error.issues },
{ status: 400 }
)
}
const { workspaceId, scope } = query.data
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
if (workspaceId) {
const wsExists = await workspaceExists(workspaceId)
if (!wsExists) {
logger.warn(
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
)
return NextResponse.json(
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
{ status: 404 }
)
}
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
if (!userRole) {
logger.warn(
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
)
return NextResponse.json(
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
{ status: 403 }
)
}
}
const workflows = await listWorkflowsForUser({ userId, workspaceId, scope })
return NextResponse.json({ data: workflows }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
// POST /api/workflows - Create a new workflow
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized workflow creation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
try {
const parsed = await parseRequest(createWorkflowContract, req, {})
if (!parsed.success) return parsed.response
const {
id: clientId,
name: requestedName,
description,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
} = parsed.data.body
if (!workspaceId) {
logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`)
return NextResponse.json(
{
error:
'workspaceId is required. Personal workflows are deprecated and cannot be created.',
},
{ status: 400 }
)
}
const workspacePermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!workspacePermission || workspacePermission === 'read') {
logger.warn(
`[${requestId}] User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions`
)
return NextResponse.json(
{ error: 'Write or Admin access required to create workflows in this workspace' },
{ status: 403 }
)
}
await assertFolderMutable(folderId ?? null)
const result = await performCreateWorkflow({
id: clientId,
name: requestedName,
description,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
userId,
requestId,
})
if (!result.success || !result.workflow) {
const status =
result.errorCode === 'conflict' ? 409 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
const createdWorkflow = result.workflow
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId: createdWorkflow.id,
name: createdWorkflow.name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
logger.info(
`[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks`
)
captureServerEvent(
userId,
'workflow_created',
{
workflow_id: createdWorkflow.id,
workspace_id: workspaceId ?? '',
name: createdWorkflow.name,
},
{
groups: workspaceId ? { workspace: workspaceId } : undefined,
setOnce: { first_workflow_created_at: new Date().toISOString() },
}
)
return NextResponse.json({
id: createdWorkflow.id,
name: createdWorkflow.name,
description: createdWorkflow.description,
workspaceId: createdWorkflow.workspaceId,
folderId: createdWorkflow.folderId,
sortOrder: createdWorkflow.sortOrder,
createdAt: createdWorkflow.createdAt,
updatedAt: createdWorkflow.updatedAt,
startBlockId: createdWorkflow.startBlockId,
subBlockValues: createdWorkflow.subBlockValues,
})
} catch (error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error(`[${requestId}] Error creating workflow`, error)
return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 })
}
})
+82
View File
@@ -0,0 +1,82 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowUtils')
export function createErrorResponse(error: string, status: number, code?: string) {
return NextResponse.json(
{
error,
code: code || error.toUpperCase().replace(/\s+/g, '_'),
},
{ status }
)
}
export function createSuccessResponse(data: any) {
return NextResponse.json(data)
}
/**
* Checks whether a deployed workflow has changes that require redeployment.
* Compares the current persisted state (from normalized tables) against the
* active deployment version state.
*
* This is the single source of truth for redeployment detection — used by
* both the /deploy and /status endpoints to ensure consistent results.
*/
/**
* Pure redeployment-change comparison shared by checkNeedsRedeployment and the
* VFS deployment serializer so both surfaces agree. Returns false when either
* side is missing.
*/
export function computeNeedsRedeployment(
currentSnapshot: WorkflowState | null | undefined,
activeState: WorkflowState | null | undefined
): boolean {
if (!activeState || !currentSnapshot) return false
return hasWorkflowChanged(currentSnapshot, activeState)
}
export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> {
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)
const currentState = await loadWorkflowDeploymentSnapshot(workflowId)
return computeNeedsRedeployment(currentState, (active?.state as WorkflowState) ?? null)
}
/**
* Verifies user's workspace permissions using the permissions table
* @param userId User ID to check
* @param workspaceId Workspace ID to check
* @returns Permission type if user has access, null otherwise
*/
export async function verifyWorkspaceMembership(
userId: string,
workspaceId: string
): Promise<string | null> {
try {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
return permission
} catch (error) {
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
return null
}
}