improvement(platform): standardize perms, audit logging, lifecycle across admin, copilot, ui actions (#3858)

* improvement(platform): standardize perms, audit logging, lifecycle mgmt across admin, copilot, ui actions

* address comments

* improve error codes

* address bugbot comments

* fix test
This commit is contained in:
Vikhyath Mondreti
2026-03-30 20:25:38 -07:00
committed by GitHub
parent e9c94fa462
commit 0abeac77e1
34 changed files with 1774 additions and 2414 deletions

View File

@@ -15,12 +15,12 @@ const {
mockLimit,
mockUpdate,
mockSet,
mockDelete,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckChatAccess,
mockDeployWorkflow,
mockPerformChatUndeploy,
mockLogger,
} = vi.hoisted(() => {
const logger = {
@@ -40,12 +40,12 @@ const {
mockLimit: vi.fn(),
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockDelete: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckChatAccess: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatUndeploy: vi.fn(),
mockLogger: logger,
}
})
@@ -66,7 +66,6 @@ vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}))
vi.mock('@sim/db/schema', () => ({
@@ -88,6 +87,9 @@ vi.mock('@/app/api/chat/utils', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performChatUndeploy: mockPerformChatUndeploy,
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -106,7 +108,7 @@ describe('Chat Edit API Route', () => {
mockWhere.mockReturnValue({ limit: mockLimit })
mockUpdate.mockReturnValue({ set: mockSet })
mockSet.mockReturnValue({ where: mockWhere })
mockDelete.mockReturnValue({ where: mockWhere })
mockPerformChatUndeploy.mockResolvedValue({ success: true })
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -428,7 +430,11 @@ describe('Chat Edit API Route', () => {
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockDelete).toHaveBeenCalled()
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'user-id',
workspaceId: 'workspace-123',
})
const data = await response.json()
expect(data.message).toBe('Chat deployment deleted successfully')
})
@@ -451,7 +457,11 @@ describe('Chat Edit API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockDelete).toHaveBeenCalled()
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'admin-user-id',
workspaceId: 'workspace-123',
})
})
})
})

View File

@@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { performChatUndeploy } from '@/lib/workflows/orchestration'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -270,33 +271,25 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401)
}
const {
hasAccess,
chat: chatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
chatId,
session.user.id
)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
await db.delete(chat).where(eq(chat.id, chatId))
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
const result = await performChatUndeploy({
chatId,
userId: session.user.id,
workspaceId: chatWorkspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to delete chat', 500)
}
return createSuccessResponse({
message: 'Chat deployment deleted successfully',
})

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { auditMock, createEnvMock } from '@sim/testing'
import { createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,66 +12,51 @@ const {
mockFrom,
mockWhere,
mockLimit,
mockInsert,
mockValues,
mockReturning,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckWorkflowAccessForChatCreation,
mockDeployWorkflow,
mockPerformChatDeploy,
mockGetSession,
mockUuidV4,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockInsert: vi.fn(),
mockValues: vi.fn(),
mockReturning: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckWorkflowAccessForChatCreation: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatDeploy: vi.fn(),
mockGetSession: vi.fn(),
mockUuidV4: vi.fn(),
}))
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.mock('@sim/db/schema', () => ({
chat: { userId: 'userId', identifier: 'identifier' },
chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
}))
vi.mock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse,
createErrorResponse: mockCreateErrorResponse,
}))
vi.mock('@/lib/core/security/encryption', () => ({
encryptSecret: mockEncryptSecret,
}))
vi.mock('uuid', () => ({
v4: mockUuidV4,
}))
vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
vi.mock('@/lib/workflows/orchestration', () => ({
performChatDeploy: mockPerformChatDeploy,
}))
vi.mock('@/lib/auth', () => ({
@@ -94,10 +79,6 @@ describe('Chat API Route', () => {
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockUuidV4.mockReturnValue('test-uuid')
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -113,12 +94,10 @@ describe('Chat API Route', () => {
})
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockDeployWorkflow.mockResolvedValue({
mockPerformChatDeploy.mockResolvedValue({
success: true,
version: 1,
deployedAt: new Date(),
chatId: 'test-uuid',
chatUrl: 'http://localhost:3000/chat/test-chat',
})
})
@@ -277,7 +256,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -287,6 +265,13 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
identifier: 'test-chat',
})
)
})
it('should allow chat deployment when user has workspace admin permission', async () => {
@@ -309,7 +294,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -319,6 +303,12 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
workspaceId: 'workspace-123',
})
)
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
@@ -383,7 +373,7 @@ describe('Chat API Route', () => {
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should auto-deploy workflow if not already deployed', async () => {
it('should call performChatDeploy for undeployed workflow', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
@@ -403,7 +393,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -412,10 +401,12 @@ describe('Chat API Route', () => {
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockDeployWorkflow).toHaveBeenCalledWith({
workflowId: 'workflow-123',
deployedBy: 'user-id',
})
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
})
)
})
})
})

View File

