Skip to content
7 changes: 2 additions & 5 deletions apps/sim/app/api/chat/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,14 @@ import { isDev } from '@/lib/core/config/env-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status'
import {
getWorkflowDeploymentSummary,
performChatUndeploy,
performFullDeploy,
} from '@/lib/workflows/orchestration'
import { checkChatAccess } from '@/app/api/chat/utils'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import {
ChatDeployAuthNotAllowedError,
validateChatDeployAuth,
Expand Down
20 changes: 5 additions & 15 deletions apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
}))

vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
Expand Down Expand Up @@ -78,21 +80,9 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => {
expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body)
})

it('keeps product analytics on the v2 adapter', async () => {
const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' }
await Reflect.get(
POST,
'onSuccess'
)({
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
result,
})
expect(mocks.capture).toHaveBeenCalledWith(
'user-1',
'workflow_deployed',
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1' },
expect.objectContaining({ groups: { workspace: 'workspace-1' } })
)
it('defers deploy analytics to durable activation', () => {
expect(Reflect.get(POST, 'onSuccess')).toBeUndefined()
expect(mocks.capture).not.toHaveBeenCalled()
})

it('keeps undeploy on the authorized operation and declared response schema', () => {
Expand Down
14 changes: 0 additions & 14 deletions apps/sim/app/api/v2/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,6 @@ export const POST = defineV2JsonRoute({
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
},
}),
onSuccess: ({ principal, result }) => {
if (principal.kind !== 'personal_api_key') {
throw new Error('Admin deployment unexpectedly admitted a workspace API key')
}
captureServerEvent(
principal.userId,
'workflow_deployed',
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
{
groups: { workspace: result.workspaceId },
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
}
)
},
})

export const DELETE = defineV2JsonRoute({
Expand Down
59 changes: 55 additions & 4 deletions apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,22 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
rateLimitSubscription: null,
keyType: 'workspace',
})
dbChainMockFns.limit.mockResolvedValue([applicationContext])
dbChainMockFns.limit
.mockResolvedValueOnce([
{
workflowId: workflowRecord.id,
workflow: workflowRecord,
workspaceId: workflowRecord.workspaceId,
},
])
.mockResolvedValueOnce([
{
id: applicationContext.workspaceId,
organizationId: applicationContext.workspaceOrganizationId,
allowPersonalApiKeys: applicationContext.allowPersonalApiKeys,
billedAccountUserId: applicationContext.billedAccountUserId,
},
])
mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord })
mockClaimExecutionId.mockImplementation(async (executionId: string) => ({
key: `workflow-execution-id:${executionId}`,
Expand Down Expand Up @@ -420,9 +435,23 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
rateLimitSubscription: null,
keyType: 'personal',
})
dbChainMockFns.limit.mockResolvedValueOnce([
{ ...applicationContext, allowPersonalApiKeys: false },
])
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit
.mockResolvedValueOnce([
{
workflowId: workflowRecord.id,
workflow: workflowRecord,
workspaceId: workflowRecord.workspaceId,
},
])
.mockResolvedValueOnce([
{
id: applicationContext.workspaceId,
organizationId: applicationContext.workspaceOrganizationId,
allowPersonalApiKeys: false,
billedAccountUserId: applicationContext.billedAccountUserId,
},
])

const res = await callExecute({ input: {} })

Expand Down Expand Up @@ -487,12 +516,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
})

it('runs the anonymous public path sync but refuses async', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])

const okRes = await callPublicExecute({ input: {} })
expect(okRes.status).toBe(200)
expect(mockCheckPreAuthRate.mock.invocationCallOrder[0]).toBeLessThan(
dbChainMockFns.select.mock.invocationCallOrder[0]
)
expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled()
expect(mockCheckOperationRate).not.toHaveBeenCalled()
expect(mockPreprocessExecution).toHaveBeenCalledWith(
Expand All @@ -506,7 +539,24 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
expect(asyncRes.status).toBe(400)
})

it('rejects anonymous abuse before looking up the workflow', async () => {
mockCheckPreAuthRate.mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: new Date('2026-08-08T05:00:00Z'),
retryAfterMs: 10_000,
})

const response = await callPublicExecute({ input: {} })

expect(response.status).toBe(429)
expect(dbChainMockFns.select).not.toHaveBeenCalled()
expect(mockValidatePublicApiAllowed).not.toHaveBeenCalled()
expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled()
})

it('401s non-public workflows without a key', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
Expand All @@ -532,6 +582,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
})

it('returns a safe error when canonical workflow lookup fails', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockRejectedValueOnce(new Error('database connection details'))

const response = await callExecute({ input: {} })
Expand Down
19 changes: 10 additions & 9 deletions apps/sim/app/api/v2/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@/lib/api/contracts/v2/workflows'
import { parseRequest } from '@/lib/api/server'
import {
admitV2Request,
admitOptionalV2Request,
V2RouteInfrastructureError,
v2ApiKeyAuth,
v2RateLimits,
Expand Down Expand Up @@ -112,14 +112,15 @@ export const POST = withRouteHandler(
let isPublicApiAccess = false
let apiKeyPrincipal: V2ApiKeyPrincipal | undefined

if (req.headers.has('x-api-key')) {
const admission = await admitV2Request(
req,
workflowOperations.execute,
v2ApiKeyAuth,
v2RateLimits.publicApi
)
if (!admission.success) return admission.response
const admission = await admitOptionalV2Request(
req,
workflowOperations.execute,
v2ApiKeyAuth,
v2RateLimits.publicApi
)
if (!admission.success) return admission.response

if (admission.auth) {
apiKeyPrincipal = admission.auth.principal
userId = admission.auth.rolloutUserId
} else {
Expand Down
20 changes: 4 additions & 16 deletions apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import { describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
defineRoute: vi.fn((definition) => definition),
capture: vi.fn(),
}))

vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' },
}))
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))

import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows'
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
Expand Down Expand Up @@ -75,19 +75,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => {
expect(v2RollbackWorkflowContract.response.schema.parse(body)).toEqual(body)
})

it('keeps activation analytics on the v2 adapter', async () => {
await Reflect.get(
POST,
'onSuccess'
)({
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
result: { workflowId: 'workflow-1', workspaceId: 'workspace-1', version: 1 },
})
expect(mocks.capture).toHaveBeenCalledWith(
'user-1',
'deployment_version_activated',
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1', version: 1 },
{ groups: { workspace: 'workspace-1' } }
)
it('defers activation analytics to durable activation', () => {
expect(Reflect.get(POST, 'onSuccess')).toBeUndefined()
})
})
16 changes: 0 additions & 16 deletions apps/sim/app/api/v2/workflows/[id]/rollback/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
import { activateWorkflowVersion } from '@/lib/workflows/application/deployments'
import { workflowOperations } from '@/lib/workflows/application/operations'
Expand Down Expand Up @@ -40,19 +39,4 @@ export const POST = defineV2JsonRoute({
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
},
}),
onSuccess: ({ principal, result }) => {
if (principal.kind !== 'personal_api_key') {
throw new Error('Admin activation unexpectedly admitted a workspace API key')
}
captureServerEvent(
principal.userId,
'deployment_version_activated',
{
workflow_id: result.workflowId,
workspace_id: result.workspaceId,
version: result.version,
},
{ groups: { workspace: result.workspaceId } }
)
},
})
Loading
Loading