Skip to content

Commit 57a0f47

Browse files
fix(billing): address review findings on the paid-plan gate
- execute route: gate on the workflow's workspace billed account (like MCP/A2A/ webhooks/chat) instead of the caller/creator's personal plan, so a paid workspace is never 402'd because an individual is on free - webhooks: all-generic-all-free fan-out now returns 402, not a 500 'No webhooks processed' fallback - deploy modal: hold the gate closed until the subscription query resolves (isFree(undefined) is true) to avoid flashing the upgrade wall at paid users
1 parent 6eaf76d commit 57a0f47

6 files changed

Lines changed: 76 additions & 14 deletions

File tree

apps/sim/app/api/webhooks/trigger/[path]/route.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ describe('Webhook Trigger API Route', () => {
399399
isFromNormalizedTables: true,
400400
})
401401
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
402+
isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true)
402403

403404
mockExecutionDependencies()
404405
mockTriggerDevSdk()
@@ -633,6 +634,44 @@ describe('Webhook Trigger API Route', () => {
633634
expect(response.status).toBe(402)
634635
})
635636

637+
it('returns 402 (not 500) when every webhook in a shared path is generic and free', async () => {
638+
testData.webhooks.push(
639+
{
640+
id: 'generic-webhook-a',
641+
provider: 'generic',
642+
path: 'test-path',
643+
isActive: true,
644+
providerConfig: { requireAuth: false },
645+
workflowId: 'test-workflow-id',
646+
rateLimitCount: 100,
647+
rateLimitPeriod: 60,
648+
},
649+
{
650+
id: 'generic-webhook-b',
651+
provider: 'generic',
652+
path: 'test-path',
653+
isActive: true,
654+
providerConfig: { requireAuth: false },
655+
workflowId: 'test-workflow-id',
656+
rateLimitCount: 100,
657+
rateLimitPeriod: 60,
658+
}
659+
)
660+
testData.workflows.push({
661+
id: 'test-workflow-id',
662+
userId: 'test-user-id',
663+
workspaceId: 'test-workspace-id',
664+
})
665+
isWorkspaceApiExecutionEntitledMock.mockResolvedValue(false)
666+
667+
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
668+
const params = Promise.resolve({ path: 'test-path' })
669+
670+
const response = await POST(req as any, { params })
671+
672+
expect(response.status).toBe(402)
673+
})
674+
636675
it('should authenticate with Bearer token when no custom header is configured', async () => {
637676
testData.webhooks.push({
638677
id: 'generic-webhook-id',

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ async function handleWebhookPost(
126126
// Process each webhook
127127
// For credential sets with shared paths, each webhook represents a different credential
128128
const responses: NextResponse[] = []
129+
let billingBlocked = false
129130

130131
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
131132
// Generic ("custom") webhooks are an unauthenticated programmatic execution
@@ -136,6 +137,7 @@ async function handleWebhookPost(
136137
!(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId))
137138
) {
138139
logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`)
140+
billingBlocked = true
139141
if (webhooksForPath.length > 1) continue
140142
return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
141143
}
@@ -203,6 +205,9 @@ async function handleWebhookPost(
203205
}
204206

205207
if (responses.length === 0) {
208+
if (billingBlocked) {
209+
return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
210+
}
206211
return new NextResponse('No webhooks processed successfully', { status: 500 })
207212
}
208213

apps/sim/app/api/workflows/[id]/execute/response-block.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,19 @@ const {
2121
mockRegisterLargeValueOwner,
2222
mockUploadFile,
2323
uploadedFiles,
24-
mockIsApiExecutionEntitled,
24+
mockIsWorkspaceApiExecutionEntitled,
2525
} = vi.hoisted(() => ({
2626
mockAddLargeValueReference: vi.fn(),
2727
mockDownloadFile: vi.fn(),
2828
mockRegisterLargeValueOwner: vi.fn(),
2929
mockUploadFile: vi.fn(),
3030
uploadedFiles: new Map<string, Buffer>(),
31-
mockIsApiExecutionEntitled: vi.fn().mockResolvedValue(true),
31+
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
3232
}))
3333

3434
vi.mock('@/lib/billing/core/api-access', () => ({
3535
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
36-
isApiExecutionEntitled: mockIsApiExecutionEntitled,
36+
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
3737
}))
3838

3939
const MATERIALIZATION_CONTEXT = {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ const {
2222
mockEnqueue,
2323
mockExecuteWorkflowCore,
2424
mockHandlePostExecutionPauseState,
25-
mockIsApiExecutionEntitled,
25+
mockIsWorkspaceApiExecutionEntitled,
2626
} = vi.hoisted(() => ({
2727
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
2828
mockExecuteWorkflowCore: vi.fn(),
2929
mockHandlePostExecutionPauseState: vi.fn(),
30-
mockIsApiExecutionEntitled: vi.fn().mockResolvedValue(true),
30+
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
3131
}))
3232

3333
vi.mock('@/lib/billing/core/api-access', () => ({
3434
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
35-
isApiExecutionEntitled: mockIsApiExecutionEntitled,
35+
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
3636
}))
3737

3838
const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth
1111
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
1212
import {
1313
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
14-
isApiExecutionEntitled,
14+
isWorkspaceApiExecutionEntitled,
1515
} from '@/lib/billing/core/api-access'
1616
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
1717
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
@@ -400,6 +400,7 @@ async function handleExecutePost(
400400

401401
let userId: string
402402
let isPublicApiAccess = false
403+
let gateWorkspaceId: string | undefined
403404

404405
if (!auth.success || !auth.userId) {
405406
const hasExplicitCredentials =
@@ -434,15 +435,29 @@ async function handleExecutePost(
434435

435436
userId = wf.userId
436437
isPublicApiAccess = true
438+
gateWorkspaceId = wf.workspaceId
437439
} else {
438440
userId = auth.userId
439441
}
440442

441-
if (
442-
(auth.authType === AuthType.API_KEY || isPublicApiAccess) &&
443-
!(await isApiExecutionEntitled(userId))
444-
) {
445-
return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
443+
// Programmatic execution (API key or public API) is gated on the workflow's
444+
// workspace billed account — the same entity MCP/A2A/webhooks/chat gate on —
445+
// so a paid workspace is never blocked because an individual is on free.
446+
if (auth.authType === AuthType.API_KEY || isPublicApiAccess) {
447+
if (!gateWorkspaceId) {
448+
const [wfRow] = await db
449+
.select({ workspaceId: workflowTable.workspaceId })
450+
.from(workflowTable)
451+
.where(eq(workflowTable.id, workflowId))
452+
.limit(1)
453+
gateWorkspaceId = wfRow?.workspaceId ?? undefined
454+
}
455+
if (!(await isWorkspaceApiExecutionEntitled(gateWorkspaceId))) {
456+
return NextResponse.json(
457+
{ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE },
458+
{ status: 402 }
459+
)
460+
}
446461
}
447462

448463
let body: any = {}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,11 @@ export function DeployModal({
158158
const userPermissions = useUserPermissionsContext()
159159
const canManageWorkspaceKeys = userPermissions.canAdmin
160160
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
161-
const { data: subscriptionData } = useSubscriptionData()
162-
const gateProgrammaticDeploy = isHosted && isFree(subscriptionData?.data?.plan)
161+
const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscriptionData()
162+
// Hold the gate closed until the plan is known — isFree(undefined) is true, so
163+
// gating during load would flash the upgrade wall at paid users.
164+
const gateProgrammaticDeploy =
165+
isHosted && !isLoadingSubscription && isFree(subscriptionData?.data?.plan)
163166
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
164167
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
165168
workflowWorkspaceId || ''

0 commit comments

Comments
 (0)