From 2e9aea7971d3cd6728f53ccc0312cf0437206409 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 13 Jun 2026 17:08:36 -0700 Subject: [PATCH 1/5] feat(billing): gate programmatic workflow execution behind a paid plan Block free-plan accounts (hosted only) from running workflows programmatically: API-key/public execute, MCP server, A2A agent server, generic webhooks, and cross-origin chat embeds. Returns 402 (403 for chat embeds) with an upgrade message; provider webhooks, session/browser runs, internal-JWT executor traffic, and self-hosted are unaffected. - Add isApiExecutionEntitled / isWorkspaceApiExecutionEntitled gate helpers - Gate the execute, mcp/serve, a2a/serve, webhooks/trigger, and chat routes - Deploy modal: show an upgrade prompt on the API/MCP/A2A tabs for free users - Upgrade page: rename Pro feature to 'Deploy workflows as APIs'; API endpoint rate-limit row shows 0 for Free --- apps/sim/app/api/a2a/serve/[agentId]/route.ts | 15 +++ .../app/api/chat/[identifier]/route.test.ts | 50 +++++++++- apps/sim/app/api/chat/[identifier]/route.ts | 8 +- apps/sim/app/api/chat/utils.test.ts | 71 ++++++++++++- apps/sim/app/api/chat/utils.ts | 51 ++++++++++ .../api/mcp/serve/[serverId]/route.test.ts | 34 ++++++- .../sim/app/api/mcp/serve/[serverId]/route.ts | 13 +++ .../app/api/webhooks/trigger/[path]/route.ts | 16 +++ .../app/api/workflows/[id]/execute/route.ts | 11 +++ .../comparison-table/comparison-data.ts | 2 +- .../[workspaceId]/upgrade/plan-configs.ts | 2 +- .../deploy-upgrade-gate.tsx | 50 ++++++++++ .../components/deploy-upgrade-gate/index.ts | 1 + .../deploy-modal/components/index.ts | 1 + .../components/deploy-modal/deploy-modal.tsx | 99 ++++++++++++------- apps/sim/lib/billing/core/api-access.test.ts | 91 +++++++++++++++++ apps/sim/lib/billing/core/api-access.ts | 40 ++++++++ apps/sim/lib/billing/index.ts | 1 + 18 files changed, 510 insertions(+), 46 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts create mode 100644 apps/sim/lib/billing/core/api-access.test.ts create mode 100644 apps/sim/lib/billing/core/api-access.ts diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts index 2a246c7cb3e..62c41b0d48a 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/route.ts @@ -26,6 +26,10 @@ import { a2aTaskIdParamsSchema, } from '@/lib/api/contracts/a2a-agents' import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid' +import { + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, + isApiExecutionEntitled, +} from '@/lib/billing/core/api-access' import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { getClientIp } from '@/lib/core/utils/request' @@ -312,6 +316,17 @@ export const POST = withRouteHandler( { status: 500 } ) } + if (!(await isApiExecutionEntitled(billedUserId))) { + return NextResponse.json( + createError( + id, + A2A_ERROR_CODES.AGENT_UNAVAILABLE, + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE + ), + { status: 402 } + ) + } + const executionUserId = isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index b1b36d60b6d..f9c30ba56c3 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -13,6 +13,7 @@ import { workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' +import { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' /** @@ -63,10 +64,16 @@ const createMockStream = () => { }) } -const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = vi.hoisted(() => ({ +const { + mockValidateChatAuth, + mockSetChatAuthCookie, + mockValidateAuthToken, + mockAssertChatEmbedAllowed, +} = vi.hoisted(() => ({ mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), mockSetChatAuthCookie: vi.fn(), mockValidateAuthToken: vi.fn().mockReturnValue(false), + mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null), })) const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse @@ -87,6 +94,7 @@ vi.mock('@/lib/core/security/deployment', () => ({ vi.mock('@/app/api/chat/utils', () => ({ validateChatAuth: mockValidateChatAuth, setChatAuthCookie: mockSetChatAuthCookie, + assertChatEmbedAllowed: mockAssertChatEmbedAllowed, })) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) @@ -230,6 +238,24 @@ describe('Chat Identifier API Route', () => { expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat') }) + it('should return 403 when embedding is blocked for a cross-origin caller', async () => { + mockAssertChatEmbedAllowed.mockResolvedValueOnce( + NextResponse.json( + { error: 'Embedding this chat on external sites requires a paid plan' }, + { + status: 403, + } + ) + ) + + const req = createMockNextRequest('GET', undefined, { origin: 'https://evil.example.com' }) + const params = Promise.resolve({ identifier: 'test-chat' }) + + const response = await GET(req, { params }) + + expect(response.status).toBe(403) + }) + it('should return 404 for non-existent identifier', async () => { dbChainMockFns.select.mockImplementation(() => { return { @@ -302,6 +328,28 @@ describe('Chat Identifier API Route', () => { }) describe('POST endpoint', () => { + it('should return 403 when embedding is blocked for a cross-origin caller', async () => { + mockAssertChatEmbedAllowed.mockResolvedValueOnce( + NextResponse.json( + { error: 'Embedding this chat on external sites requires a paid plan' }, + { + status: 403, + } + ) + ) + + const req = createMockNextRequest( + 'POST', + { input: 'Hello' }, + { origin: 'https://evil.example.com' } + ) + const params = Promise.resolve({ identifier: 'test-chat' }) + + const response = await POST(req, { params }) + + expect(response.status).toBe(403) + }) + it('should return chat config on successful authentication', async () => { const req = createMockNextRequest('POST', { password: 'test-password' }) const params = Promise.resolve({ identifier: 'password-protected-chat' }) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 330ece68852..dc02c328436 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -15,7 +15,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { ChatFiles } from '@/lib/uploads' -import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' +import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatIdentifierAPI') @@ -134,6 +134,9 @@ export const POST = withRouteHandler( return createErrorResponse('This chat is currently unavailable', 403) } + const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId) + if (embedBlock) return embedBlock + const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { const response = createErrorResponse( @@ -353,6 +356,9 @@ export const GET = withRouteHandler( return createErrorResponse('This chat is currently unavailable', 403) } + const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId) + if (embedBlock) return embedBlock + const cookieName = `chat_auth_${deployment.id}` const authCookie = request.cookies.get(cookieName) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 337310d6303..bb27d8ecedf 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -20,6 +20,8 @@ const { mockIsEmailAllowed, mockGetSession, mockCheckRateLimitDirect, + mockIsWorkspaceApiExecutionEntitled, + flagState, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), @@ -28,6 +30,12 @@ const { mockIsEmailAllowed: vi.fn(), mockGetSession: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), + mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), + flagState: { isHosted: false }, +})) + +vi.mock('@/lib/billing/core/api-access', () => ({ + isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) vi.mock('@/lib/core/rate-limiter', () => ({ @@ -68,14 +76,23 @@ vi.mock('@/lib/core/security/deployment', () => ({ vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, - isHosted: false, isProd: false, + get isHosted() { + return flagState.isHosted + }, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +import { NextRequest } from 'next/server' import { decryptSecret } from '@/lib/core/security/encryption' -import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' +import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' + +function chatRequest(origin?: string): NextRequest { + return new NextRequest('https://www.sim.ai/api/chat/abc', { + headers: origin ? { origin } : undefined, + }) +} describe('Chat API Utils', () => { beforeEach(() => { @@ -453,3 +470,53 @@ describe('Chat API Utils', () => { }) }) }) + +describe('assertChatEmbedAllowed', () => { + beforeEach(() => { + vi.clearAllMocks() + flagState.isHosted = true + mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) + }) + + it('returns 403 for a cross-site origin when the owner is on the free plan', async () => { + mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false) + const res = await assertChatEmbedAllowed( + chatRequest('https://evil.example.com'), + 'wf-1', + 'req-1' + ) + expect(res?.status).toBe(403) + }) + + it('allows a cross-site origin when the owner is on a paid plan', async () => { + const res = await assertChatEmbedAllowed( + chatRequest('https://evil.example.com'), + 'wf-1', + 'req-1' + ) + expect(res).toBeNull() + }) + + it('allows a first-party *.sim.ai origin without gating', async () => { + const res = await assertChatEmbedAllowed(chatRequest('https://chat.sim.ai'), 'wf-1', 'req-1') + expect(res).toBeNull() + expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() + }) + + it('allows requests with no Origin header', async () => { + const res = await assertChatEmbedAllowed(chatRequest(), 'wf-1', 'req-1') + expect(res).toBeNull() + expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() + }) + + it('is a no-op on self-hosted', async () => { + flagState.isHosted = false + const res = await assertChatEmbedAllowed( + chatRequest('https://evil.example.com'), + 'wf-1', + 'req-1' + ) + expect(res).toBeNull() + expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 70e4a657ac1..e2ebdd48051 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -5,6 +5,9 @@ import { safeCompare } from '@sim/security/compare' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' +import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' +import { getEnv } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/feature-flags' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { @@ -14,6 +17,7 @@ import { } from '@/lib/core/security/deployment' import { decryptSecret } from '@/lib/core/security/encryption' import { getClientIp } from '@/lib/core/utils/request' +import { createErrorResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatAuthUtils') @@ -38,6 +42,53 @@ export function setChatAuthCookie( setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword) } +/** + * A first-party origin is the app itself or any `*.sim.ai` host (chat subdomains + * + apex). Anything else is a third-party embed. Malformed origins are treated + * as third-party. + */ +function isFirstPartyOrigin(origin: string): boolean { + try { + const host = new URL(origin).hostname.toLowerCase() + if (host === 'sim.ai' || host.endsWith('.sim.ai')) return true + const appUrl = getEnv('NEXT_PUBLIC_APP_URL') + if (appUrl && host === new URL(appUrl).hostname.toLowerCase()) return true + return false + } catch { + return false + } +} + +/** + * Gates cross-origin (embedded) chat requests behind a paid plan on hosted. + * Same-origin / SSR / first-party requests — including the chat page rendered in + * a third-party iframe, which calls the API from a `*.sim.ai` origin — are never + * gated. Returns a 403 response to short-circuit the route, or `null` to allow. + */ +export async function assertChatEmbedAllowed( + request: NextRequest, + workflowId: string, + requestId: string +): Promise { + if (!isHosted) return null + + const origin = request.headers.get('origin') + if (!origin || isFirstPartyOrigin(origin)) return null + + const [wf] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) + .limit(1) + + if (!(await isWorkspaceApiExecutionEntitled(wf?.workspaceId ?? undefined))) { + logger.warn(`[${requestId}] Chat embed blocked: workspace on free plan, origin=${origin}`) + return createErrorResponse('Embedding this chat on external sites requires a paid plan', 403) + } + + return null +} + /** * Check if user has permission to create a chat for a specific workflow */ diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 31d4defca0a..36ecea6032c 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -14,9 +14,17 @@ import { import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGenerateInternalToken, fetchMock } = vi.hoisted(() => ({ - mockGenerateInternalToken: vi.fn(), - fetchMock: vi.fn(), +const { mockGenerateInternalToken, fetchMock, mockIsWorkspaceApiExecutionEntitled } = vi.hoisted( + () => ({ + mockGenerateInternalToken: vi.fn(), + fetchMock: vi.fn(), + mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), + }) +) + +vi.mock('@/lib/billing/core/api-access', () => ({ + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', + isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -85,6 +93,26 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) + it('returns 402 when the workspace billed account is on the free plan', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Private Server', + workspaceId: 'ws-1', + isPublic: false, + createdBy: 'owner-1', + }, + ]) + mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), + }) + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + expect(response.status).toBe(402) + }) + it('returns 401 on GET for private server when auth fails', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 8f910764b3d..c113c69821d 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -30,6 +30,10 @@ import { } from '@/lib/api/contracts/mcp' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' +import { + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, + isWorkspaceApiExecutionEntitled, +} from '@/lib/billing/core/api-access' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { assertContentLengthWithinLimit, @@ -312,6 +316,15 @@ async function authorizeMcpServeRequest( server: WorkflowMcpServeServer, options: { requireAuthForPublic?: boolean } = {} ): Promise<{ response?: NextResponse; executeAuthContext?: ExecuteAuthContext }> { + if (!(await isWorkspaceApiExecutionEntitled(server.workspaceId))) { + return { + response: NextResponse.json( + { error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, + { status: 402 } + ), + } + } + if (server.isPublic && !options.requireAuthForPublic) return {} const auth = await checkHybridAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 166fddffcf8..e0b87fcc79c 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -2,6 +2,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { webhookTriggerGetContract, webhookTriggerPostContract } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' +import { + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, + isWorkspaceApiExecutionEntitled, +} from '@/lib/billing/core/api-access' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -124,6 +128,18 @@ async function handleWebhookPost( const responses: NextResponse[] = [] for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { + // Generic ("custom") webhooks are an unauthenticated programmatic execution + // surface, so they fall under the same paid-plan gate as the API. Provider + // webhooks (slack, github, ...) are unaffected. + if ( + foundWebhook.provider === 'generic' && + !(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId)) + ) { + logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`) + if (webhooksForPath.length > 1) continue + return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) + } + const authError = await verifyProviderAuth( foundWebhook, foundWorkflow, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index c8e89c259b0..d7cfb9a7a27 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -9,6 +9,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows' import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import { + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, + isApiExecutionEntitled, +} from '@/lib/billing/core/api-access' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { @@ -434,6 +438,13 @@ async function handleExecutePost( userId = auth.userId } + if ( + (auth.authType === AuthType.API_KEY || isPublicApiAccess) && + !(await isApiExecutionEntitled(userId)) + ) { + return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) + } + let body: any = {} try { body = await readExecuteRequestBody(req) diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts index 953e1deadc6..4ba8bf86c46 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts @@ -92,7 +92,7 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [ }, { label: 'API endpoint', - values: ['30', '100', '200', 'Custom'], + values: ['0', '100', '200', 'Custom'], }, ], }, diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts index 67ef62c17cb..4a5a154b9a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts @@ -26,7 +26,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = { export const PRO_PLAN_FEATURES: readonly string[] = [ 'Invite teammates', - 'Higher rate limits', + 'Deploy workflows as APIs', 'Extended run timeouts', 'More storage & tables', ] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx new file mode 100644 index 00000000000..afd91dfa6d9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { ArrowRight } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { ChipLink } from '@/components/emcn' +import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription' +import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace' + +interface DeployUpgradeGateProps { + feature: 'API' | 'MCP' | 'A2A' +} + +export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) { + const router = useRouter() + const queryClient = useQueryClient() + const { workspaceId } = useParams<{ workspaceId: string }>() + const upgradeHref = `/workspace/${workspaceId}/upgrade` + + // Warm the upgrade route + the queries it gates on so the click lands on + // cached data. ChipLink isn't memoized, so no useCallback is needed. + const prefetchUpgrade = () => { + router.prefetch(upgradeHref) + prefetchUpgradeBillingData(queryClient) + prefetchWorkspaceSettings(queryClient, workspaceId) + } + + return ( +
+
+

+ {feature} deployment requires a paid plan +

+

+ {feature} deployment lets external apps run this workflow programmatically. Upgrade to Pro + or higher to enable it. +

+
+ + Explore plans + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts new file mode 100644 index 00000000000..a124dede56d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts @@ -0,0 +1 @@ +export { DeployUpgradeGate } from './deploy-upgrade-gate' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts index 6e393a4e9e4..22bb581cb27 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts @@ -1,5 +1,6 @@ export { A2aDeploy } from './a2a' export { ApiDeploy } from './api' export { ChatDeploy, type ExistingChat } from './chat' +export { DeployUpgradeGate } from './deploy-upgrade-gate' export { GeneralDeploy } from './general' export { McpDeploy } from './mcp' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index baa2dbff787..3c43672fca7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' @@ -21,6 +21,8 @@ import { ModalTabsList, ModalTabsTrigger, } from '@/components/emcn' +import { isFree } from '@/lib/billing/plan-helpers' +import { isHosted } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -44,6 +46,7 @@ import { useDeployWorkflow, useUndeployWorkflow, } from '@/hooks/queries/deployments' +import { useSubscriptionData } from '@/hooks/queries/subscription' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' import { useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkspaceSettings } from '@/hooks/queries/workspace' @@ -57,6 +60,7 @@ import { A2aDeploy, ApiDeploy, ChatDeploy, + DeployUpgradeGate, type ExistingChat, GeneralDeploy, McpDeploy, @@ -65,6 +69,19 @@ import { ApiInfoModal } from './components/general/components/api-info-modal' const logger = createLogger('DeployModal') +/** Renders the upgrade prompt in place of a programmatic-deploy tab when gated. */ +function GatedTabContent({ + gated, + feature, + children, +}: { + gated: boolean + feature: 'API' | 'MCP' | 'A2A' + children: ReactNode +}) { + return gated ? : <>{children} +} + interface DeployModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -141,6 +158,8 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() + const { data: subscriptionData } = useSubscriptionData() + const gateProgrammaticDeploy = isHosted && isFree(subscriptionData?.data?.plan) const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || '' @@ -605,16 +624,18 @@ export function DeployModal({ /> - - + + + + @@ -634,32 +655,36 @@ export function DeployModal({ - {workflowId && ( - - )} + + {workflowId && ( + + )} + - {workflowId && ( - - )} + + {workflowId && ( + + )} + @@ -679,7 +704,7 @@ export function DeployModal({ }} /> )} - {activeTab === 'api' && ( + {activeTab === 'api' && !gateProgrammaticDeploy && (
@@ -731,7 +756,7 @@ export function DeployModal({
)} - {activeTab === 'mcp' && isDeployed && hasMcpServers && ( + {activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && (
@@ -753,7 +778,7 @@ export function DeployModal({
)} - {activeTab === 'a2a' && ( + {activeTab === 'a2a' && !gateProgrammaticDeploy && ( {hasA2aAgent ? ( isA2aPublished ? ( diff --git a/apps/sim/lib/billing/core/api-access.test.ts b/apps/sim/lib/billing/core/api-access.test.ts new file mode 100644 index 00000000000..2b7b937b726 --- /dev/null +++ b/apps/sim/lib/billing/core/api-access.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, hostedState } = + vi.hoisted(() => ({ + mockGetHighestPrioritySubscription: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), + hostedState: { isHosted: true }, + })) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isHosted() { + return hostedState.isHosted + }, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +import { + isApiExecutionEntitled, + isWorkspaceApiExecutionEntitled, +} from '@/lib/billing/core/api-access' + +describe('isApiExecutionEntitled', () => { + beforeEach(() => { + vi.clearAllMocks() + hostedState.isHosted = true + }) + + it('is false for a free plan', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'free' }) + expect(await isApiExecutionEntitled('user-1')).toBe(false) + }) + + it('is false when there is no subscription', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue(null) + expect(await isApiExecutionEntitled('user-1')).toBe(false) + }) + + it.each(['pro', 'pro_6000', 'team', 'team_25000', 'enterprise'])( + 'is true for paid plan %s', + async (plan) => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan }) + expect(await isApiExecutionEntitled('user-1')).toBe(true) + } + ) + + it('is true on self-hosted regardless of plan, without a subscription lookup', async () => { + hostedState.isHosted = false + expect(await isApiExecutionEntitled('user-1')).toBe(true) + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + }) + + it('is true when userId is missing', async () => { + expect(await isApiExecutionEntitled(undefined)).toBe(true) + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + }) +}) + +describe('isWorkspaceApiExecutionEntitled', () => { + beforeEach(() => { + vi.clearAllMocks() + hostedState.isHosted = true + }) + + it('is false when the workspace billed account is free', async () => { + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1') + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'free' }) + expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false) + }) + + it('is true when the workspace billed account is paid', async () => { + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1') + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'team_6000' }) + expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) + }) + + it('skips the billed-account lookup on self-hosted', async () => { + hostedState.isHosted = false + expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) + expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/api-access.ts b/apps/sim/lib/billing/core/api-access.ts new file mode 100644 index 00000000000..2830ec67ad1 --- /dev/null +++ b/apps/sim/lib/billing/core/api-access.ts @@ -0,0 +1,40 @@ +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isPaid } from '@/lib/billing/plan-helpers' +import { isHosted } from '@/lib/core/config/feature-flags' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' + +/** + * Message for the 402 returned when a free-plan account attempts programmatic + * workflow execution (API key, public API, MCP server, or A2A agent server). + */ +export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE = + 'Programmatic workflow execution requires a paid plan. Upgrade to Pro or higher to use the API.' + +/** + * Whether `userId` may run workflows programmatically. Always allowed on + * self-hosted (no billing) and when no user is resolved; on hosted, requires a + * paid plan. + * + * `getHighestPrioritySubscription` rolls up organization memberships, so a free + * individual belonging to a paid org/workspace is entitled. + */ +export async function isApiExecutionEntitled(userId: string | undefined): Promise { + if (!isHosted || !userId) return true + + const subscription = await getHighestPrioritySubscription(userId) + return isPaid(subscription?.plan) +} + +/** + * Workspace-scoped variant of {@link isApiExecutionEntitled} that gates on the + * workspace's billed account. Short-circuits on self-hosted before any DB + * lookup, so the billed-account query only runs on hosted. + */ +export async function isWorkspaceApiExecutionEntitled( + workspaceId: string | undefined +): Promise { + if (!isHosted || !workspaceId) return true + + const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId) + return isApiExecutionEntitled(billedUserId ?? undefined) +} diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 0df31e11d88..83a938f633c 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -4,6 +4,7 @@ */ export * from '@/lib/billing/calculations/usage-monitor' +export * from '@/lib/billing/core/api-access' export * from '@/lib/billing/core/billing' export * from '@/lib/billing/core/organization' export * from '@/lib/billing/core/subscription' From 6eaf76d8e89ad32dc24871590932d6b427e98888 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 13 Jun 2026 17:15:35 -0700 Subject: [PATCH 2/5] test(billing): mock api-execution gate in webhook + execute route tests These pre-existing tests exercise gated routes with API-key/generic-webhook paths; on hosted (CI) the gate now returns 402. Mock the gate as entitled by default and add a generic-webhook 402 coverage case. --- .../api/webhooks/trigger/[path]/route.test.ts | 33 +++++++++++++++++++ .../[id]/execute/response-block.test.ts | 7 ++++ .../[id]/execute/route.async.test.ts | 23 +++++++++---- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 29850b5fe27..962869480e9 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -106,6 +106,7 @@ const { executeMock, getWorkspaceBilledAccountUserIdMock, queueWebhookExecutionMock, + isWorkspaceApiExecutionEntitledMock, } = vi.hoisted(() => ({ generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'), validateSlackSignatureMock: vi.fn().mockResolvedValue(true), @@ -133,6 +134,12 @@ const { const { NextResponse } = await import('next/server') return NextResponse.json({ message: 'Webhook processed' }) }), + isWorkspaceApiExecutionEntitledMock: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/billing/core/api-access', () => ({ + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', + isWorkspaceApiExecutionEntitled: isWorkspaceApiExecutionEntitledMock, })) vi.mock('@trigger.dev/sdk', () => ({ @@ -600,6 +607,32 @@ describe('Webhook Trigger API Route', () => { expect(data.message).toBe('Webhook processed') }) + it('blocks a generic webhook when the workspace is on the free plan', async () => { + testData.webhooks.push({ + id: 'generic-webhook-id', + provider: 'generic', + path: 'test-path', + isActive: true, + providerConfig: { requireAuth: false }, + workflowId: 'test-workflow-id', + rateLimitCount: 100, + rateLimitPeriod: 60, + }) + testData.workflows.push({ + id: 'test-workflow-id', + userId: 'test-user-id', + workspaceId: 'test-workspace-id', + }) + isWorkspaceApiExecutionEntitledMock.mockResolvedValueOnce(false) + + const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) + const params = Promise.resolve({ path: 'test-path' }) + + const response = await POST(req as any, { params }) + + expect(response.status).toBe(402) + }) + it('should authenticate with Bearer token when no custom header is configured', async () => { testData.webhooks.push({ id: 'generic-webhook-id', diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts index c23bba7d666..0be2db32436 100644 --- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts @@ -21,12 +21,19 @@ const { mockRegisterLargeValueOwner, mockUploadFile, uploadedFiles, + mockIsApiExecutionEntitled, } = vi.hoisted(() => ({ mockAddLargeValueReference: vi.fn(), mockDownloadFile: vi.fn(), mockRegisterLargeValueOwner: vi.fn(), mockUploadFile: vi.fn(), uploadedFiles: new Map(), + mockIsApiExecutionEntitled: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/billing/core/api-access', () => ({ + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', + isApiExecutionEntitled: mockIsApiExecutionEntitled, })) const MATERIALIZATION_CONTEXT = { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 2c7b9aa7064..9f5ff0af590 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -18,13 +18,22 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnqueue, mockExecuteWorkflowCore, mockHandlePostExecutionPauseState } = vi.hoisted( - () => ({ - mockEnqueue: vi.fn().mockResolvedValue('job-123'), - mockExecuteWorkflowCore: vi.fn(), - mockHandlePostExecutionPauseState: vi.fn(), - }) -) +const { + mockEnqueue, + mockExecuteWorkflowCore, + mockHandlePostExecutionPauseState, + mockIsApiExecutionEntitled, +} = vi.hoisted(() => ({ + mockEnqueue: vi.fn().mockResolvedValue('job-123'), + mockExecuteWorkflowCore: vi.fn(), + mockHandlePostExecutionPauseState: vi.fn(), + mockIsApiExecutionEntitled: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/billing/core/api-access', () => ({ + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', + isApiExecutionEntitled: mockIsApiExecutionEntitled, +})) const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution From 57a0f4711ed021c914138289e7f2be7672ba0162 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 13 Jun 2026 17:26:30 -0700 Subject: [PATCH 3/5] 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 --- .../api/webhooks/trigger/[path]/route.test.ts | 39 +++++++++++++++++++ .../app/api/webhooks/trigger/[path]/route.ts | 5 +++ .../[id]/execute/response-block.test.ts | 6 +-- .../[id]/execute/route.async.test.ts | 6 +-- .../app/api/workflows/[id]/execute/route.ts | 27 ++++++++++--- .../components/deploy-modal/deploy-modal.tsx | 7 +++- 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 962869480e9..df0e11e35dd 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -399,6 +399,7 @@ describe('Webhook Trigger API Route', () => { isFromNormalizedTables: true, }) workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true) + isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true) mockExecutionDependencies() mockTriggerDevSdk() @@ -633,6 +634,44 @@ describe('Webhook Trigger API Route', () => { expect(response.status).toBe(402) }) + it('returns 402 (not 500) when every webhook in a shared path is generic and free', async () => { + testData.webhooks.push( + { + id: 'generic-webhook-a', + provider: 'generic', + path: 'test-path', + isActive: true, + providerConfig: { requireAuth: false }, + workflowId: 'test-workflow-id', + rateLimitCount: 100, + rateLimitPeriod: 60, + }, + { + id: 'generic-webhook-b', + provider: 'generic', + path: 'test-path', + isActive: true, + providerConfig: { requireAuth: false }, + workflowId: 'test-workflow-id', + rateLimitCount: 100, + rateLimitPeriod: 60, + } + ) + testData.workflows.push({ + id: 'test-workflow-id', + userId: 'test-user-id', + workspaceId: 'test-workspace-id', + }) + isWorkspaceApiExecutionEntitledMock.mockResolvedValue(false) + + const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) + const params = Promise.resolve({ path: 'test-path' }) + + const response = await POST(req as any, { params }) + + expect(response.status).toBe(402) + }) + it('should authenticate with Bearer token when no custom header is configured', async () => { testData.webhooks.push({ id: 'generic-webhook-id', diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index e0b87fcc79c..f6d83a41ea4 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -126,6 +126,7 @@ async function handleWebhookPost( // Process each webhook // For credential sets with shared paths, each webhook represents a different credential const responses: NextResponse[] = [] + let billingBlocked = false for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { // Generic ("custom") webhooks are an unauthenticated programmatic execution @@ -136,6 +137,7 @@ async function handleWebhookPost( !(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId)) ) { logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`) + billingBlocked = true if (webhooksForPath.length > 1) continue return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) } @@ -203,6 +205,9 @@ async function handleWebhookPost( } if (responses.length === 0) { + if (billingBlocked) { + return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) + } return new NextResponse('No webhooks processed successfully', { status: 500 }) } diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts index 0be2db32436..04aaa4e5e9e 100644 --- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts @@ -21,19 +21,19 @@ const { mockRegisterLargeValueOwner, mockUploadFile, uploadedFiles, - mockIsApiExecutionEntitled, + mockIsWorkspaceApiExecutionEntitled, } = vi.hoisted(() => ({ mockAddLargeValueReference: vi.fn(), mockDownloadFile: vi.fn(), mockRegisterLargeValueOwner: vi.fn(), mockUploadFile: vi.fn(), uploadedFiles: new Map(), - mockIsApiExecutionEntitled: vi.fn().mockResolvedValue(true), + mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), })) vi.mock('@/lib/billing/core/api-access', () => ({ API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', - isApiExecutionEntitled: mockIsApiExecutionEntitled, + isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) const MATERIALIZATION_CONTEXT = { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 9f5ff0af590..ebce3426622 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -22,17 +22,17 @@ const { mockEnqueue, mockExecuteWorkflowCore, mockHandlePostExecutionPauseState, - mockIsApiExecutionEntitled, + mockIsWorkspaceApiExecutionEntitled, } = vi.hoisted(() => ({ mockEnqueue: vi.fn().mockResolvedValue('job-123'), mockExecuteWorkflowCore: vi.fn(), mockHandlePostExecutionPauseState: vi.fn(), - mockIsApiExecutionEntitled: vi.fn().mockResolvedValue(true), + mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), })) vi.mock('@/lib/billing/core/api-access', () => ({ API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', - isApiExecutionEntitled: mockIsApiExecutionEntitled, + isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index d7cfb9a7a27..34868195dc1 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -11,7 +11,7 @@ import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, - isApiExecutionEntitled, + isWorkspaceApiExecutionEntitled, } from '@/lib/billing/core/api-access' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' @@ -400,6 +400,7 @@ async function handleExecutePost( let userId: string let isPublicApiAccess = false + let gateWorkspaceId: string | undefined if (!auth.success || !auth.userId) { const hasExplicitCredentials = @@ -434,15 +435,29 @@ async function handleExecutePost( userId = wf.userId isPublicApiAccess = true + gateWorkspaceId = wf.workspaceId } else { userId = auth.userId } - if ( - (auth.authType === AuthType.API_KEY || isPublicApiAccess) && - !(await isApiExecutionEntitled(userId)) - ) { - return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) + // Programmatic execution (API key or public API) is gated on the workflow's + // workspace billed account — the same entity MCP/A2A/webhooks/chat gate on — + // so a paid workspace is never blocked because an individual is on free. + if (auth.authType === AuthType.API_KEY || isPublicApiAccess) { + if (!gateWorkspaceId) { + const [wfRow] = await db + .select({ workspaceId: workflowTable.workspaceId }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + gateWorkspaceId = wfRow?.workspaceId ?? undefined + } + if (!(await isWorkspaceApiExecutionEntitled(gateWorkspaceId))) { + return NextResponse.json( + { error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, + { status: 402 } + ) + } } let body: any = {} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 3c43672fca7..64d23e6d4f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -158,8 +158,11 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() - const { data: subscriptionData } = useSubscriptionData() - const gateProgrammaticDeploy = isHosted && isFree(subscriptionData?.data?.plan) + const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscriptionData() + // Hold the gate closed until the plan is known — isFree(undefined) is true, so + // gating during load would flash the upgrade wall at paid users. + const gateProgrammaticDeploy = + isHosted && !isLoadingSubscription && isFree(subscriptionData?.data?.plan) const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || '' From 132447a0fe5c30baaedaff585a14897cade3553d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 13 Jun 2026 17:38:47 -0700 Subject: [PATCH 4/5] fix(billing): gate on isBillingEnabled, not isHosted The paywall should follow billing enforcement, not the hostname. Keying off isHosted would still 402 free users on a hosted deployment with BILLING_ENABLED unset. Switch the server gate (api-access, chat embed) to isBillingEnabled and the deploy-modal UI to the client NEXT_PUBLIC_BILLING_ENABLED flag (matching the Inbox paywall), so a billing-disabled deployment skips the gate entirely. --- apps/sim/app/api/chat/utils.test.ts | 12 ++++++------ apps/sim/app/api/chat/utils.ts | 4 ++-- .../components/deploy-modal/deploy-modal.tsx | 4 ++-- apps/sim/lib/billing/core/api-access.test.ts | 16 ++++++++-------- apps/sim/lib/billing/core/api-access.ts | 16 ++++++++-------- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index bb27d8ecedf..5e60b57f4d4 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -31,7 +31,7 @@ const { mockGetSession: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), - flagState: { isHosted: false }, + flagState: { isBillingEnabled: false }, })) vi.mock('@/lib/billing/core/api-access', () => ({ @@ -77,8 +77,8 @@ vi.mock('@/lib/core/security/deployment', () => ({ vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, isProd: false, - get isHosted() { - return flagState.isHosted + get isBillingEnabled() { + return flagState.isBillingEnabled }, })) @@ -474,7 +474,7 @@ describe('Chat API Utils', () => { describe('assertChatEmbedAllowed', () => { beforeEach(() => { vi.clearAllMocks() - flagState.isHosted = true + flagState.isBillingEnabled = true mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) }) @@ -509,8 +509,8 @@ describe('assertChatEmbedAllowed', () => { expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() }) - it('is a no-op on self-hosted', async () => { - flagState.isHosted = false + it('is a no-op when billing is disabled', async () => { + flagState.isBillingEnabled = false const res = await assertChatEmbedAllowed( chatRequest('https://evil.example.com'), 'wf-1', diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index e2ebdd48051..1bff150f77c 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -7,7 +7,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' import { getEnv } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { @@ -70,7 +70,7 @@ export async function assertChatEmbedAllowed( workflowId: string, requestId: string ): Promise { - if (!isHosted) return null + if (!isBillingEnabled) return null const origin = request.headers.get('origin') if (!origin || isFirstPartyOrigin(origin)) return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 64d23e6d4f3..4450e0db3b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -22,11 +22,11 @@ import { ModalTabsTrigger, } from '@/components/emcn' import { isFree } from '@/lib/billing/plan-helpers' -import { isHosted } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' +import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { releaseDeployAction, tryAcquireDeployAction, @@ -162,7 +162,7 @@ export function DeployModal({ // Hold the gate closed until the plan is known — isFree(undefined) is true, so // gating during load would flash the upgrade wall at paid users. const gateProgrammaticDeploy = - isHosted && !isLoadingSubscription && isFree(subscriptionData?.data?.plan) + isBillingEnabled && !isLoadingSubscription && isFree(subscriptionData?.data?.plan) const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || '' diff --git a/apps/sim/lib/billing/core/api-access.test.ts b/apps/sim/lib/billing/core/api-access.test.ts index 2b7b937b726..8a5623b59c3 100644 --- a/apps/sim/lib/billing/core/api-access.test.ts +++ b/apps/sim/lib/billing/core/api-access.test.ts @@ -3,16 +3,16 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, hostedState } = +const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, billingState } = vi.hoisted(() => ({ mockGetHighestPrioritySubscription: vi.fn(), mockGetWorkspaceBilledAccountUserId: vi.fn(), - hostedState: { isHosted: true }, + billingState: { isBillingEnabled: true }, })) vi.mock('@/lib/core/config/feature-flags', () => ({ - get isHosted() { - return hostedState.isHosted + get isBillingEnabled() { + return billingState.isBillingEnabled }, })) @@ -32,7 +32,7 @@ import { describe('isApiExecutionEntitled', () => { beforeEach(() => { vi.clearAllMocks() - hostedState.isHosted = true + billingState.isBillingEnabled = true }) it('is false for a free plan', async () => { @@ -54,7 +54,7 @@ describe('isApiExecutionEntitled', () => { ) it('is true on self-hosted regardless of plan, without a subscription lookup', async () => { - hostedState.isHosted = false + billingState.isBillingEnabled = false expect(await isApiExecutionEntitled('user-1')).toBe(true) expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() }) @@ -68,7 +68,7 @@ describe('isApiExecutionEntitled', () => { describe('isWorkspaceApiExecutionEntitled', () => { beforeEach(() => { vi.clearAllMocks() - hostedState.isHosted = true + billingState.isBillingEnabled = true }) it('is false when the workspace billed account is free', async () => { @@ -84,7 +84,7 @@ describe('isWorkspaceApiExecutionEntitled', () => { }) it('skips the billed-account lookup on self-hosted', async () => { - hostedState.isHosted = false + billingState.isBillingEnabled = false expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/billing/core/api-access.ts b/apps/sim/lib/billing/core/api-access.ts index 2830ec67ad1..4aa4558a749 100644 --- a/apps/sim/lib/billing/core/api-access.ts +++ b/apps/sim/lib/billing/core/api-access.ts @@ -1,6 +1,6 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' /** @@ -11,15 +11,15 @@ export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE = 'Programmatic workflow execution requires a paid plan. Upgrade to Pro or higher to use the API.' /** - * Whether `userId` may run workflows programmatically. Always allowed on - * self-hosted (no billing) and when no user is resolved; on hosted, requires a - * paid plan. + * Whether `userId` may run workflows programmatically. Always allowed when + * billing enforcement is off (self-hosted / `BILLING_ENABLED` unset) and when no + * user is resolved; otherwise requires a paid plan. * * `getHighestPrioritySubscription` rolls up organization memberships, so a free * individual belonging to a paid org/workspace is entitled. */ export async function isApiExecutionEntitled(userId: string | undefined): Promise { - if (!isHosted || !userId) return true + if (!isBillingEnabled || !userId) return true const subscription = await getHighestPrioritySubscription(userId) return isPaid(subscription?.plan) @@ -27,13 +27,13 @@ export async function isApiExecutionEntitled(userId: string | undefined): Promis /** * Workspace-scoped variant of {@link isApiExecutionEntitled} that gates on the - * workspace's billed account. Short-circuits on self-hosted before any DB - * lookup, so the billed-account query only runs on hosted. + * workspace's billed account. Short-circuits when billing is off before any DB + * lookup, so the billed-account query only runs when billing is enforced. */ export async function isWorkspaceApiExecutionEntitled( workspaceId: string | undefined ): Promise { - if (!isHosted || !workspaceId) return true + if (!isBillingEnabled || !workspaceId) return true const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId) return isApiExecutionEntitled(billedUserId ?? undefined) From 9687476e82f3ae0e49f5d5a9a3bf3dd2bd1b0b1c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 13 Jun 2026 17:51:11 -0700 Subject: [PATCH 5/5] feat(billing): add FREE_API_DEPLOYMENT_GATE_ENABLED kill-switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the programmatic-execution paywall behind a dedicated backend env flag (combined with BILLING_ENABLED), off by default, so it can ship dark and be enabled per-deployment after a backend sanity check. Backend only — the deploy modal UI is unchanged. --- apps/sim/app/api/chat/utils.test.ts | 17 ++++++++++++++++- apps/sim/app/api/chat/utils.ts | 4 ++-- apps/sim/lib/billing/core/api-access.test.ts | 19 ++++++++++++++++++- apps/sim/lib/billing/core/api-access.ts | 11 ++++++++--- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 8 ++++++++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 5e60b57f4d4..d592060eac2 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -31,7 +31,7 @@ const { mockGetSession: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), - flagState: { isBillingEnabled: false }, + flagState: { isBillingEnabled: false, isFreeApiDeploymentGateEnabled: true }, })) vi.mock('@/lib/billing/core/api-access', () => ({ @@ -80,6 +80,9 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ get isBillingEnabled() { return flagState.isBillingEnabled }, + get isFreeApiDeploymentGateEnabled() { + return flagState.isFreeApiDeploymentGateEnabled + }, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -475,6 +478,7 @@ describe('assertChatEmbedAllowed', () => { beforeEach(() => { vi.clearAllMocks() flagState.isBillingEnabled = true + flagState.isFreeApiDeploymentGateEnabled = true mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) }) @@ -519,4 +523,15 @@ describe('assertChatEmbedAllowed', () => { expect(res).toBeNull() expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() }) + + it('is a no-op when the gate feature flag is disabled', async () => { + flagState.isFreeApiDeploymentGateEnabled = false + const res = await assertChatEmbedAllowed( + chatRequest('https://evil.example.com'), + 'wf-1', + 'req-1' + ) + expect(res).toBeNull() + expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 1bff150f77c..a72b5c8f5dd 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -7,7 +7,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' import { getEnv } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/feature-flags' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { @@ -70,7 +70,7 @@ export async function assertChatEmbedAllowed( workflowId: string, requestId: string ): Promise { - if (!isBillingEnabled) return null + if (!isBillingEnabled || !isFreeApiDeploymentGateEnabled) return null const origin = request.headers.get('origin') if (!origin || isFirstPartyOrigin(origin)) return null diff --git a/apps/sim/lib/billing/core/api-access.test.ts b/apps/sim/lib/billing/core/api-access.test.ts index 8a5623b59c3..fd5bfe8d92a 100644 --- a/apps/sim/lib/billing/core/api-access.test.ts +++ b/apps/sim/lib/billing/core/api-access.test.ts @@ -7,13 +7,16 @@ const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, vi.hoisted(() => ({ mockGetHighestPrioritySubscription: vi.fn(), mockGetWorkspaceBilledAccountUserId: vi.fn(), - billingState: { isBillingEnabled: true }, + billingState: { isBillingEnabled: true, isFreeApiDeploymentGateEnabled: true }, })) vi.mock('@/lib/core/config/feature-flags', () => ({ get isBillingEnabled() { return billingState.isBillingEnabled }, + get isFreeApiDeploymentGateEnabled() { + return billingState.isFreeApiDeploymentGateEnabled + }, })) vi.mock('@/lib/billing/core/subscription', () => ({ @@ -33,6 +36,7 @@ describe('isApiExecutionEntitled', () => { beforeEach(() => { vi.clearAllMocks() billingState.isBillingEnabled = true + billingState.isFreeApiDeploymentGateEnabled = true }) it('is false for a free plan', async () => { @@ -59,6 +63,12 @@ describe('isApiExecutionEntitled', () => { expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() }) + it('is true (gate off) when the feature flag is disabled, even with billing on', async () => { + billingState.isFreeApiDeploymentGateEnabled = false + expect(await isApiExecutionEntitled('user-1')).toBe(true) + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + }) + it('is true when userId is missing', async () => { expect(await isApiExecutionEntitled(undefined)).toBe(true) expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() @@ -69,6 +79,7 @@ describe('isWorkspaceApiExecutionEntitled', () => { beforeEach(() => { vi.clearAllMocks() billingState.isBillingEnabled = true + billingState.isFreeApiDeploymentGateEnabled = true }) it('is false when the workspace billed account is free', async () => { @@ -88,4 +99,10 @@ describe('isWorkspaceApiExecutionEntitled', () => { expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled() }) + + it('skips the lookup (gate off) when the feature flag is disabled', async () => { + billingState.isFreeApiDeploymentGateEnabled = false + expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) + expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/billing/core/api-access.ts b/apps/sim/lib/billing/core/api-access.ts index 4aa4558a749..be41d29293f 100644 --- a/apps/sim/lib/billing/core/api-access.ts +++ b/apps/sim/lib/billing/core/api-access.ts @@ -1,8 +1,13 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/feature-flags' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' +/** The programmatic-execution paywall is active only when billing is enforced AND the gate flag is on. */ +function isApiExecutionGateActive(): boolean { + return isBillingEnabled && isFreeApiDeploymentGateEnabled +} + /** * Message for the 402 returned when a free-plan account attempts programmatic * workflow execution (API key, public API, MCP server, or A2A agent server). @@ -19,7 +24,7 @@ export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE = * individual belonging to a paid org/workspace is entitled. */ export async function isApiExecutionEntitled(userId: string | undefined): Promise { - if (!isBillingEnabled || !userId) return true + if (!isApiExecutionGateActive() || !userId) return true const subscription = await getHighestPrioritySubscription(userId) return isPaid(subscription?.plan) @@ -33,7 +38,7 @@ export async function isApiExecutionEntitled(userId: string | undefined): Promis export async function isWorkspaceApiExecutionEntitled( workspaceId: string | undefined ): Promise { - if (!isBillingEnabled || !workspaceId) return true + if (!isApiExecutionGateActive() || !workspaceId) return true const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId) return isApiExecutionEntitled(billedUserId ?? undefined) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index c01826cdfee..378ad933d15 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -71,6 +71,7 @@ export const env = createEnv({ ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users ENTERPRISE_STORAGE_LIMIT_GB: z.number().optional().default(500), // Default storage limit in GB for enterprise tier (can be overridden per org) BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking + FREE_API_DEPLOYMENT_GATE_ENABLED: z.boolean().optional(), // Block free-plan accounts from programmatic execution (API/MCP/A2A/generic webhooks/chat embeds). Requires BILLING_ENABLED. Off by default for dark rollout TABLES_FRACTIONAL_ORDERING: z.boolean().optional(), // Order table rows by fractional order_key (O(1) insert/delete) instead of integer position // Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans. diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 45374727689..7c10e6fe927 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -36,6 +36,14 @@ export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.a */ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) +/** + * Block free-plan accounts from programmatic workflow execution (API key, public + * API, MCP server, A2A agent server, generic webhooks, cross-origin chat embeds). + * Gated behind {@link isBillingEnabled}; off by default so the paywall can ship + * dark and be enabled per-deployment once verified. + */ +export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_ENABLED) + /** * Order table rows by fractional `order_key` (O(1) insert/delete) instead of the * legacy integer `position`. When off, behavior is unchanged. Keys are written