Skip to content

Commit 9fad504

Browse files
fix(workflows): migrate Copilot application boundary
1 parent 2cab7d7 commit 9fad504

34 files changed

Lines changed: 1644 additions & 651 deletions

apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => {
3939
workflowId: 'workflow-1',
4040
name: undefined,
4141
description: undefined,
42+
analytics: 'human',
4243
})
4344
)
4445

@@ -78,21 +79,9 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => {
7879
expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body)
7980
})
8081

81-
it('keeps product analytics on the v2 adapter', async () => {
82-
const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' }
83-
await Reflect.get(
84-
POST,
85-
'onSuccess'
86-
)({
87-
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
88-
result,
89-
})
90-
expect(mocks.capture).toHaveBeenCalledWith(
91-
'user-1',
92-
'workflow_deployed',
93-
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1' },
94-
expect.objectContaining({ groups: { workspace: 'workspace-1' } })
95-
)
82+
it('defers deploy analytics to durable activation', () => {
83+
expect(Reflect.get(POST, 'onSuccess')).toBeUndefined()
84+
expect(mocks.capture).not.toHaveBeenCalled()
9685
})
9786

9887
it('keeps undeploy on the authorized operation and declared response schema', () => {

apps/sim/app/api/v2/workflows/[id]/deploy/route.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const POST = defineV2JsonRoute({
3030
name: body.name,
3131
description: body.description ?? undefined,
3232
requestId: generateRequestId(),
33+
analytics: 'human' as const,
3334
}),
3435
useCase: deployWorkflow,
3536
present: (result) => ({
@@ -43,20 +44,6 @@ export const POST = defineV2JsonRoute({
4344
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
4445
},
4546
}),
46-
onSuccess: ({ principal, result }) => {
47-
if (principal.kind !== 'personal_api_key') {
48-
throw new Error('Admin deployment unexpectedly admitted a workspace API key')
49-
}
50-
captureServerEvent(
51-
principal.userId,
52-
'workflow_deployed',
53-
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
54-
{
55-
groups: { workspace: result.workspaceId },
56-
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
57-
}
58-
)
59-
},
6047
})
6148

6249
export const DELETE = defineV2JsonRoute({

apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,9 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
493493

494494
const okRes = await callPublicExecute({ input: {} })
495495
expect(okRes.status).toBe(200)
496+
expect(mockCheckPreAuthRate.mock.invocationCallOrder[0]).toBeLessThan(
497+
dbChainMockFns.select.mock.invocationCallOrder[0]
498+
)
496499
expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled()
497500
expect(mockCheckOperationRate).not.toHaveBeenCalled()
498501
expect(mockPreprocessExecution).toHaveBeenCalledWith(
@@ -506,6 +509,22 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
506509
expect(asyncRes.status).toBe(400)
507510
})
508511

512+
it('rejects anonymous abuse before looking up the workflow', async () => {
513+
mockCheckPreAuthRate.mockResolvedValueOnce({
514+
allowed: false,
515+
remaining: 0,
516+
resetAt: new Date('2026-08-08T05:00:00Z'),
517+
retryAfterMs: 10_000,
518+
})
519+
520+
const response = await callPublicExecute({ input: {} })
521+
522+
expect(response.status).toBe(429)
523+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
524+
expect(mockValidatePublicApiAllowed).not.toHaveBeenCalled()
525+
expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled()
526+
})
527+
509528
it('401s non-public workflows without a key', async () => {
510529
dbChainMockFns.limit.mockResolvedValueOnce([
511530
{ isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },

apps/sim/app/api/v2/workflows/[id]/execute/route.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '@/lib/api/contracts/v2/workflows'
1212
import { parseRequest } from '@/lib/api/server'
1313
import {
14-
admitV2Request,
14+
admitOptionalV2Request,
1515
V2RouteInfrastructureError,
1616
v2ApiKeyAuth,
1717
v2RateLimits,
@@ -112,14 +112,15 @@ export const POST = withRouteHandler(
112112
let isPublicApiAccess = false
113113
let apiKeyPrincipal: V2ApiKeyPrincipal | undefined
114114

115-
if (req.headers.has('x-api-key')) {
116-
const admission = await admitV2Request(
117-
req,
118-
workflowOperations.execute,
119-
v2ApiKeyAuth,
120-
v2RateLimits.publicApi
121-
)
122-
if (!admission.success) return admission.response
115+
const admission = await admitOptionalV2Request(
116+
req,
117+
workflowOperations.execute,
118+
v2ApiKeyAuth,
119+
v2RateLimits.publicApi
120+
)
121+
if (!admission.success) return admission.response
122+
123+
if (admission.auth) {
123124
apiKeyPrincipal = admission.auth.principal
124125
userId = admission.auth.rolloutUserId
125126
} else {

apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest'
55

66
const mocks = vi.hoisted(() => ({
77
defineRoute: vi.fn((definition) => definition),
8-
capture: vi.fn(),
98
}))
109

1110
vi.mock('@/lib/api/server/routes', () => ({
@@ -14,7 +13,6 @@ vi.mock('@/lib/api/server/routes', () => ({
1413
v2RateLimits: { publicApi: { kind: 'public-api' } },
1514
v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' },
1615
}))
17-
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
1816

1917
import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows'
2018
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
@@ -36,6 +34,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => {
3634
workflowId: 'workflow-1',
3735
version: undefined,
3836
transition: 'rollback',
37+
analytics: 'human',
3938
})
4039
)
4140

@@ -75,19 +74,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => {
7574
expect(v2RollbackWorkflowContract.response.schema.parse(body)).toEqual(body)
7675
})
7776

78-
it('keeps activation analytics on the v2 adapter', async () => {
79-
await Reflect.get(
80-
POST,
81-
'onSuccess'
82-
)({
83-
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
84-
result: { workflowId: 'workflow-1', workspaceId: 'workspace-1', version: 1 },
85-
})
86-
expect(mocks.capture).toHaveBeenCalledWith(
87-
'user-1',
88-
'deployment_version_activated',
89-
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1', version: 1 },
90-
{ groups: { workspace: 'workspace-1' } }
91-
)
77+
it('defers activation analytics to durable activation', () => {
78+
expect(Reflect.get(POST, 'onSuccess')).toBeUndefined()
9279
})
9380
})

apps/sim/app/api/v2/workflows/[id]/rollback/route.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows'
22
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
33
import { generateRequestId } from '@/lib/core/utils/request'
4-
import { captureServerEvent } from '@/lib/posthog/server'
54
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
65
import { activateWorkflowVersion } from '@/lib/workflows/application/deployments'
76
import { workflowOperations } from '@/lib/workflows/application/operations'
@@ -27,6 +26,7 @@ export const POST = defineV2JsonRoute({
2726
version: body.version,
2827
transition: 'rollback' as const,
2928
requestId: generateRequestId(),
29+
analytics: 'human' as const,
3030
}),
3131
useCase: activateWorkflowVersion,
3232
present: (result) => ({
@@ -40,19 +40,4 @@ export const POST = defineV2JsonRoute({
4040
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
4141
},
4242
}),
43-
onSuccess: ({ principal, result }) => {
44-
if (principal.kind !== 'personal_api_key') {
45-
throw new Error('Admin activation unexpectedly admitted a workspace API key')
46-
}
47-
captureServerEvent(
48-
principal.userId,
49-
'deployment_version_activated',
50-
{
51-
workflow_id: result.workflowId,
52-
workspace_id: result.workspaceId,
53-
version: result.version,
54-
},
55-
{ groups: { workspace: result.workspaceId } }
56-
)
57-
},
5843
})

apps/sim/app/api/workflows/[id]/deploy/route.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const POST = withRouteHandler(
110110
const principal = await internalSessionAuth.authenticate()
111111
const result = await deployWorkflow.execute({
112112
principal,
113-
input: { workflowId: id, requestId },
113+
input: { workflowId: id, requestId, analytics: 'human' },
114114
request,
115115
})
116116

@@ -120,16 +120,6 @@ export const POST = withRouteHandler(
120120
`[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}`
121121
)
122122

123-
captureServerEvent(
124-
principal.userId,
125-
'workflow_deployed',
126-
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
127-
{
128-
groups: { workspace: result.workspaceId },
129-
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
130-
}
131-
)
132-
133123
return createSuccessResponse({
134124
apiKey: 'Workspace API keys',
135125
isDeployed,

apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/ser
88
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11-
import { captureServerEvent } from '@/lib/posthog/server'
1211
import { activateWorkflowVersion } from '@/lib/workflows/application/deployments'
1312
import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version'
1413
import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils'
@@ -85,7 +84,13 @@ export const PATCH = withRouteHandler(
8584
if (isActive) {
8685
const activateResult = await activateWorkflowVersion.execute({
8786
principal,
88-
input: { workflowId: id, version: versionNum, transition: 'activate', requestId },
87+
input: {
88+
workflowId: id,
89+
version: versionNum,
90+
transition: 'activate',
91+
requestId,
92+
analytics: 'human',
93+
},
8994
request,
9095
})
9196

@@ -124,17 +129,6 @@ export const PATCH = withRouteHandler(
124129
}
125130
}
126131

127-
captureServerEvent(
128-
principal.userId,
129-
'deployment_version_activated',
130-
{
131-
workflow_id: activateResult.workflowId,
132-
workspace_id: activateResult.workspaceId,
133-
version: versionNum,
134-
},
135-
{ groups: { workspace: activateResult.workspaceId } }
136-
)
137-
138132
return createSuccessResponse({
139133
success: true,
140134
deployedAt: activateResult.deployedAt ?? null,

apps/sim/lib/api/server/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
} from '@/lib/api/server/routes/internal-json-route'
1616
export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route'
1717
export {
18+
admitOptionalV2Request,
1819
admitV2Request,
1920
defineV2JsonRoute,
2021
type V2ErrorPolicy,

apps/sim/lib/api/server/routes/v2-json-route.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -110,27 +110,26 @@ export const v2OrchestrationErrorPolicy = {
110110
},
111111
} satisfies V2ErrorPolicy
112112

113-
export async function admitV2Request(
114-
request: NextRequest,
115-
operation: ApplicationOperation,
116-
authPolicy: typeof v2ApiKeyAuth,
117-
rateLimitPolicy: V2RateLimitPolicy
118-
): Promise<
119-
{ success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse }
120-
> {
113+
async function enforceV2PreAuthIpLimit(request: NextRequest): Promise<NextResponse | null> {
121114
const ip = getClientIp(request)
122115
const abuseLimit = await rateLimiter.checkRateLimitDirect(
123116
`v2:preauth:ip:${ip}`,
124117
V2_PREAUTH_IP_LIMIT,
125118
{ failClosed: true }
126119
)
127-
if (!abuseLimit.allowed) {
128-
return {
129-
success: false,
130-
response: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }),
131-
}
132-
}
120+
return abuseLimit.allowed
121+
? null
122+
: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens })
123+
}
133124

125+
async function admitAuthenticatedV2Request(
126+
request: NextRequest,
127+
operation: ApplicationOperation,
128+
authPolicy: typeof v2ApiKeyAuth,
129+
rateLimitPolicy: V2RateLimitPolicy
130+
): Promise<
131+
{ success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse }
132+
> {
134133
let auth: V2ApiKeyAuthContext
135134
try {
136135
auth = await authPolicy.authenticate(request)
@@ -153,6 +152,33 @@ export async function admitV2Request(
153152
return limited ? { success: false, response: limited } : { success: true, auth }
154153
}
155154

155+
export async function admitV2Request(
156+
request: NextRequest,
157+
operation: ApplicationOperation,
158+
authPolicy: typeof v2ApiKeyAuth,
159+
rateLimitPolicy: V2RateLimitPolicy
160+
): Promise<
161+
{ success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse }
162+
> {
163+
const preAuthResponse = await enforceV2PreAuthIpLimit(request)
164+
if (preAuthResponse) return { success: false, response: preAuthResponse }
165+
return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy)
166+
}
167+
168+
export async function admitOptionalV2Request(
169+
request: NextRequest,
170+
operation: ApplicationOperation,
171+
authPolicy: typeof v2ApiKeyAuth,
172+
rateLimitPolicy: V2RateLimitPolicy
173+
): Promise<
174+
{ success: true; auth?: V2ApiKeyAuthContext } | { success: false; response: NextResponse }
175+
> {
176+
const preAuthResponse = await enforceV2PreAuthIpLimit(request)
177+
if (preAuthResponse) return { success: false, response: preAuthResponse }
178+
if (!request.headers.has('x-api-key')) return { success: true }
179+
return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy)
180+
}
181+
156182
interface V2JsonRouteOptions<C extends JsonApiRouteContract, O extends ApplicationOperation, I, R>
157183
extends JsonRouteDefinition<C, O, I, R> {
158184
auth: typeof v2ApiKeyAuth

0 commit comments

Comments
 (0)