@@ -3,14 +3,9 @@ import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { performChatDeploy } from '@/lib/workflows/orchestration'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -109,7 +104,6 @@ export async function POST(request: NextRequest) {
)
}
// Check identifier availability and workflow access in parallel
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db
.select()
@@ -127,121 +121,27 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Workflow not found or access denied', 404)
}
// Always deploy/redeploy the workflow to ensure latest version
const result = await deployWorkflow({
workflowId,
deployedBy: session.user.id,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
}
logger.info(
`${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})`
)
// Encrypt password if provided
let encryptedPassword = null
if (authType === 'password' && password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
}
// Create the chat deployment
const id = uuidv4()
// Log the values we're inserting
logger.info('Creating chat deployment with values:', {
workflowId,
identifier,
title,
authType,
hasPassword: !!encryptedPassword,
emailCount: allowedEmails?.length || 0,
outputConfigsCount: outputConfigs.length,
})
// Merge customizations with the additional fields
const mergedCustomizations = {
...(customizations || {}),
primaryColor: customizations?.primaryColor || 'var(--brand-hover)',
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?',
}
await db.insert(chat).values({
id,
const result = await performChatDeploy({
workflowId,
userId: session.user.id,
identifier,
title,
description: description || null,
customizations: mergedCustomizations,
isActive: true,
description,
customizations,
authType,
password: encryptedPassword,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
password,
allowedEmails,
outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),
workspaceId: workflowRecord.workspaceId,
})
// Return successful response with chat URL
// Generate chat URL using path-based routing instead of subdomains
const baseUrl = getBaseUrl()
let chatUrl: string
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) {
host = host.substring(4)
}
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
} catch (error) {
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
baseUrl,
error: error instanceof Error ? error.message : 'Unknown error',
})
// Fallback based on environment
if (isDev) {
chatUrl = `http://localhost:3000/chat/${identifier}`
} else {
chatUrl = `https://sim.ai/chat/${identifier}`
}
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
}
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.chatDeployed({
chatId: id,
workflowId,
authType,
hasOutputConfigs: outputConfigs.length > 0,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
resourceId: id,
resourceName: title,
description: `Deployed chat "${title}"`,
metadata: { workflowId, identifier, authType },
request,
})
return createSuccessResponse({
id,
chatUrl,
id: result.chatId,
chatUrl: result.chatUrl,
message: 'Chat deployment created successfully',
})
} catch (validationError) {

View File

@@ -6,7 +6,14 @@
import { auditMock, createMockRequest, type MockUser } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = vi.hoisted(() => {
const {
mockGetSession,
mockGetUserEntityPermissions,
mockLogger,
mockDbRef,
mockPerformDeleteFolder,
mockCheckForCircularReference,
} = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
@@ -21,6 +28,8 @@ const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } =
mockGetUserEntityPermissions: vi.fn(),
mockLogger: logger,
mockDbRef: { current: null as any },
mockPerformDeleteFolder: vi.fn(),
mockCheckForCircularReference: vi.fn(),
}
})
@@ -39,6 +48,12 @@ vi.mock('@sim/db', () => ({
return mockDbRef.current
},
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performDeleteFolder: mockPerformDeleteFolder,
}))
vi.mock('@/lib/workflows/utils', () => ({
checkForCircularReference: mockCheckForCircularReference,
}))
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
@@ -144,6 +159,11 @@ describe('Individual Folder API Route', () => {
mockGetUserEntityPermissions.mockResolvedValue('admin')
mockDbRef.current = createFolderDbMock()
mockPerformDeleteFolder.mockResolvedValue({
success: true,
deletedItems: { folders: 1, workflows: 0 },
})
mockCheckForCircularReference.mockResolvedValue(false)
})
describe('PUT /api/folders/[id]', () => {
@@ -369,13 +389,17 @@ describe('Individual Folder API Route', () => {
it('should prevent circular references when updating parent', async () => {
mockAuthenticatedUser()
const circularCheckResults = [{ parentId: 'folder-2' }, { parentId: 'folder-3' }]
mockDbRef.current = createFolderDbMock({
folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' },
circularCheckResults,
folderLookupResult: {
id: 'folder-3',
parentId: null,
name: 'Folder 3',
workspaceId: 'workspace-123',
},
})
mockCheckForCircularReference.mockResolvedValue(true)
const req = createMockRequest('PUT', {
name: 'Updated Folder 3',
parentId: 'folder-1',
@@ -388,6 +412,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
expect(mockCheckForCircularReference).toHaveBeenCalledWith('folder-3', 'folder-1')
})
})
@@ -409,6 +434,12 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('deletedItems')
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
folderId: 'folder-1',
workspaceId: 'workspace-123',
userId: TEST_USER.id,
folderName: 'Test Folder',
})
})
it('should return 401 for unauthenticated delete requests', async () => {
@@ -472,6 +503,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
})
it('should handle database errors during deletion', async () => {

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import { performDeleteFolder } from '@/lib/workflows/orchestration'
import { checkForCircularReference } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersIDAPI')
@@ -130,7 +130,6 @@ export async function DELETE(
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
// Check if user has admin permissions for the workspace (admin-only for deletions)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -144,170 +143,25 @@ export async function DELETE(
)
}
// Check if deleting this folder would delete the last workflow(s) in the workspace
const workflowsInFolder = await countWorkflowsInFolderRecursively(
id,
existingFolder.workspaceId
)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, existingFolder.workspaceId), isNull(workflow.archivedAt)))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return NextResponse.json(
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
{ status: 400 }
)
}
// Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
logger.info('Deleted folder and all contents:', {
id,
deletionStats,
})
recordAudit({
const result = await performDeleteFolder({
folderId: id,
workspaceId: existingFolder.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: id,
resourceName: existingFolder.name,
description: `Deleted folder "${existingFolder.name}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
request,
userId: session.user.id,
folderName: existingFolder.name,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
return NextResponse.json({
success: true,
deletedItems: deletionStats,
deletedItems: result.deletedItems,
})
} catch (error) {
logger.error('Error deleting folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Helper function to recursively delete a folder and all its contents
async function deleteFolderRecursively(
folderId: string,
workspaceId: string
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
// Get all child folders first (workspace-scoped, not user-scoped)
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
// Recursively delete child folders
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
// Delete all workflows in this folder (workspace-scoped, not user-scoped)
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolder.length > 0) {
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}` }
)
stats.workflows += workflowsInFolder.length
}
// Delete this folder
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
}
/**
* Counts the number of workflows in a folder and all its subfolders recursively.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
// Helper function to check for circular references
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
return true // Circular reference detected
}
if (currentParentId === folderId) {
return true // Would create a cycle
}
visited.add(currentParentId)
// Get the parent of the current parent
const parent: { parentId: string | null } | undefined = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, currentParentId))
.then((rows) => rows[0])
currentParentId = parent?.parentId || null
}
return false
}

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
@@ -96,6 +97,18 @@ export async function POST(req: NextRequest) {
requestId,
})
for (const skill of resultSkills) {
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.SKILL_CREATED,
resourceType: AuditResourceType.SKILL,
resourceId: skill.id,
resourceName: skill.name,
description: `Created/updated skill "${skill.name}"`,
})
}
return NextResponse.json({ success: true, data: resultSkills })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -158,6 +171,15 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
recordAudit({
workspaceId,
actorId: authResult.userId,
action: AuditAction.SKILL_DELETED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: `Deleted skill`,
})
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
@@ -166,6 +167,18 @@ export async function POST(req: NextRequest) {
requestId,
})
for (const tool of resultTools) {
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.CUSTOM_TOOL_CREATED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: tool.id,
resourceName: tool.title,
description: `Created/updated custom tool "${tool.title}"`,
})
}
return NextResponse.json({ success: true, data: resultTools })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -265,6 +278,15 @@ export async function DELETE(request: NextRequest) {
// Delete the tool
await db.delete(customTools).where(eq(customTools.id, toolId))
recordAudit({
workspaceId: tool.workspaceId || undefined,
actorId: userId,
action: AuditAction.CUSTOM_TOOL_DELETED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: toolId,
description: `Deleted custom tool`,
})
logger.info(`[${requestId}] Deleted tool: ${toolId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -1,25 +1,7 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import {
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -31,12 +13,19 @@ import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/
const logger = createLogger('AdminWorkflowDeployAPI')
const ADMIN_ACTOR_ID = 'admin-api'
interface RouteParams {
id: string
}
/**
* POST — Deploy a workflow via admin API.
*
* `userId` is set to the workflow owner so that webhook credential resolution
* (OAuth token lookups for providers like Airtable, Attio, etc.) uses a real
* user. `actorId` is set to `'admin-api'` so that the `deployedBy` field on
* the deployment version and audit log entries are correctly attributed to an
* admin action rather than the workflow owner.
*/
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
@@ -48,140 +37,28 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return notFoundResponse('Workflow')
}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return badRequestResponse('Workflow has no saved state')
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) {
return
}
}
await undeployWorkflow({ workflowId })
}
const deployResult = await deployWorkflow({
const result = await performFullDeploy({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
workflowName: workflowRecord.name,
})
if (!deployResult.success) {
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
if (!deployResult.deploymentVersionId) {
await undeployWorkflow({ workflowId })
return internalErrorResponse('Failed to resolve deployment version')
}
const workflowData = workflowRecord as Record<string, unknown>
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks: normalizedData.blocks,
workflowName: workflowRecord.name,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
previousVersionId,
request,
actorId: 'admin-api',
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await rollbackDeployment()
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
if (!result.success) {
if (result.errorCode === 'not_found') return notFoundResponse('Workflow state')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to deploy workflow')
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
db,
deployResult.deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Admin API: Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await rollbackDeployment()
return internalErrorResponse(scheduleResult.error || 'Failed to create schedule')
}
if (previousVersionId && previousVersionId !== deployResult.deploymentVersionId) {
try {
logger.info(`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId}`)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
logger.info(
`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`
)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' })
logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
version: result.version!,
deployedAt: result.deployedAt!.toISOString(),
warnings: result.warnings,
}
return singleResponse(response)
@@ -191,7 +68,7 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
@@ -202,19 +79,17 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
const result = await undeployWorkflow({ workflowId })
const result = await performFullUndeploy({
workflowId,
userId: workflowRecord.userId,
requestId,
actorId: 'admin-api',
})
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
await cleanupWebhooksForWorkflow(
workflowId,
workflowRecord as Record<string, unknown>,
requestId
)
await removeMcpToolsForWorkflow(workflowId, requestId)
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {

View File

@@ -13,12 +13,12 @@
*/
import { db } from '@sim/db'
import { templates, workflowBlocks, workflowEdges } from '@sim/db/schema'
import { workflowBlocks, workflowEdges } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
@@ -69,7 +69,7 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context) => {
const { id: workflowId } = await context.params
try {
@@ -79,12 +79,18 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
await db.update(templates).set({ workflowId: null }).where(eq(templates.workflowId, workflowId))
await archiveWorkflow(workflowId, {
const result = await performDeleteWorkflow({
workflowId,
userId: workflowData.userId,
skipLastWorkflowGuard: true,
requestId: `admin-workflow-${workflowId}`,
actorId: 'admin-api',
})
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to delete workflow')
}
logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`)
return NextResponse.json({ success: true, workflowId })

View File

@@ -1,16 +1,7 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -18,7 +9,6 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
@@ -43,144 +33,22 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('Invalid version number')
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return notFoundResponse('Deployment version')
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return internalErrorResponse('Invalid deployed state structure')
}
const workflowData = workflowRecord as Record<string, unknown>
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
const result = await performActivateVersion({
workflowId,
workflow: workflowData,
version: versionNum,
userId: workflowRecord.userId,
blocks,
workflow: workflowRecord as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
request,
actorId: 'admin-api',
})
if (!triggerSaveResult.success) {
logger.error(
`[${requestId}] Admin API: Failed to sync triggers for workflow ${workflowId}`,
triggerSaveResult.error
)
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(workflowId, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
return internalErrorResponse(scheduleResult.error || 'Failed to sync schedules')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
if (result.errorCode === 'not_found') return notFoundResponse('Deployment version')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to activate version')
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Admin API: Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId,
requestId,
state: versionRow.state,
context: 'activate',
})
logger.info(
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
)
@@ -189,14 +57,12 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
warnings: result.warnings,
})
} catch (error) {
logger.error(
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
{
error,
}
{ error }
)
return internalErrorResponse('Failed to activate deployment version')
}

View File

@@ -1,26 +1,9 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import {
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import {
checkNeedsRedeployment,
@@ -97,164 +80,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse('Unable to determine deploying user', 400)
}
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
return createErrorResponse('Failed to load workflow state', 500)
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
logger.warn(
`[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}`
)
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId: id,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) {
return
}
}
await undeployWorkflow({ workflowId: id })
}
const deployResult = await deployWorkflow({
const result = await performFullDeploy({
workflowId: id,
deployedBy: actorUserId,
workflowName: workflowData!.name,
})
if (!deployResult.success) {
return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500)
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
if (!deploymentVersionId) {
await undeployWorkflow({ workflowId: id })
return createErrorResponse('Failed to resolve deployment version', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
workflowName: workflowData!.name || undefined,
requestId,
deploymentVersionId,
previousVersionId,
request,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
const scheduleResult = await createSchedulesForDeploy(
id,
normalizedData.blocks,
db,
deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
if (scheduleResult.scheduleId) {
scheduleInfo = {
scheduleId: scheduleResult.scheduleId,
cronExpression: scheduleResult.cronExpression,
nextRunAt: scheduleResult.nextRunAt,
}
logger.info(
`[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}`
)
}
if (previousVersionId && previousVersionId !== deploymentVersionId) {
try {
logger.info(`[${requestId}] Cleaning up previous version ${previousVersionId} DB records`)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
// Non-fatal - continue with success response
}
if (!result.success) {
const status =
result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500
return createErrorResponse(result.error || 'Failed to deploy workflow', status)
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData?.name || id}"`,
metadata: { version: deploymentVersionId },
request,
})
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
@@ -262,25 +103,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createSuccessResponse({
apiKey: responseApiKeyInfo,
isDeployed: true,
deployedAt,
schedule: scheduleInfo.scheduleId
? {
id: scheduleInfo.scheduleId,
cronExpression: scheduleInfo.cronExpression,
nextRunAt: scheduleInfo.nextRunAt,
}
: undefined,
warnings: triggerSaveResult.warnings,
deployedAt: result.deployedAt,
warnings: result.warnings,
})
} catch (error: any) {
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
error: error.message,
stack: error.stack,
name: error.name,
cause: error.cause,
fullError: error,
})
return createErrorResponse(error.message || 'Failed to deploy workflow', 500)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to deploy workflow'
logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error })
return createErrorResponse(message, 500)
}
}
@@ -328,60 +157,36 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
export async function DELETE(
request: NextRequest,
_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')
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const result = await undeployWorkflow({ workflowId: id })
const result = await performFullUndeploy({
workflowId: id,
userId: session!.user.id,
requestId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
await cleanupWebhooksForWorkflow(id, workflowData as Record<string, unknown>, requestId)
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowUndeployed({ workflowId: id })
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: session!.user.id,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_UNDEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Undeployed workflow "${workflowData?.name || id}"`,
request,
})
return createSuccessResponse({
isDeployed: false,
deployedAt: null,
apiKey: null,
})
} catch (error: any) {
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to undeploy workflow', 500)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to undeploy workflow'
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error })
return createErrorResponse(message, 500)
}
}

View File

@@ -3,19 +3,10 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDeploymentVersionAPI')
@@ -129,140 +120,25 @@ export async function PATCH(
return createErrorResponse('Unable to determine activating user', 400)
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return createErrorResponse('Deployment version not found', 404)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return createErrorResponse('Invalid deployed state structure', 500)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(
`Invalid schedule configuration: ${scheduleValidation.error}`,
400
)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
const activateResult = await performActivateVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
version: versionNum,
userId: actorUserId,
blocks,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
request,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration',
triggerSaveResult.error?.status || 500
)
if (!activateResult.success) {
const status =
activateResult.errorCode === 'not_found'
? 404
: activateResult.errorCode === 'validation'
? 400
: 500
return createErrorResponse(activateResult.error || 'Failed to activate deployment', status)
}
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
}
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionRow.state,
context: 'activate',
})
// Apply name/description updates if provided alongside activation
let updatedName: string | null | undefined
let updatedDescription: string | null | undefined
if (name !== undefined || description !== undefined) {
@@ -298,23 +174,10 @@ export async function PATCH(
}
}
recordAudit({
workspaceId: workflowData?.workspaceId,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
description: `Activated deployment version ${versionNum}`,
metadata: { version: versionNum },
request,
})
return createSuccessResponse({
success: true,
deployedAt: result.deployedAt,
warnings: triggerSaveResult.warnings,
deployedAt: activateResult.deployedAt,
warnings: activateResult.warnings,
...(updatedName !== undefined && { name: updatedName }),
...(updatedDescription !== undefined && { description: updatedDescription }),
})

View File

@@ -5,14 +5,7 @@
* @vitest-environment node
*/
import {
auditMock,
envMock,
loggerMock,
requestUtilsMock,
setupGlobalFetchMock,
telemetryMock,
} from '@sim/testing'
import { auditMock, envMock, loggerMock, requestUtilsMock, telemetryMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,7 +14,7 @@ const mockCheckSessionOrInternalAuth = vi.fn()
const mockLoadWorkflowFromNormalizedTables = vi.fn()
const mockGetWorkflowById = vi.fn()
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockArchiveWorkflow = vi.fn()
const mockPerformDeleteWorkflow = vi.fn()
const mockDbUpdate = vi.fn()
const mockDbSelect = vi.fn()
@@ -72,8 +65,8 @@ vi.mock('@/lib/workflows/utils', () => ({
}) => mockAuthorizeWorkflowByWorkspacePermission(params),
}))
vi.mock('@/lib/workflows/lifecycle', () => ({
archiveWorkflow: (...args: unknown[]) => mockArchiveWorkflow(...args),
vi.mock('@/lib/workflows/orchestration', () => ({
performDeleteWorkflow: (...args: unknown[]) => mockPerformDeleteWorkflow(...args),
}))
vi.mock('@sim/db', () => ({
@@ -294,18 +287,7 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'admin',
})
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
}),
})
mockArchiveWorkflow.mockResolvedValue({
archived: true,
workflow: mockWorkflow,
})
setupGlobalFetchMock({ ok: true })
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
@@ -317,6 +299,12 @@ describe('Workflow By ID API Route', () => {
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 () => {
@@ -337,19 +325,7 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'admin',
})
// Mock db.select() to return multiple workflows so deletion is allowed
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
}),
})
mockArchiveWorkflow.mockResolvedValue({
archived: true,
workflow: mockWorkflow,
})
setupGlobalFetchMock({ ok: true })
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
@@ -381,11 +357,10 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'admin',
})
// Mock db.select() to return only 1 workflow (the one being deleted)
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
}),
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', {

View File

@@ -1,13 +1,12 @@
import { db } from '@sim/db'
import { templates, workflow } from '@sim/db/schema'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
@@ -184,28 +183,12 @@ export async function DELETE(
)
}
// Check if this is the last workflow in the workspace
if (workflowData.workspaceId) {
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workflowData.workspaceId), isNull(workflow.archivedAt)))
if (totalWorkflowsInWorkspace.length <= 1) {
return NextResponse.json(
{ error: 'Cannot delete the only workflow in the workspace' },
{ status: 400 }
)
}
}
// Check if workflow has published templates before deletion
const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'
const deleteTemplatesParam = searchParams.get('deleteTemplates')
if (checkTemplates) {
// Return template information for frontend to handle
const { templates } = await import('@sim/db/schema')
const publishedTemplates = await db
.select({
id: templates.id,
@@ -229,49 +212,22 @@ export async function DELETE(
})
}
// Handle template deletion based on user choice
if (deleteTemplatesParam !== null) {
const deleteTemplates = deleteTemplatesParam === 'delete'
const result = await performDeleteWorkflow({
workflowId,
userId,
requestId,
templateAction: deleteTemplatesParam === 'delete' ? 'delete' : 'orphan',
})
if (deleteTemplates) {
// Delete all templates associated with this workflow
await db.delete(templates).where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
} else {
// Orphan the templates (set workflowId to null)
await db
.update(templates)
.set({ workflowId: null })
.where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
}
}
const archiveResult = await archiveWorkflow(workflowId, { requestId })
if (!archiveResult.workflow) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`)
recordAudit({
workspaceId: workflowData.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_DELETED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowData.name,
description: `Archived workflow "${workflowData.name}"`,
metadata: {
archived: archiveResult.archived,
deleteTemplates: deleteTemplatesParam === 'delete',
},
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime

View File

@@ -26,6 +26,11 @@ export const AuditAction = {
CHAT_UPDATED: 'chat.updated',
CHAT_DELETED: 'chat.deleted',
// Custom Tools
CUSTOM_TOOL_CREATED: 'custom_tool.created',
CUSTOM_TOOL_UPDATED: 'custom_tool.updated',
CUSTOM_TOOL_DELETED: 'custom_tool.deleted',
// Billing
CREDIT_PURCHASED: 'credit.purchased',
@@ -99,8 +104,10 @@ export const AuditAction = {
NOTIFICATION_UPDATED: 'notification.updated',
NOTIFICATION_DELETED: 'notification.deleted',
// OAuth
// OAuth / Credentials
OAUTH_DISCONNECTED: 'oauth.disconnected',
CREDENTIAL_RENAMED: 'credential.renamed',
CREDENTIAL_DELETED: 'credential.deleted',
// Password
PASSWORD_RESET: 'password.reset',
@@ -124,6 +131,11 @@ export const AuditAction = {
PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added',
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
// Skills
SKILL_CREATED: 'skill.created',
SKILL_UPDATED: 'skill.updated',
SKILL_DELETED: 'skill.deleted',
// Schedules
SCHEDULE_UPDATED: 'schedule.updated',
@@ -173,6 +185,7 @@ export const AuditResourceType = {
CHAT: 'chat',
CONNECTOR: 'connector',
CREDENTIAL_SET: 'credential_set',
CUSTOM_TOOL: 'custom_tool',
DOCUMENT: 'document',
ENVIRONMENT: 'environment',
FILE: 'file',
@@ -186,6 +199,7 @@ export const AuditResourceType = {
PASSWORD: 'password',
PERMISSION_GROUP: 'permission_group',
SCHEDULE: 'schedule',
SKILL: 'skill',
TABLE: 'table',
TEMPLATE: 'template',
WEBHOOK: 'webhook',

View File

@@ -1,62 +0,0 @@
import type { ClientContentBlock, ClientStreamingContext } from '@/lib/copilot/client-sse/types'
/**
* Appends plain text to the active text block, or starts a new one when needed.
*/
export function appendTextBlock(context: ClientStreamingContext, content: string): void {
if (!content) return
context.accumulatedContent += content
if (context.currentTextBlock?.type === 'text') {
context.currentTextBlock.content = `${context.currentTextBlock.content || ''}${content}`
return
}
const block: ClientContentBlock = {
type: 'text',
content,
timestamp: Date.now(),
}
context.currentTextBlock = block
context.contentBlocks.push(block)
}
/**
* Starts a new thinking block when the stream enters a reasoning segment.
*/
export function beginThinkingBlock(context: ClientStreamingContext): void {
if (context.currentThinkingBlock) {
context.isInThinkingBlock = true
context.currentTextBlock = null
return
}
const block: ClientContentBlock = {
type: 'thinking',
content: '',
timestamp: Date.now(),
startTime: Date.now(),
}
context.currentThinkingBlock = block
context.contentBlocks.push(block)
context.currentTextBlock = null
context.isInThinkingBlock = true
}
/**
* Closes the active thinking block and records its visible duration.
*/
export function finalizeThinkingBlock(context: ClientStreamingContext): void {
if (!context.currentThinkingBlock) {
context.isInThinkingBlock = false
return
}
const startTime = context.currentThinkingBlock.startTime ?? context.currentThinkingBlock.timestamp
context.currentThinkingBlock.duration = Math.max(0, Date.now() - startTime)
context.currentThinkingBlock = null
context.isInThinkingBlock = false
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,14 @@ import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { authorizeWorkflowByWorkspacePermission, type getWorkflowById } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
type WorkflowRecord = NonNullable<Awaited<ReturnType<typeof getWorkflowById>>>
export async function ensureWorkflowAccess(
workflowId: string,
userId: string
userId: string,
action: 'read' | 'write' | 'admin' = 'read'
): Promise<{
workflow: WorkflowRecord
workspaceId?: string | null
@@ -16,7 +17,7 @@ export async function ensureWorkflowAccess(
const result = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
action,
})
if (!result.workflow) {
@@ -56,25 +57,25 @@ export async function getDefaultWorkspaceId(userId: string): Promise<string> {
export async function ensureWorkspaceAccess(
workspaceId: string,
userId: string,
requireWrite: boolean
level: 'read' | 'write' | 'admin' = 'read'
): Promise<void> {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.exists || !access.hasAccess) {
throw new Error(`Workspace ${workspaceId} not found`)
}
const permissionType = access.canWrite
? 'write'
: access.workspace?.ownerId === userId
? 'admin'
: 'read'
const canWrite = permissionType === 'admin' || permissionType === 'write'
if (level === 'read') return
if (requireWrite && !canWrite) {
if (level === 'admin') {
if (access.workspace?.ownerId === userId) return
const perm = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (perm !== 'admin') {
throw new Error('Admin access required for this workspace')
}
return
}
if (!access.canWrite) {
throw new Error('Write or admin access required for this workspace')
}
if (!requireWrite && !canWrite && permissionType !== 'read') {
throw new Error('Access denied to workspace')
}
}

View File

@@ -2,15 +2,18 @@ import crypto from 'crypto'
import { db } from '@sim/db'
import { chat, workflowMcpTool } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import {
generateParameterSchemaForWorkflow,
removeMcpToolsForWorkflow,
} from '@/lib/mcp/workflow-mcp-sync'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/persistence/utils'
import {
performChatDeploy,
performChatUndeploy,
performFullDeploy,
performFullUndeploy,
} from '@/lib/workflows/orchestration'
import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { ensureWorkflowAccess } from '../access'
import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types'
@@ -25,20 +28,23 @@ export async function executeDeployApi(
return { success: false, error: 'workflowId is required' }
}
const action = params.action === 'undeploy' ? 'undeploy' : 'deploy'
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'admin'
)
if (action === 'undeploy') {
const result = await undeployWorkflow({ workflowId })
const result = await performFullUndeploy({ workflowId, userId: context.userId })
if (!result.success) {
return { success: false, error: result.error || 'Failed to undeploy workflow' }
}
await removeMcpToolsForWorkflow(workflowId, crypto.randomUUID().slice(0, 8))
return { success: true, output: { workflowId, isDeployed: false } }
}
const result = await deployWorkflow({
const result = await performFullDeploy({
workflowId,
deployedBy: context.userId,
userId: context.userId,
workflowName: workflowRecord.name || undefined,
})
if (!result.success) {
@@ -82,11 +88,21 @@ export async function executeDeployChat(
if (!existing.length) {
return { success: false, error: 'No active chat deployment found for this workflow' }
}
const { hasAccess } = await checkChatAccess(existing[0].id, context.userId)
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
existing[0].id,
context.userId
)
if (!hasAccess) {
return { success: false, error: 'Unauthorized chat access' }
}
await db.delete(chat).where(eq(chat.id, existing[0].id))
const undeployResult = await performChatUndeploy({
chatId: existing[0].id,
userId: context.userId,
workspaceId: chatWorkspaceId,
})
if (!undeployResult.success) {
return { success: false, error: undeployResult.error || 'Failed to undeploy chat' }
}
return {
success: true,
output: {
@@ -99,17 +115,19 @@ export async function executeDeployChat(
}
}
const { hasAccess } = await checkWorkflowAccessForChatCreation(workflowId, context.userId)
if (!hasAccess) {
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
workflowId,
context.userId
)
if (!hasAccess || !workflowRecord) {
return { success: false, error: 'Workflow not found or access denied' }
}
const existing = await db
const [existingDeployment] = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
const existingDeployment = existing[0] || null
const identifier = String(params.identifier || existingDeployment?.identifier || '').trim()
const title = String(params.title || existingDeployment?.title || '').trim()
@@ -134,21 +152,14 @@ export async function executeDeployChat(
return { success: false, error: 'Identifier already in use' }
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: context.userId,
})
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
const existingCustomizations =
(existingDeployment?.customizations as
| { primaryColor?: string; welcomeMessage?: string }
| undefined) || {}
const payload = {
const result = await performChatDeploy({
workflowId,
userId: context.userId,
identifier,
title,
description: String(params.description || existingDeployment?.description || ''),
@@ -162,46 +173,22 @@ export async function executeDeployChat(
existingCustomizations.welcomeMessage ||
'Hi there! How can I help you today?',
},
authType: params.authType || existingDeployment?.authType || 'public',
authType: (params.authType || existingDeployment?.authType || 'public') as
| 'public'
| 'password'
| 'email'
| 'sso',
password: params.password,
allowedEmails: params.allowedEmails || existingDeployment?.allowedEmails || [],
outputConfigs: params.outputConfigs || existingDeployment?.outputConfigs || [],
}
allowedEmails: params.allowedEmails || (existingDeployment?.allowedEmails as string[]) || [],
outputConfigs: (params.outputConfigs || existingDeployment?.outputConfigs || []) as Array<{
blockId: string
path: string
}>,
workspaceId: workflowRecord.workspaceId,
})
if (existingDeployment) {
await db
.update(chat)
.set({
identifier: payload.identifier,
title: payload.title,
description: payload.description,
customizations: payload.customizations,
authType: payload.authType,
password: payload.password || existingDeployment.password,
allowedEmails:
payload.authType === 'email' || payload.authType === 'sso' ? payload.allowedEmails : [],
outputConfigs: payload.outputConfigs,
updatedAt: new Date(),
})
.where(eq(chat.id, existingDeployment.id))
} else {
await db.insert(chat).values({
id: crypto.randomUUID(),
workflowId,
userId: context.userId,
identifier: payload.identifier,
title: payload.title,
description: payload.description,
customizations: payload.customizations,
isActive: true,
authType: payload.authType,
password: payload.password || null,
allowedEmails:
payload.authType === 'email' || payload.authType === 'sso' ? payload.allowedEmails : [],
outputConfigs: payload.outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to deploy chat' }
}
const baseUrl = getBaseUrl()
@@ -214,7 +201,7 @@ export async function executeDeployChat(
isDeployed: true,
isChatDeployed: true,
identifier,
chatUrl: `${baseUrl}/chat/${identifier}`,
chatUrl: result.chatUrl,
apiEndpoint: `${baseUrl}/api/workflows/${workflowId}/run`,
baseUrl,
},
@@ -234,7 +221,11 @@ export async function executeDeployMcp(
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'admin'
)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
@@ -263,8 +254,15 @@ export async function executeDeployMcp(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
// Intentionally omits `isDeployed` — removing from an MCP server does not
// affect the workflow's API deployment.
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Undeployed workflow "${workflowId}" from MCP server`,
})
return {
success: true,
output: { workflowId, serverId, action: 'undeploy', removed: true },
@@ -319,6 +317,15 @@ export async function executeDeployMcp(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Updated MCP tool "${toolName}" on server`,
})
return {
success: true,
output: { toolId, toolName, toolDescription, updated: true, mcpServerUrl, baseUrl },
@@ -339,6 +346,15 @@ export async function executeDeployMcp(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Deployed workflow as MCP tool "${toolName}"`,
})
return {
success: true,
output: { toolId, toolName, toolDescription, updated: false, mcpServerUrl, baseUrl },
@@ -357,9 +373,9 @@ export async function executeRedeploy(
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await ensureWorkflowAccess(workflowId, context.userId, 'admin')
const result = await deployWorkflow({ workflowId, deployedBy: context.userId })
const result = await performFullDeploy({ workflowId, userId: context.userId })
if (!result.success) {
return { success: false, error: result.error || 'Failed to redeploy workflow' }
}

View File

@@ -8,12 +8,13 @@ import {
workflowMcpTool,
} from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
import { ensureWorkflowAccess } from '../access'
import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access'
import type {
CheckDeploymentStatusParams,
CreateWorkspaceMcpServerParams,
@@ -182,7 +183,11 @@ export async function executeCreateWorkspaceMcpServer(
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
@@ -242,6 +247,16 @@ export async function executeCreateWorkspaceMcpServer(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
}
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: name,
description: `Created MCP server "${name}"`,
})
return { success: true, output: { server, addedTools } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
@@ -277,7 +292,10 @@ export async function executeUpdateWorkspaceMcpServer(
}
const [existing] = await db
.select({ id: workflowMcpServer.id, createdBy: workflowMcpServer.createdBy })
.select({
id: workflowMcpServer.id,
workspaceId: workflowMcpServer.workspaceId,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
.limit(1)
@@ -286,8 +304,18 @@ export async function executeUpdateWorkspaceMcpServer(
return { success: false, error: 'MCP server not found' }
}
await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write')
await db.update(workflowMcpServer).set(updates).where(eq(workflowMcpServer.id, serverId))
recordAudit({
actorId: context.userId,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: params.serverId,
description: `Updated MCP server`,
})
return { success: true, output: { serverId, ...updates, updatedAt: undefined } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
@@ -318,10 +346,20 @@ export async function executeDeleteWorkspaceMcpServer(
return { success: false, error: 'MCP server not found' }
}
await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin')
await db.delete(workflowMcpServer).where(eq(workflowMcpServer.id, serverId))
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: existing.workspaceId })
recordAudit({
actorId: context.userId,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: params.serverId,
description: `Deleted MCP server`,
})
return { success: true, output: { serverId, name: existing.name, deleted: true } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
@@ -379,7 +417,7 @@ export async function executeRevertToVersion(
return { success: false, error: 'version is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await ensureWorkflowAccess(workflowId, context.userId, 'admin')
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || 'http://localhost:3000'

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { credential, mcpServers, pendingCredentialDraft, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lt } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import type {
ExecutionContext,
@@ -231,6 +232,16 @@ async function executeManageCustomTool(
})
const created = resultTools.find((tool) => tool.title === title)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.CUSTOM_TOOL_CREATED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: created?.id,
resourceName: title,
description: `Created custom tool "${title}"`,
})
return {
success: true,
output: {
@@ -279,6 +290,16 @@ async function executeManageCustomTool(
userId: context.userId,
})
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.CUSTOM_TOOL_UPDATED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: params.toolId,
resourceName: title,
description: `Updated custom tool "${title}"`,
})
return {
success: true,
output: {
@@ -305,6 +326,15 @@ async function executeManageCustomTool(
return { success: false, error: `Custom tool not found: ${params.toolId}` }
}
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.CUSTOM_TOOL_DELETED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: params.toolId,
description: 'Deleted custom tool',
})
return {
success: true,
output: {
@@ -461,6 +491,18 @@ async function executeManageMcpTool(
await mcpService.clearCache(workspaceId)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: config.name,
description: existing
? `Updated existing MCP server "${config.name}"`
: `Added MCP server "${config.name}"`,
})
return {
success: true,
output: {
@@ -514,6 +556,15 @@ async function executeManageMcpTool(
await mcpService.clearCache(workspaceId)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: params.serverId,
description: `Updated MCP server "${updated.name}"`,
})
return {
success: true,
output: {
@@ -527,6 +578,13 @@ async function executeManageMcpTool(
}
if (operation === 'delete') {
if (context.userPermission && context.userPermission !== 'admin') {
return {
success: false,
error: `Permission denied: 'delete' on manage_mcp_tool requires admin access. You have '${context.userPermission}' permission.`,
}
}
if (!params.serverId) {
return { success: false, error: "'serverId' is required for 'delete'" }
}
@@ -542,6 +600,15 @@ async function executeManageMcpTool(
await mcpService.clearCache(workspaceId)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: params.serverId,
description: `Deleted MCP server "${deleted.name}"`,
})
return {
success: true,
output: {
@@ -643,6 +710,16 @@ async function executeManageSkill(
})
const created = resultSkills.find((s) => s.name === params.name)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.SKILL_CREATED,
resourceType: AuditResourceType.SKILL,
resourceId: created?.id,
resourceName: params.name,
description: `Created skill "${params.name}"`,
})
return {
success: true,
output: {
@@ -685,14 +762,26 @@ async function executeManageSkill(
userId: context.userId,
})
const updatedName = params.name || found.name
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.SKILL_UPDATED,
resourceType: AuditResourceType.SKILL,
resourceId: params.skillId,
resourceName: updatedName,
description: `Updated skill "${updatedName}"`,
})
return {
success: true,
output: {
success: true,
operation,
skillId: params.skillId,
name: params.name || found.name,
message: `Updated skill "${params.name || found.name}"`,
name: updatedName,
message: `Updated skill "${updatedName}"`,
},
}
}
@@ -707,6 +796,15 @@ async function executeManageSkill(
return { success: false, error: `Skill not found: ${params.skillId}` }
}
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.SKILL_DELETED,
resourceType: AuditResourceType.SKILL,
resourceId: params.skillId,
description: 'Deleted skill',
})
return {
success: true,
output: {
@@ -951,10 +1049,24 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
.update(credential)
.set({ displayName, updatedAt: new Date() })
.where(eq(credential.id, credentialId))
recordAudit({
actorId: c.userId,
action: AuditAction.CREDENTIAL_RENAMED,
resourceType: AuditResourceType.OAUTH,
resourceId: credentialId,
description: `Renamed credential to "${displayName}"`,
})
return { success: true, output: { credentialId, displayName } }
}
case 'delete': {
await db.delete(credential).where(eq(credential.id, credentialId))
recordAudit({
actorId: c.userId,
action: AuditAction.CREDENTIAL_DELETED,
resourceType: AuditResourceType.OAUTH,
resourceId: credentialId,
description: `Deleted credential`,
})
return { success: true, output: { credentialId, deleted: true } }
}
default:

View File

@@ -3,6 +3,7 @@ import { copilotChats, workflowSchedule } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils'
@@ -150,6 +151,17 @@ export async function executeCreateJob(
logger.info('Job created', { jobId, cronExpression, nextRunAt: nextRunAt.toISOString() })
recordAudit({
workspaceId: context.workspaceId || null,
actorId: context.userId,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: jobId,
resourceName: title || undefined,
description: `Created job "${title || jobId}"`,
metadata: { operation: 'create', cronExpression },
})
return {
success: true,
output: {
@@ -381,6 +393,19 @@ export async function executeManageJob(
logger.info('Job updated', { jobId: args.jobId, fields: Object.keys(updates) })
recordAudit({
workspaceId: context.workspaceId || null,
actorId: context.userId,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: args.jobId,
description: `Updated job`,
metadata: {
operation: 'update',
fields: Object.keys(updates).filter((k) => k !== 'updatedAt'),
},
})
return {
success: true,
output: {
@@ -419,6 +444,16 @@ export async function executeManageJob(
logger.info('Job deleted', { jobId: args.jobId })
recordAudit({
workspaceId: context.workspaceId || null,
actorId: context.userId,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: args.jobId,
description: `Deleted job`,
metadata: { operation: 'delete' },
})
return {
success: true,
output: {
@@ -492,6 +527,16 @@ export async function executeCompleteJob(
logger.info('Job completed', { jobId })
recordAudit({
workspaceId: context.workspaceId || null,
actorId: context.userId,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: jobId,
description: `Completed job`,
metadata: { operation: 'complete' },
})
return {
success: true,
output: { jobId, message: 'Job marked as completed. No further executions will occur.' },

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { workflow, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/orchestrator/tool-executor/upload-file-reader'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { getServePathPrefix } from '@/lib/uploads'
@@ -158,6 +159,17 @@ async function executeImport(
chatId,
})
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: dedupedName,
description: `Imported workflow "${dedupedName}" from file`,
metadata: { fileName, source: 'copilot-import' },
})
return {
success: true,
output: {

View File

@@ -1,6 +1,7 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { createWorkspaceApiKey } from '@/lib/api-key/auth'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
@@ -8,13 +9,13 @@ import {
getExecutionState,
getLatestExecutionState,
} from '@/lib/workflows/executor/execution-state'
import { performDeleteFolder, performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import {
checkForCircularReference,
createFolderRecord,
createWorkflowRecord,
deleteFolderRecord,
deleteWorkflowRecord,
listFolders,
setWorkflowVariables,
updateFolderRecord,
@@ -121,7 +122,7 @@ export async function executeCreateWorkflow(
params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId))
const folderId = params?.folderId || null
await ensureWorkspaceAccess(workspaceId, context.userId, true)
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
assertWorkflowMutationNotAborted(context)
const result = await createWorkflowRecord({
@@ -132,6 +133,28 @@ export async function executeCreateWorkflow(
folderId,
})
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: result.workflowId,
resourceName: name,
description: `Created workflow "${name}"`,
})
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowCreated({
workflowId: result.workflowId,
name,
workspaceId,
folderId: folderId ?? undefined,
})
} catch (_e) {
// Telemetry is best-effort
}
const normalized = await loadWorkflowFromNormalizedTables(result.workflowId)
let copilotSanitizedWorkflowState: unknown
if (normalized) {
@@ -175,7 +198,7 @@ export async function executeCreateFolder(
params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId))
const parentId = params?.parentId || null
await ensureWorkspaceAccess(workspaceId, context.userId, true)
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
assertWorkflowMutationNotAborted(context)
const result = await createFolderRecord({
@@ -185,6 +208,16 @@ export async function executeCreateFolder(
parentId,
})
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: result.folderId,
resourceName: name,
description: `Created folder "${name}"`,
})
return { success: true, output: result }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
@@ -201,7 +234,11 @@ export async function executeRunWorkflow(
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
const useDraftState = !params.useDeployedState
@@ -236,7 +273,11 @@ export async function executeSetGlobalWorkflowVariables(
const operations: VariableOperation[] = Array.isArray(params.operations)
? params.operations
: []
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
interface WorkflowVariable {
id: string
@@ -325,6 +366,14 @@ export async function executeSetGlobalWorkflowVariables(
assertWorkflowMutationNotAborted(context)
await setWorkflowVariables(workflowId, nextVarsRecord)
recordAudit({
actorId: context.userId,
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
description: `Updated workflow variables`,
})
return { success: true, output: { updated: Object.values(byName).length } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
@@ -348,7 +397,7 @@ export async function executeRenameWorkflow(
return { success: false, error: 'Workflow name must be 200 characters or less' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await ensureWorkflowAccess(workflowId, context.userId, 'write')
assertWorkflowMutationNotAborted(context)
await updateWorkflowRecord(workflowId, { name })
@@ -368,7 +417,7 @@ export async function executeMoveWorkflow(
return { success: false, error: 'workflowId is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await ensureWorkflowAccess(workflowId, context.userId, 'write')
const folderId = params.folderId || null
assertWorkflowMutationNotAborted(context)
await updateWorkflowRecord(workflowId, { folderId })
@@ -395,6 +444,15 @@ export async function executeMoveFolder(
return { success: false, error: 'A folder cannot be moved into itself' }
}
if (parentId) {
const wouldCreateCycle = await checkForCircularReference(folderId, parentId)
if (wouldCreateCycle) {
return { success: false, error: 'Cannot create circular folder reference' }
}
}
const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
assertWorkflowMutationNotAborted(context)
await updateFolderRecord(folderId, { parentId })
@@ -417,7 +475,11 @@ export async function executeRunWorkflowUntilBlock(
return { success: false, error: 'stopAfterBlockId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
const useDraftState = !params.useDeployedState
@@ -460,7 +522,7 @@ export async function executeGenerateApiKey(
const workspaceId =
params.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, true)
await ensureWorkspaceAccess(workspaceId, context.userId, 'admin')
assertWorkflowMutationNotAborted(context)
const newKey = await createWorkspaceApiKey({
@@ -469,6 +531,14 @@ export async function executeGenerateApiKey(
name,
})
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
description: `Generated API key for workspace`,
})
return {
success: true,
output: {
@@ -511,7 +581,11 @@ export async function executeRunFromBlock(
}
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
const useDraftState = !params.useDeployedState
const result = await executeWorkflow(
@@ -569,7 +643,7 @@ export async function executeUpdateWorkflow(
return { success: false, error: 'At least one of name or description is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
await ensureWorkflowAccess(workflowId, context.userId, 'write')
assertWorkflowMutationNotAborted(context)
await updateWorkflowRecord(workflowId, updates)
@@ -592,9 +666,17 @@ export async function executeDeleteWorkflow(
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'admin'
)
assertWorkflowMutationNotAborted(context)
await deleteWorkflowRecord(workflowId)
const result = await performDeleteWorkflow({ workflowId, userId: context.userId })
if (!result.success) {
return { success: false, error: result.error || 'Failed to delete workflow' }
}
return {
success: true,
@@ -616,7 +698,7 @@ export async function executeDeleteFolder(
}
const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, true)
await ensureWorkspaceAccess(workspaceId, context.userId, 'admin')
const folders = await listFolders(workspaceId)
const folder = folders.find((f) => f.folderId === folderId)
@@ -625,12 +707,19 @@ export async function executeDeleteFolder(
}
assertWorkflowMutationNotAborted(context)
const deleted = await deleteFolderRecord(folderId)
if (!deleted) {
return { success: false, error: 'Folder not found' }
const result = await performDeleteFolder({
folderId,
workspaceId,
userId: context.userId,
folderName: folder.folderName,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to delete folder' }
}
return { success: true, output: { folderId, deleted: true } }
return { success: true, output: { folderId, deleted: true, ...result.deletedItems } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
@@ -653,6 +742,8 @@ export async function executeRenameFolder(
return { success: false, error: 'Folder name must be 200 characters or less' }
}
const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
assertWorkflowMutationNotAborted(context)
await updateFolderRecord(folderId, { name })
@@ -688,7 +779,11 @@ export async function executeRunBlock(
}
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const { workflow: workflowRecord } = await ensureWorkflowAccess(
workflowId,
context.userId,
'write'
)
const useDraftState = !params.useDeployedState
const result = await executeWorkflow(

View File

@@ -46,7 +46,7 @@ export async function executeListFolders(
context.workspaceId ||
(await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, false)
await ensureWorkspaceAccess(workspaceId, context.userId, 'read')
const folders = await listFolders(workspaceId)

View File

@@ -0,0 +1,206 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
const logger = createLogger('ChatDeployOrchestration')
export interface ChatDeployPayload {
workflowId: string
userId: string
identifier: string
title: string
description?: string
customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string }
authType?: 'public' | 'password' | 'email' | 'sso'
password?: string | null
allowedEmails?: string[]
outputConfigs?: Array<{ blockId: string; path: string }>
workspaceId?: string | null
}
export interface PerformChatDeployResult {
success: boolean
chatId?: string
chatUrl?: string
error?: string
}
/**
* Deploys a chat: deploys the underlying workflow via `performFullDeploy`,
* encrypts passwords, creates or updates the chat record, fires telemetry,
* and records an audit entry. Both the chat API route and the copilot
* `deploy_chat` tool must use this function.
*/
export async function performChatDeploy(
params: ChatDeployPayload
): Promise<PerformChatDeployResult> {
const {
workflowId,
userId,
identifier,
title,
description = '',
authType = 'public',
password,
allowedEmails = [],
outputConfigs = [],
} = params
const customizations = {
primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)',
welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}),
}
const deployResult = await performFullDeploy({ workflowId, userId })
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
let encryptedPassword: string | null = null
if (authType === 'password' && password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
}
const [existingDeployment] = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
let chatId: string
if (existingDeployment) {
chatId = existingDeployment.id
let passwordToStore: string | null
if (authType === 'password') {
passwordToStore = encryptedPassword || existingDeployment.password
} else {
passwordToStore = null
}
await db
.update(chat)
.set({
identifier,
title,
description: description || null,
customizations,
authType,
password: passwordToStore,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
updatedAt: new Date(),
})
.where(eq(chat.id, chatId))
} else {
chatId = crypto.randomUUID()
await db.insert(chat).values({
id: chatId,
workflowId,
userId,
identifier,
title,
description: description || null,
customizations,
isActive: true,
authType,
password: encryptedPassword,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),
})
}
const baseUrl = getBaseUrl()
let chatUrl: string
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) {
host = host.substring(4)
}
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
} catch {
chatUrl = `${baseUrl}/chat/${identifier}`
}
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.chatDeployed({
chatId,
workflowId,
authType,
hasOutputConfigs: outputConfigs.length > 0,
})
} catch (_e) {
// Telemetry is best-effort
}
recordAudit({
workspaceId: params.workspaceId || null,
actorId: userId,
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: title,
description: `Deployed chat "${title}"`,
metadata: { workflowId, identifier, authType },
})
return { success: true, chatId, chatUrl }
}
export interface PerformChatUndeployParams {
chatId: string
userId: string
workspaceId?: string | null
}
export interface PerformChatUndeployResult {
success: boolean
error?: string
}
/**
* Undeploys a chat: deletes the chat record and records an audit entry.
* Both the chat manage DELETE route and the copilot `deploy_chat` undeploy
* action must use this function.
*/
export async function performChatUndeploy(
params: PerformChatUndeployParams
): Promise<PerformChatUndeployResult> {
const { chatId, userId, workspaceId } = params
const [chatRecord] = await db.select().from(chat).where(eq(chat.id, chatId)).limit(1)
if (!chatRecord) {
return { success: false, error: 'Chat not found' }
}
await db.delete(chat).where(eq(chat.id, chatId))
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: workspaceId || null,
actorId: userId,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord.title || chatId,
description: `Deleted chat deployment "${chatRecord.title || chatId}"`,
})
return { success: true }
}

View File

@@ -0,0 +1,484 @@
import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
import {
activateWorkflowVersion,
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
const logger = createLogger('DeployOrchestration')
export interface PerformFullDeployParams {
workflowId: string
userId: string
workflowName?: string
requestId?: string
/**
* Optional NextRequest for external webhook subscriptions.
* If not provided, a synthetic request is constructed from the base URL.
*/
request?: NextRequest
/**
* Override the actor ID used in audit logs and the `deployedBy` field.
* Defaults to `userId`. Use `'admin-api'` for admin-initiated actions.
*/
actorId?: string
}
export interface PerformFullDeployResult {
success: boolean
deployedAt?: Date
version?: number
deploymentVersionId?: string
error?: string
errorCode?: OrchestrationErrorCode
warnings?: string[]
}
/**
* Performs a full workflow deployment: creates a deployment version, syncs
* trigger webhooks, creates schedules, cleans up the previous version, and
* syncs MCP tools. Both the deploy API route and the copilot deploy tools
* must use this single function so behaviour stays consistent.
*/
export async function performFullDeploy(
params: PerformFullDeployParams
): Promise<PerformFullDeployResult> {
const { workflowId, userId, workflowName } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const request = params.request ?? new NextRequest(new URL('/api/webhooks', getBaseUrl()))
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return { success: false, error: 'Failed to load workflow state', errorCode: 'not_found' }
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return {
success: false,
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
errorCode: 'validation',
}
}
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
const workflowData = workflowRecord as Record<string, unknown>
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) return
}
await undeployWorkflow({ workflowId })
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: actorId,
workflowName: workflowName || workflowRecord.name || undefined,
})
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
if (!deploymentVersionId) {
await undeployWorkflow({ workflowId })
return { success: false, error: 'Failed to resolve deployment version' }
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return {
success: false,
error: triggerSaveResult.error?.message || 'Failed to save trigger configuration',
}
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
db,
deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(`[${requestId}] Failed to create schedule: ${scheduleResult.error}`)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return { success: false, error: scheduleResult.error || 'Failed to create schedule' }
}
if (previousVersionId && previousVersionId !== deploymentVersionId) {
try {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(`[${requestId}] Failed to clean up previous version`, cleanupError)
}
}
await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' })
recordAudit({
workspaceId: (workflowData.workspaceId as string) || null,
actorId: actorId,
action: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: (workflowData.name as string) || undefined,
description: `Deployed workflow "${(workflowData.name as string) || workflowId}"`,
metadata: { version: deploymentVersionId },
request,
})
return {
success: true,
deployedAt,
version: deployResult.version,
deploymentVersionId,
warnings: triggerSaveResult.warnings,
}
}
export interface PerformFullUndeployParams {
workflowId: string
userId: string
requestId?: string
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
export interface PerformFullUndeployResult {
success: boolean
error?: string
}
/**
* Performs a full workflow undeploy: marks the workflow as undeployed, cleans up
* webhook records and external subscriptions, removes MCP tools, emits a
* telemetry event, and records an audit log entry. Both the deploy API DELETE
* handler and the copilot undeploy tools must use this single function.
*/
export async function performFullUndeploy(
params: PerformFullUndeployParams
): Promise<PerformFullUndeployResult> {
const { workflowId, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
return { success: false, error: 'Workflow not found' }
}
const workflowData = workflowRecord as Record<string, unknown>
const result = await undeployWorkflow({ workflowId })
if (!result.success) {
return { success: false, error: result.error || 'Failed to undeploy workflow' }
}
await cleanupWebhooksForWorkflow(workflowId, workflowData, requestId)
await removeMcpToolsForWorkflow(workflowId, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${workflowId}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowUndeployed({ workflowId })
} catch (_e) {
// Telemetry is best-effort
}
recordAudit({
workspaceId: (workflowData.workspaceId as string) || null,
actorId: actorId,
action: AuditAction.WORKFLOW_UNDEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: (workflowData.name as string) || undefined,
description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`,
})
return { success: true }
}
export interface PerformActivateVersionParams {
workflowId: string
version: number
userId: string
workflow: Record<string, unknown>
requestId?: string
request?: NextRequest
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
export interface PerformActivateVersionResult {
success: boolean
deployedAt?: Date
error?: string
errorCode?: OrchestrationErrorCode
warnings?: string[]
}
/**
* Activates an existing deployment version: validates schedules, syncs trigger
* webhooks (with forced subscription recreation), creates schedules, activates
* the version, cleans up the previous version, syncs MCP tools, and records
* an audit entry. Both the deployment version PATCH handler and the admin
* activate route must use this function.
*/
export async function performActivateVersion(
params: PerformActivateVersionParams
): Promise<PerformActivateVersionResult> {
const { workflowId, version, userId, workflow } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const request = params.request ?? new NextRequest(new URL('/api/webhooks', getBaseUrl()))
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!versionRow?.state) {
return { success: false, error: 'Deployment version not found', errorCode: 'not_found' }
}
const deployedState = versionRow.state as { blocks?: Record<string, unknown> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return { success: false, error: 'Invalid deployed state structure', errorCode: 'validation' }
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const scheduleValidation = validateWorkflowSchedules(
blocks as Record<string, import('@/stores/workflows/workflow/types').BlockState>
)
if (!scheduleValidation.isValid) {
return {
success: false,
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
errorCode: 'validation',
}
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow,
userId,
blocks: blocks as Record<string, import('@/stores/workflows/workflow/types').BlockState>,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow,
userId,
previousVersionId,
requestId,
})
}
return {
success: false,
error: triggerSaveResult.error?.message || 'Failed to sync trigger configuration',
}
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
blocks as Record<string, import('@/stores/workflows/workflow/types').BlockState>,
db,
versionRow.id
)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow,
userId,
previousVersionId,
requestId,
})
}
return { success: false, error: scheduleResult.error || 'Failed to sync schedules' }
}
const result = await activateWorkflowVersion({ workflowId, version })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId,
workflow,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow,
userId,
previousVersionId,
requestId,
})
}
return { success: false, error: result.error || 'Failed to activate version' }
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
await cleanupDeploymentVersion({
workflowId,
workflow,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(`[${requestId}] Failed to clean up previous version`, cleanupError)
}
}
await syncMcpToolsForWorkflow({
workflowId,
requestId,
state: versionRow.state as { blocks?: Record<string, unknown> },
context: 'activate',
})
recordAudit({
workspaceId: (workflow.workspaceId as string) || null,
actorId: actorId,
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
description: `Activated deployment version ${version}`,
metadata: { version },
})
return {
success: true,
deployedAt: result.deployedAt,
warnings: triggerSaveResult.warnings,
}
}

View File

@@ -0,0 +1,155 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
const logger = createLogger('FolderLifecycle')
/**
* Recursively deletes a folder: removes child folders first, archives non-archived
* workflows in each folder via {@link archiveWorkflowsByIdsInWorkspace}, then deletes
* the folder row.
*/
export async function deleteFolderRecursively(
folderId: string,
workspaceId: string
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolder.length > 0) {
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}` }
)
stats.workflows += workflowsInFolder.length
}
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
}
/**
* Counts non-archived workflows in the folder and all descendant folders.
*/
export async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
/** Parameters for {@link performDeleteFolder}. */
export interface PerformDeleteFolderParams {
folderId: string
workspaceId: string
userId: string
folderName?: string
}
/** Outcome of {@link performDeleteFolder}. */
export interface PerformDeleteFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
deletedItems?: { folders: number; workflows: number }
}
/**
* Performs a full folder deletion: enforces the last-workflow guard,
* recursively archives child workflows and sub-folders, and records
* an audit entry. Both the folders API DELETE handler and the copilot
* delete_folder tool must use this function.
*/
export async function performDeleteFolder(
params: PerformDeleteFolderParams
): Promise<PerformDeleteFolderResult> {
const { folderId, workspaceId, userId, folderName } = params
const workflowsInFolder = await countWorkflowsInFolderRecursively(folderId, workspaceId)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return {
success: false,
error: 'Cannot delete folder containing the only workflow(s) in the workspace',
errorCode: 'validation',
}
}
const deletionStats = await deleteFolderRecursively(folderId, workspaceId)
logger.info('Deleted folder and all contents:', { folderId, deletionStats })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName,
description: `Deleted folder "${folderName || folderId}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
})
return { success: true, deletedItems: deletionStats }
}

View File

@@ -0,0 +1,30 @@
export {
type ChatDeployPayload,
type PerformChatDeployResult,
type PerformChatUndeployParams,
type PerformChatUndeployResult,
performChatDeploy,
performChatUndeploy,
} from './chat-deploy'
export {
type PerformActivateVersionParams,
type PerformActivateVersionResult,
type PerformFullDeployParams,
type PerformFullDeployResult,
type PerformFullUndeployParams,
type PerformFullUndeployResult,
performActivateVersion,
performFullDeploy,
performFullUndeploy,
} from './deploy'
export {
type PerformDeleteFolderParams,
type PerformDeleteFolderResult,
performDeleteFolder,
} from './folder-lifecycle'
export type { OrchestrationErrorCode } from './types'
export {
type PerformDeleteWorkflowParams,
type PerformDeleteWorkflowResult,
performDeleteWorkflow,
} from './workflow-lifecycle'

View File

@@ -0,0 +1 @@
export type OrchestrationErrorCode = 'validation' | 'not_found' | 'internal'

View File

@@ -0,0 +1,118 @@
import { db } from '@sim/db'
import { templates, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
const logger = createLogger('WorkflowLifecycle')
export interface PerformDeleteWorkflowParams {
workflowId: string
userId: string
requestId?: string
/** When 'delete', delete published templates. When 'orphan' (default), set their workflowId to null. */
templateAction?: 'delete' | 'orphan'
/** When true, allows deleting the last workflow in a workspace (used by admin API). */
skipLastWorkflowGuard?: boolean
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
export interface PerformDeleteWorkflowResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
}
/**
* Performs a full workflow deletion: enforces the last-workflow guard,
* handles published templates, archives the workflow via `archiveWorkflow`,
* and records an audit entry. Both the workflow API DELETE handler and the
* copilot delete_workflow tool must use this function.
*/
export async function performDeleteWorkflow(
params: PerformDeleteWorkflowParams
): Promise<PerformDeleteWorkflowResult> {
const { workflowId, userId, templateAction = 'orphan', skipLastWorkflowGuard = false } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const [workflowRecord] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
if (!skipLastWorkflowGuard && workflowRecord.workspaceId) {
const totalWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workflowRecord.workspaceId), isNull(workflow.archivedAt)))
if (totalWorkflows.length <= 1) {
return {
success: false,
error: 'Cannot delete the only workflow in the workspace',
errorCode: 'validation',
}
}
}
try {
const publishedTemplates = await db
.select({ id: templates.id })
.from(templates)
.where(eq(templates.workflowId, workflowId))
if (publishedTemplates.length > 0) {
if (templateAction === 'delete') {
await db.delete(templates).where(eq(templates.workflowId, workflowId))
logger.info(
`[${requestId}] Deleted ${publishedTemplates.length} templates for workflow ${workflowId}`
)
} else {
await db
.update(templates)
.set({ workflowId: null })
.where(eq(templates.workflowId, workflowId))
logger.info(
`[${requestId}] Orphaned ${publishedTemplates.length} templates for workflow ${workflowId}`
)
}
}
} catch (templateError) {
logger.warn(`[${requestId}] Failed to handle templates for workflow ${workflowId}`, {
error: templateError,
})
}
const archiveResult = await archiveWorkflow(workflowId, { requestId })
if (!archiveResult.workflow) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
logger.info(`[${requestId}] Successfully archived workflow ${workflowId}`)
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: actorId,
action: AuditAction.WORKFLOW_DELETED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowRecord.name,
description: `Archived workflow "${workflowRecord.name}"`,
metadata: {
archived: archiveResult.archived,
templateAction,
},
})
return { success: true }
}

