feat(billing): allow programmatic workflow execution on the free plan (#5678)

* feat(billing): allow programmatic workflow execution on the free plan

Remove the free-plan paywall on programmatic execution (API-key/public
execute, MCP serve, generic webhooks, cross-origin chat embeds) added in
#5036. The gate shipped dark behind FREE_API_DEPLOYMENT_GATE_ENABLED to
curb bot abuse; with that abuse handled upstream, free workspaces can use
the API again (still subject to the free-tier rate limits).

- Delete isWorkspaceApiExecutionEntitled / api-access.ts and the
  FREE_API_DEPLOYMENT_GATE_ENABLED env flag
- Ungate the execute, mcp/serve, and webhooks/trigger routes and the chat
  embed check (assertChatEmbedAllowed removed)
- Remove the deploy-modal upgrade wall (DeployUpgradeGate) and the now
  unused useWorkspaceOwnerBilling client hook; the owner-billing endpoint
  stays as reusable billing infrastructure
- Restore pre-gate upgrade-page copy (Pro feature line, free API endpoint
  rate-limit column)

* chore(billing): drop orphaned owner-billing endpoint and dead chat logger

Self-review follow-ups: the /api/workspaces/[id]/owner-billing route +
contract existed solely for the deploy-modal gate's client hook (deleted
here); host-context serves the same ownerBilling data server-side, so the
standalone endpoint is dead HTTP surface. workspaceOwnerBillingSchema and
the WorkspaceOwnerBilling type stay (embedded in workspaceHostContextSchema).
Also removes the now-unused ChatAuthUtils logger.

