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..d592060eac2 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: { isBillingEnabled: false, isFreeApiDeploymentGateEnabled: true }, +})) + +vi.mock('@/lib/billing/core/api-access', () => ({ + isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) vi.mock('@/lib/core/rate-limiter', () => ({ @@ -68,14 +76,26 @@ vi.mock('@/lib/core/security/deployment', () => ({ vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, - isHosted: false, isProd: false, + get isBillingEnabled() { + return flagState.isBillingEnabled + }, + get isFreeApiDeploymentGateEnabled() { + return flagState.isFreeApiDeploymentGateEnabled + }, })) 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 +473,65 @@ describe('Chat API Utils', () => { }) }) }) + +describe('assertChatEmbedAllowed', () => { + beforeEach(() => { + vi.clearAllMocks() + flagState.isBillingEnabled = true + flagState.isFreeApiDeploymentGateEnabled = 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 when billing is disabled', async () => { + flagState.isBillingEnabled = false + const res = await assertChatEmbedAllowed( + chatRequest('https://evil.example.com'), + 'wf-1', + 'req-1' + ) + 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 70e4a657ac1..a72b5c8f5dd 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 { isBillingEnabled, isFreeApiDeploymentGateEnabled } 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 (!isBillingEnabled || !isFreeApiDeploymentGateEnabled) 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.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 29850b5fe27..df0e11e35dd 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', () => ({ @@ -392,6 +399,7 @@ describe('Webhook Trigger API Route', () => { isFromNormalizedTables: true, }) workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true) + isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true) mockExecutionDependencies() mockTriggerDevSdk() @@ -600,6 +608,70 @@ 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('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 166fddffcf8..f6d83a41ea4 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' @@ -122,8 +126,22 @@ 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 + // 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`) + billingBlocked = true + if (webhooksForPath.length > 1) continue + return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) + } + const authError = await verifyProviderAuth( foundWebhook, foundWorkflow, @@ -187,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 c23bba7d666..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,12 +21,19 @@ const { mockRegisterLargeValueOwner, mockUploadFile, uploadedFiles, + mockIsWorkspaceApiExecutionEntitled, } = vi.hoisted(() => ({ mockAddLargeValueReference: vi.fn(), mockDownloadFile: vi.fn(), mockRegisterLargeValueOwner: vi.fn(), mockUploadFile: vi.fn(), uploadedFiles: new Map(), + mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/billing/core/api-access', () => ({ + API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', + 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 2c7b9aa7064..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 @@ -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, + mockIsWorkspaceApiExecutionEntitled, +} = vi.hoisted(() => ({ + mockEnqueue: vi.fn().mockResolvedValue('job-123'), + mockExecuteWorkflowCore: vi.fn(), + mockHandlePostExecutionPauseState: 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 mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index c8e89c259b0..34868195dc1 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, + isWorkspaceApiExecutionEntitled, +} from '@/lib/billing/core/api-access' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { @@ -396,6 +400,7 @@ async function handleExecutePost( let userId: string let isPublicApiAccess = false + let gateWorkspaceId: string | undefined if (!auth.success || !auth.userId) { const hasExplicitCredentials = @@ -430,10 +435,31 @@ async function handleExecutePost( userId = wf.userId isPublicApiAccess = true + gateWorkspaceId = wf.workspaceId } else { userId = auth.userId } + // 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 = {} 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..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 @@ -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,10 +21,12 @@ import { ModalTabsList, ModalTabsTrigger, } from '@/components/emcn' +import { isFree } from '@/lib/billing/plan-helpers' 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, @@ -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,11 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() + 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 = + isBillingEnabled && !isLoadingSubscription && isFree(subscriptionData?.data?.plan) const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || '' @@ -605,16 +627,18 @@ export function DeployModal({ /> - - + + + + @@ -634,32 +658,36 @@ export function DeployModal({ - {workflowId && ( - - )} + + {workflowId && ( + + )} + - {workflowId && ( - - )} + + {workflowId && ( + + )} + @@ -679,7 +707,7 @@ export function DeployModal({ }} /> )} - {activeTab === 'api' && ( + {activeTab === 'api' && !gateProgrammaticDeploy && (
@@ -731,7 +759,7 @@ export function DeployModal({
)} - {activeTab === 'mcp' && isDeployed && hasMcpServers && ( + {activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && (
@@ -753,7 +781,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..fd5bfe8d92a --- /dev/null +++ b/apps/sim/lib/billing/core/api-access.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, billingState } = + vi.hoisted(() => ({ + mockGetHighestPrioritySubscription: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), + 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', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +import { + isApiExecutionEntitled, + isWorkspaceApiExecutionEntitled, +} from '@/lib/billing/core/api-access' + +describe('isApiExecutionEntitled', () => { + beforeEach(() => { + vi.clearAllMocks() + billingState.isBillingEnabled = true + billingState.isFreeApiDeploymentGateEnabled = 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 () => { + billingState.isBillingEnabled = false + expect(await isApiExecutionEntitled('user-1')).toBe(true) + 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() + }) +}) + +describe('isWorkspaceApiExecutionEntitled', () => { + beforeEach(() => { + vi.clearAllMocks() + billingState.isBillingEnabled = true + billingState.isFreeApiDeploymentGateEnabled = 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 () => { + billingState.isBillingEnabled = false + 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 new file mode 100644 index 00000000000..be41d29293f --- /dev/null +++ b/apps/sim/lib/billing/core/api-access.ts @@ -0,0 +1,45 @@ +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isPaid } from '@/lib/billing/plan-helpers' +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). + */ +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 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 (!isApiExecutionGateActive() || !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 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 (!isApiExecutionGateActive() || !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' 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