View File

@@ -616,6 +616,36 @@ export async function deleteFolderRecord(folderId: string): Promise<boolean> {
return true
}
/**
* Checks whether setting `parentId` as the parent of `folderId` would
* create a circular reference in the folder tree.
*/
export async function checkForCircularReference(
folderId: string,
parentId: string
): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId) || currentParentId === folderId) {
return true
}
visited.add(currentParentId)
const [parent] = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, currentParentId))
.limit(1)
currentParentId = parent?.parentId || null
}
return false
}
export async function listFolders(workspaceId: string) {
return db
.select({

View File

@@ -22,6 +22,8 @@ export const auditMock = {
CHAT_DEPLOYED: 'chat.deployed',
CHAT_UPDATED: 'chat.updated',
CHAT_DELETED: 'chat.deleted',
CREDENTIAL_DELETED: 'credential.deleted',
CREDENTIAL_RENAMED: 'credential.renamed',
CREDIT_PURCHASED: 'credit.purchased',
CREDENTIAL_SET_CREATED: 'credential_set.created',
CREDENTIAL_SET_UPDATED: 'credential_set.updated',
@@ -32,6 +34,9 @@ export const auditMock = {
CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted',
CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent',
CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked',
CUSTOM_TOOL_CREATED: 'custom_tool.created',
CUSTOM_TOOL_UPDATED: 'custom_tool.updated',
CUSTOM_TOOL_DELETED: 'custom_tool.deleted',
CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored',
CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded',
DOCUMENT_UPLOADED: 'document.uploaded',
@@ -85,6 +90,9 @@ export const auditMock = {
PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added',
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
SCHEDULE_UPDATED: 'schedule.updated',
SKILL_CREATED: 'skill.created',
SKILL_UPDATED: 'skill.updated',
SKILL_DELETED: 'skill.deleted',
TABLE_CREATED: 'table.created',
TABLE_UPDATED: 'table.updated',
TABLE_DELETED: 'table.deleted',
@@ -116,6 +124,7 @@ export const auditMock = {
CHAT: 'chat',
CONNECTOR: 'connector',
CREDENTIAL_SET: 'credential_set',
CUSTOM_TOOL: 'custom_tool',
DOCUMENT: 'document',
ENVIRONMENT: 'environment',
FILE: 'file',
@@ -129,6 +138,7 @@ export const auditMock = {
PASSWORD: 'password',
PERMISSION_GROUP: 'permission_group',
SCHEDULE: 'schedule',
SKILL: 'skill',
TABLE: 'table',
TEMPLATE: 'template',
WEBHOOK: 'webhook',