* style: biome formatting after gate removal
This commit is contained in:
Waleed
2026-07-14 16:33:11 -07:00
committed by GitHub
parent 82bed774e5
commit 515dafa274
26 changed files with 39 additions and 801 deletions
@@ -14,7 +14,6 @@ import {
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
} from '@sim/testing'
import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
@@ -65,19 +64,13 @@ const createMockStream = () => {
})
}
const {
mockValidateChatAuth,
mockSetChatAuthCookie,
mockValidateAuthToken,
mockAssertChatEmbedAllowed,
mockProcessChatFiles,
} = vi.hoisted(() => ({
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
mockSetChatAuthCookie: vi.fn(),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null),
mockProcessChatFiles: vi.fn(),
}))
const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken, mockProcessChatFiles } =
vi.hoisted(() => ({
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
mockSetChatAuthCookie: vi.fn(),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockProcessChatFiles: vi.fn(),
}))
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
@@ -97,7 +90,6 @@ 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)
@@ -245,24 +237,6 @@ 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 {
@@ -335,28 +309,6 @@ 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' })
+1 -7
View File
@@ -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 { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatIdentifierAPI')
@@ -135,9 +135,6 @@ 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(
@@ -361,9 +358,6 @@ 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)
+1 -102
View File
@@ -5,7 +5,6 @@
*/
import {
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
loggingSessionMock,
@@ -22,8 +21,6 @@ const {
mockIsEmailAllowed,
mockGetSession,
mockCheckRateLimitDirect,
mockIsWorkspaceApiExecutionEntitled,
flagState,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
@@ -32,16 +29,10 @@ 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('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/core/api-access', () => ({
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
@@ -79,28 +70,10 @@ vi.mock('@/lib/core/security/deployment', () => ({
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isDev: true,
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 { 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,
})
}
import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
describe('Chat API Utils', () => {
beforeEach(() => {
@@ -479,77 +452,3 @@ describe('Chat API Utils', () => {
})
})
})
describe('assertChatEmbedAllowed', () => {
beforeEach(() => {
vi.clearAllMocks()
flagState.isBillingEnabled = true
flagState.isFreeApiDeploymentGateEnabled = true
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'ws-1' }])
})
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('returns 403 for a cross-site origin when the workflow has no active workspace', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res?.status).toBe(403)
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
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()
})
})
-61
View File
@@ -1,20 +1,13 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
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/env-flags'
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
import {
type DeploymentAuthResult,
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatAuthUtils')
export function setChatAuthCookie(
response: NextResponse,
@@ -25,60 +18,6 @@ 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<NextResponse | null> {
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 (!wf?.workspaceId) {
logger.warn(
`[${requestId}] Chat embed blocked: no active workspace for workflow ${workflowId}, origin=${origin}`
)
return createErrorResponse('This chat is currently unavailable', 403)
}
if (!(await isWorkspaceApiExecutionEntitled(wf.workspaceId))) {
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
*/
@@ -20,19 +20,12 @@ const {
mockResolveBillingAttribution,
mockSerializeBillingAttributionHeader,
fetchMock,
mockIsWorkspaceApiExecutionEntitled,
} = vi.hoisted(() => ({
mockAssertBillingAttributionSnapshot: vi.fn(),
mockGenerateInternalToken: vi.fn(),
mockResolveBillingAttribution: vi.fn(),
mockSerializeBillingAttributionHeader: 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,
}))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
@@ -129,26 +122,6 @@ 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([
{
@@ -30,10 +30,6 @@ 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 {
assertBillingAttributionSnapshot,
BILLING_ATTRIBUTION_HEADER,
@@ -341,15 +337,6 @@ 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 })
@@ -116,7 +116,6 @@ const {
handleWebhookEventFilterMock,
queueWebhookExecutionMock,
dispatchResolvedWebhookTargetMock,
isWorkspaceApiExecutionEntitledMock,
shouldSkipWebhookEventMock,
admissionRejectedResponseMock,
tryAdmitMock,
@@ -176,17 +175,11 @@ const {
return NextResponse.json({ message: 'Webhook processed' })
}),
dispatchResolvedWebhookTargetMock: vi.fn(),
isWorkspaceApiExecutionEntitledMock: vi.fn().mockResolvedValue(true),
shouldSkipWebhookEventMock: vi.fn().mockReturnValue(false),
admissionRejectedResponseMock: vi.fn(),
tryAdmitMock: vi.fn<() => { release: () => void } | null>(() => ({ release: vi.fn() })),
}))
vi.mock('@/lib/billing/core/api-access', () => ({
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
isWorkspaceApiExecutionEntitled: isWorkspaceApiExecutionEntitledMock,
}))
vi.mock('@/lib/core/admission/gate', () => ({
admissionRejectedResponse: admissionRejectedResponseMock,
tryAdmit: tryAdmitMock,
@@ -513,7 +506,6 @@ describe('Webhook Trigger API Route', () => {
isFromNormalizedTables: true,
})
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true)
handleWebhookEventFilterMock.mockResolvedValue(null)
shouldSkipWebhookEventMock.mockReturnValue(false)
@@ -859,70 +851,6 @@ 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, { 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, { 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',
@@ -2,10 +2,6 @@ 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'
@@ -134,7 +130,6 @@ async function handleWebhookPost(
// Process each webhook matched on this path
const responses: NextResponse[] = []
const failures: NextResponse[] = []
let billingBlocked = false
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
const provider = foundWebhook.provider
@@ -152,19 +147,6 @@ async function handleWebhookPost(
return missingProviderResponse
}
// 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 (
provider === 'generic' &&
!(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId ?? undefined))
) {
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,
@@ -218,9 +200,6 @@ async function handleWebhookPost(
}
if (responses.length === 0) {
if (billingBlocked) {
return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
}
if (failures.length > 0) {
return failures[0]
}
@@ -21,19 +21,12 @@ const {
mockRegisterLargeValueOwner,
mockUploadFile,
uploadedFiles,
mockIsWorkspaceApiExecutionEntitled,
} = vi.hoisted(() => ({
mockAddLargeValueReference: vi.fn(),
mockDownloadFile: vi.fn(),
mockRegisterLargeValueOwner: vi.fn(),
mockUploadFile: vi.fn(),
uploadedFiles: new Map<string, Buffer>(),
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 = {
@@ -31,7 +31,6 @@ const {
mockGetWorkspaceBillingSettings,
mockHandlePostExecutionPauseState,
mockHasDurableExecutionOwner,
mockIsWorkspaceApiExecutionEntitled,
mockReleaseExecutionIdClaim,
mockReleaseExecutionSlot,
mockRequireBillingAttributionHeader,
@@ -50,7 +49,6 @@ const {
mockGetWorkspaceBillingSettings: vi.fn(),
mockHandlePostExecutionPauseState: vi.fn(),
mockHasDurableExecutionOwner: vi.fn(),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
mockReleaseExecutionIdClaim: vi.fn(),
mockReleaseExecutionSlot: vi.fn(),
mockRequireBillingAttributionHeader: vi.fn(),
@@ -59,11 +57,6 @@ const {
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/core/api-access', () => ({
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
@@ -14,10 +14,6 @@ import {
} 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 {
assertBillingAttributionSnapshot,
type BillingAttributionSnapshot,
@@ -563,7 +559,6 @@ async function handleExecutePost(
let userId: string
let isPublicApiAccess = false
let gateWorkspaceId: string | undefined
if (!auth.success || !auth.userId) {
const hasExplicitCredentials =
@@ -598,31 +593,10 @@ 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/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)
@@ -1,80 +0,0 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } =
vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
vi.mock('@/lib/billing/core/workspace-access', () => ({
getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess,
}))
import { GET } from '@/app/api/workspaces/[id]/owner-billing/route'
const WORKSPACE_ID = 'ws-1'
const PAID_ACCESS = {
plan: 'team_25000',
status: 'active',
isPaid: true,
isPro: false,
isTeam: true,
isEnterprise: false,
isOrgScoped: true,
organizationId: 'org-1',
}
function buildParams() {
return { params: Promise.resolve({ id: WORKSPACE_ID }) }
}
async function callGet() {
const request = createMockRequest('GET')
const response = await GET(request, buildParams())
return { status: response.status, body: await response.json() }
}
describe('GET /api/workspaces/[id]/owner-billing', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
mockGetUserEntityPermissions.mockResolvedValue('read')
mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS)
})
it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)
const { status } = await callGet()
expect(status).toBe(401)
expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
})
it('returns 404 when the caller has no workspace access', async () => {
mockGetUserEntityPermissions.mockResolvedValue(null)
const { status } = await callGet()
expect(status).toBe(404)
expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
})
it('returns the workspace owner subscription access for a member', async () => {
const { status, body } = await callGet()
expect(status).toBe(200)
expect(body).toEqual(PAID_ACCESS)
expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID)
})
})
@@ -1,35 +0,0 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
/**
* Subscription access state of the workspace's billed account — the workspace-
* scoped counterpart to the viewer `/api/billing`. Lets the UI gate workspace
* features (e.g. the deploy modal) on the owner's plan rather than the viewer's,
* so a free member of a paid workspace isn't shown an upgrade wall.
*/
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const ownerAccess = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
return NextResponse.json(ownerAccess)
}
)
@@ -144,7 +144,7 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
},
{
label: 'API endpoint',
values: ['0', '100', '200', 'Custom'],
values: ['30', '100', '200', 'Custom'],
},
],
},
@@ -29,7 +29,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
export const PRO_PLAN_FEATURES: readonly string[] = [
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Deploy workflows as APIs',
'Higher rate limits',
'Extended run timeouts',
'More storage & tables',
]
@@ -1,51 +0,0 @@
'use client'
import { ChipLink } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
interface DeployUpgradeGateProps {
feature: 'API' | 'MCP'
}
export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) {
const router = useRouter()
const queryClient = useQueryClient()
const { workspaceId } = useParams<{ workspaceId: string }>()
const upgradeHref = buildUpgradeHref(workspaceId)
// 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 (
<div className='flex h-full flex-col items-center justify-center gap-4 py-20'>
<div className='max-w-[28rem] text-center'>
<h3 className='font-medium text-[16px] text-[var(--text-primary)]'>
{feature} deployment requires a paid plan
</h3>
<p className='mt-1.5 text-[14px] text-[var(--text-muted)]'>
{feature} deployment lets external apps run this workflow programmatically. Upgrade to Pro
or higher to enable it.
</p>
</div>
<ChipLink
href={upgradeHref}
variant='primary'
rightIcon={ArrowRight}
onMouseEnter={prefetchUpgrade}
onFocus={prefetchUpgrade}
>
Explore plans
</ChipLink>
</div>
)
}
@@ -1,5 +1,4 @@
export { ApiDeploy } from './api'
export { ChatDeploy, type ExistingChat } from './chat'
export { DeployUpgradeGate } from './deploy-upgrade-gate'
export { GeneralDeploy } from './general'
export { McpDeploy } from './mcp'
@@ -1,6 +1,6 @@
'use client'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Badge,
Button,
@@ -22,7 +22,6 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { isBillingEnabled } from '@/lib/core/config/env-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'
@@ -47,38 +46,18 @@ import {
} from '@/hooks/queries/deployments'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkspaceOwnerBilling, useWorkspaceSettings } from '@/hooks/queries/workspace'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
ApiDeploy,
ChatDeploy,
DeployUpgradeGate,
type ExistingChat,
GeneralDeploy,
McpDeploy,
} from './components'
import { ApiDeploy, ChatDeploy, type ExistingChat, GeneralDeploy, McpDeploy } from './components'
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'
children: ReactNode
}) {
return gated ? <DeployUpgradeGate feature={feature} /> : <>{children}</>
}
interface DeployModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -153,16 +132,6 @@ export function DeployModal({
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
// Gate on the WORKSPACE owner's plan (billed account, rolled up), not the
// viewer's individual plan, so a free member of a paid workspace isn't shown
// the upgrade wall. Keyed on the URL `workspaceId` (available on mount). Uses
// `isPaid` — the same check the server gate runs (any paid plan in an entitled
// status, incl. `past_due`) — rather than `hasUsablePaidAccess`, which would
// reject `past_due`/billing-blocked owners the API still allows. While loading
// the data is undefined → gate stays closed (no flash); only a resolved,
// non-paid owner gates.
const { data: ownerBilling } = useWorkspaceOwnerBilling(workspaceId ?? undefined)
const gateProgrammaticDeploy = isBillingEnabled && !!ownerBilling && !ownerBilling.isPaid
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
workflowWorkspaceId || ''
@@ -581,17 +550,15 @@ export function DeployModal({
</ModalTabsContent>
<ModalTabsContent value='api' className='h-full'>
<GatedTabContent gated={gateProgrammaticDeploy} feature='API'>
<ApiDeploy
workflowId={workflowId}
deploymentInfo={deploymentInfo}
isLoading={isLoadingDeploymentInfo}
needsRedeployment={needsRedeployment}
getInputFormatExample={getInputFormatExample}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
/>
</GatedTabContent>
<ApiDeploy
workflowId={workflowId}
deploymentInfo={deploymentInfo}
isLoading={isLoadingDeploymentInfo}
needsRedeployment={needsRedeployment}
getInputFormatExample={getInputFormatExample}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
/>
</ModalTabsContent>
<ModalTabsContent value='chat'>
@@ -611,22 +578,20 @@ export function DeployModal({
</ModalTabsContent>
<ModalTabsContent value='mcp' className='h-full'>
<GatedTabContent gated={gateProgrammaticDeploy} feature='MCP'>
{workflowId && (
<McpDeploy
workflowId={workflowId}
workflowName={workflowMetadata?.name || 'Workflow'}
workflowDescription={workflowMetadata?.description}
isDeployed={isDeployed}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
onSubmittingChange={setMcpToolSubmitting}
onCanSaveChange={setMcpToolCanSave}
onSaveDisabledReasonChange={setMcpToolSaveDisabledReason}
onActiveServerChange={setMcpActiveServerId}
/>
)}
</GatedTabContent>
{workflowId && (
<McpDeploy
workflowId={workflowId}
workflowName={workflowMetadata?.name || 'Workflow'}
workflowDescription={workflowMetadata?.description}
isDeployed={isDeployed}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
onSubmittingChange={setMcpToolSubmitting}
onCanSaveChange={setMcpToolCanSave}
onSaveDisabledReasonChange={setMcpToolSaveDisabledReason}
onActiveServerChange={setMcpActiveServerId}
/>
)}
</ModalTabsContent>
</ModalBody>
</ModalTabs>
@@ -646,7 +611,7 @@ export function DeployModal({
}}
/>
)}
{activeTab === 'api' && !gateProgrammaticDeploy && (
{activeTab === 'api' && (
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
@@ -698,7 +663,7 @@ export function DeployModal({
</div>
</ModalFooter>
)}
{activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && (
{activeTab === 'mcp' && isDeployed && hasMcpServers && (
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
-33
View File
@@ -8,14 +8,12 @@ import {
deleteWorkspaceContract,
getWorkspaceContract,
getWorkspaceMembersContract,
getWorkspaceOwnerBillingContract,
getWorkspacePermissionsContract,
listWorkspacesContract,
updateWorkspaceContract,
type Workspace,
type WorkspaceCreationPolicy,
type WorkspaceMember,
type WorkspaceOwnerBilling,
type WorkspacePermissions,
type WorkspaceQueryScope,
type WorkspacesResponse,
@@ -35,7 +33,6 @@ export const workspaceKeys = {
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
ownerBilling: (id: string) => [...workspaceKeys.detail(id), 'ownerBilling'] as const,
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
@@ -117,36 +114,6 @@ export function useWorkspaceCreationPolicy(enabled = true) {
})
}
async function fetchWorkspaceOwnerBilling(
workspaceId: string,
signal?: AbortSignal
): Promise<WorkspaceOwnerBilling> {
return requestJson(getWorkspaceOwnerBillingContract, {
params: { id: workspaceId },
signal,
})
}
/**
* Subscription access state of the workspace's billed account (its owner's
* rolled-up plan) — the workspace-scoped counterpart to `useSubscriptionData`.
* Feed the result to `getSubscriptionAccessState` to gate workspace features on
* the owner's plan rather than the viewer's, so a free member of a paid workspace
* isn't gated.
*
* `staleTime: 0` so consumers (e.g. the deploy modal) refetch on mount: a plan
* change happens outside this query's invalidation graph, and the cached value is
* shown during the background refetch (no flash), so gates self-heal on reopen.
*/
export function useWorkspaceOwnerBilling(workspaceId?: string) {
return useQuery({
queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''),
queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal),
enabled: Boolean(workspaceId),
staleTime: 0,
})
}
type CreateWorkspaceParams = Pick<ContractBodyInput<typeof createWorkspaceContract>, 'name'>
/**
-10
View File
@@ -208,16 +208,6 @@ export const workspaceOwnerBillingSchema = z.object({
export type WorkspaceOwnerBilling = z.output<typeof workspaceOwnerBillingSchema>
export const getWorkspaceOwnerBillingContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/owner-billing',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: workspaceOwnerBillingSchema,
},
})
export const workspaceHostContextSchema = z.object({
workspace: z.object({
id: nonEmptyIdSchema,
@@ -1,87 +0,0 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetHighestPrioritySubscription, mockResolveWorkspaceBillingPayer, billingState } =
vi.hoisted(() => ({
mockGetHighestPrioritySubscription: vi.fn(),
mockResolveWorkspaceBillingPayer: vi.fn(),
billingState: { isBillingEnabled: true, isFreeApiDeploymentGateEnabled: true },
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return billingState.isBillingEnabled
},
get isFreeApiDeploymentGateEnabled() {
return billingState.isFreeApiDeploymentGateEnabled
},
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
resolveWorkspaceBillingPayer: mockResolveWorkspaceBillingPayer,
}))
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
describe('isWorkspaceApiExecutionEntitled', () => {
beforeEach(() => {
vi.clearAllMocks()
billingState.isBillingEnabled = true
billingState.isFreeApiDeploymentGateEnabled = true
})
it('is false when the exact workspace payer is free', async () => {
mockResolveWorkspaceBillingPayer.mockResolvedValue({
billedAccountUserId: 'owner-1',
organizationId: 'org-1',
payerSubscription: { plan: 'free' },
})
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('is true when the exact workspace payer is paid', async () => {
mockResolveWorkspaceBillingPayer.mockResolvedValue({
billedAccountUserId: 'owner-1',
organizationId: 'org-1',
payerSubscription: { plan: 'team_6000' },
})
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('does not substitute another subscription held by the billed account owner', async () => {
mockResolveWorkspaceBillingPayer.mockResolvedValue({
billedAccountUserId: 'owner-1',
organizationId: 'free-org',
payerSubscription: null,
})
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'enterprise' })
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})
it('is false when the workspace has no resolvable payer', async () => {
mockResolveWorkspaceBillingPayer.mockResolvedValue(null)
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
})
it('skips the billed-account lookup on self-hosted', async () => {
billingState.isBillingEnabled = false
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
expect(mockResolveWorkspaceBillingPayer).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(mockResolveWorkspaceBillingPayer).not.toHaveBeenCalled()
})
})
-30
View File
@@ -1,30 +0,0 @@
import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution'
import { isPaid } from '@/lib/billing/plan-helpers'
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
/** 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, or MCP 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 workflows in `workspaceId` may run programmatically, gated on the
* workspace-selected payer's exact subscription. Always allowed when billing
* enforcement is off (self-hosted / `BILLING_ENABLED` unset); short-circuits
* before any DB lookup.
*/
export async function isWorkspaceApiExecutionEntitled(
workspaceId: string | undefined
): Promise<boolean> {
if (!isApiExecutionGateActive() || !workspaceId) return true
const payer = await resolveWorkspaceBillingPayer(workspaceId, { onMissing: 'return-null' })
return isPaid(payer?.payerSubscription?.plan)
}
-1
View File
@@ -4,7 +4,6 @@
*/
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'
-8
View File
@@ -59,14 +59,6 @@ export const isBillingEnabled =
? isTruthy(env.BILLING_ENABLED)
: isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
/**
* Block free-plan accounts from programmatic workflow execution (API key, public
* API, MCP 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)
/**
* Is email verification enabled
*/
-1
View File
@@ -87,7 +87,6 @@ export const env = createEnv({
BILLING_CONCURRENCY_LIMIT_TEAM: z.string().optional(), // In-flight executions per Max-tier billing account (Max and Max for Teams)
BILLING_CONCURRENCY_LIMIT_ENTERPRISE: z.string().optional(), // In-flight executions per Enterprise billing account (metadata-overridable)
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
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
PII_GRANULAR_REDACTION: z.boolean().optional(), // Expose the execution-altering PII redaction stages (redact workflow input + block outputs in-flight) in the Data Retention config; layered on top of PII_REDACTION