mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
+1
-1
@@ -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',
|
||||
]
|
||||
|
||||
-51
@@ -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
@@ -1 +0,0 @@
|
||||
export { DeployUpgradeGate } from './deploy-upgrade-gate'
|
||||
-1
@@ -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'
|
||||
|
||||
+28
-63
@@ -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'>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user