Skip to content

Commit 5341912

Browse files
fix(billing): deploy modal gates on workspace entitlement, not viewer plan
The deploy modal showed the upgrade wall to a free user in a PAID workspace, because it gated on the viewer's individual plan (useSubscriptionData) while the server gates on the workspace billed account (rolled-up plan). Add a workspace api-execution-entitlement endpoint that mirrors isWorkspaceApiExecutionEntitled, and gate the API/MCP/A2A tabs on it so the UI matches the server exactly.
1 parent 3ada4a3 commit 5341912

6 files changed

Lines changed: 177 additions & 11 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockGetUserEntityPermissions, mockIsWorkspaceApiExecutionEntitled } =
8+
vi.hoisted(() => ({
9+
mockGetSession: vi.fn(),
10+
mockGetUserEntityPermissions: vi.fn(),
11+
mockIsWorkspaceApiExecutionEntitled: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({
15+
auth: { api: { getSession: vi.fn() } },
16+
getSession: mockGetSession,
17+
}))
18+
19+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
20+
getUserEntityPermissions: mockGetUserEntityPermissions,
21+
}))
22+
23+
vi.mock('@/lib/billing/core/api-access', () => ({
24+
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
25+
}))
26+
27+
import { GET } from '@/app/api/workspaces/[id]/api-execution-entitlement/route'
28+
29+
const WORKSPACE_ID = 'ws-1'
30+
31+
function buildParams() {
32+
return { params: Promise.resolve({ id: WORKSPACE_ID }) }
33+
}
34+
35+
async function callGet() {
36+
const request = createMockRequest('GET')
37+
const response = await GET(request, buildParams())
38+
return { status: response.status, body: await response.json() }
39+
}
40+
41+
describe('GET /api/workspaces/[id]/api-execution-entitlement', () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks()
44+
mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
45+
mockGetUserEntityPermissions.mockResolvedValue('read')
46+
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
47+
})
48+
49+
it('returns 401 when unauthenticated', async () => {
50+
mockGetSession.mockResolvedValue(null)
51+
const { status } = await callGet()
52+
expect(status).toBe(401)
53+
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
54+
})
55+
56+
it('returns 404 when the caller has no workspace access', async () => {
57+
mockGetUserEntityPermissions.mockResolvedValue(null)
58+
const { status } = await callGet()
59+
expect(status).toBe(404)
60+
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
61+
})
62+
63+
it('returns entitled: true for an entitled workspace', async () => {
64+
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
65+
const { status, body } = await callGet()
66+
expect(status).toBe(200)
67+
expect(body).toEqual({ entitled: true })
68+
expect(mockIsWorkspaceApiExecutionEntitled).toHaveBeenCalledWith(WORKSPACE_ID)
69+
})
70+
71+
it('returns entitled: false for a free workspace with the gate active', async () => {
72+
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(false)
73+
const { status, body } = await callGet()
74+
expect(status).toBe(200)
75+
expect(body).toEqual({ entitled: false })
76+
})
77+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextRequest } from 'next/server'
3+
import { NextResponse } from 'next/server'
4+
import { getWorkspaceApiExecutionEntitlementContract } from '@/lib/api/contracts/workspaces'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
10+
11+
const logger = createLogger('WorkspaceApiExecutionEntitlementAPI')
12+
13+
/**
14+
* Whether this workspace may run workflows programmatically — the UI mirror of
15+
* the server gate (`isWorkspaceApiExecutionEntitled`). Lets the deploy modal
16+
* reflect the workspace's billed-account plan instead of the viewer's individual
17+
* plan, so a free member of a paid workspace isn't shown the upgrade wall.
18+
*/
19+
export const GET = withRouteHandler(
20+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
21+
const session = await getSession()
22+
if (!session?.user?.id) {
23+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
24+
}
25+
26+
const parsed = await parseRequest(getWorkspaceApiExecutionEntitlementContract, req, context)
27+
if (!parsed.success) return parsed.response
28+
const { id: workspaceId } = parsed.data.params
29+
30+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
31+
if (!permission) {
32+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
33+
}
34+
35+
const entitled = await isWorkspaceApiExecutionEntitled(workspaceId)
36+
logger.info('Resolved workspace API-execution entitlement', { workspaceId, entitled })
37+
return NextResponse.json({ entitled })
38+
}
39+
)

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ import {
2121
ModalTabsList,
2222
ModalTabsTrigger,
2323
} from '@/components/emcn'
24-
import { isFree } from '@/lib/billing/plan-helpers'
2524
import { getBaseUrl } from '@/lib/core/utils/urls'
2625
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
2726
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2827
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
29-
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
3028
import {
3129
releaseDeployAction,
3230
tryAcquireDeployAction,
@@ -46,10 +44,12 @@ import {
4644
useDeployWorkflow,
4745
useUndeployWorkflow,
4846
} from '@/hooks/queries/deployments'
49-
import { useSubscriptionData } from '@/hooks/queries/subscription'
5047
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
5148
import { useWorkflowMap } from '@/hooks/queries/workflows'
52-
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
49+
import {
50+
useWorkspaceApiExecutionEntitlement,
51+
useWorkspaceSettings,
52+
} from '@/hooks/queries/workspace'
5353
import { usePermissionConfig } from '@/hooks/use-permission-config'
5454
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
5555
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -158,11 +158,14 @@ export function DeployModal({
158158
const userPermissions = useUserPermissionsContext()
159159
const canManageWorkspaceKeys = userPermissions.canAdmin
160160
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
161-
const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscriptionData()
162-
// Hold the gate closed until the plan is known — isFree(undefined) is true, so
163-
// gating during load would flash the upgrade wall at paid users.
164-
const gateProgrammaticDeploy =
165-
isBillingEnabled && !isLoadingSubscription && isFree(subscriptionData?.data?.plan)
161+
// Mirror the server gate: entitlement reflects the workspace's billed-account
162+
// plan (rolled up), not the viewer's individual plan, so a free member of a
163+
// paid workspace isn't shown the upgrade wall. Undefined while loading keeps
164+
// the gate closed (no flash); only an explicit `entitled === false` gates.
165+
const { data: apiExecutionEntitlement } = useWorkspaceApiExecutionEntitlement(
166+
workflowWorkspaceId ?? undefined
167+
)
168+
const gateProgrammaticDeploy = apiExecutionEntitlement?.entitled === false
166169
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
167170
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
168171
workflowWorkspaceId || ''

apps/sim/hooks/queries/workspace.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import type { ContractBodyInput } from '@/lib/api/contracts'
66
import {
77
createWorkspaceContract,
88
deleteWorkspaceContract,
9+
getWorkspaceApiExecutionEntitlementContract,
910
getWorkspaceContract,
1011
getWorkspaceMembersContract,
1112
getWorkspacePermissionsContract,
1213
listWorkspacesContract,
1314
updateWorkspaceContract,
1415
type Workspace,
16+
type WorkspaceApiExecutionEntitlement,
1517
type WorkspaceCreationPolicy,
1618
type WorkspaceMember,
1719
type WorkspacePermissions,
@@ -33,6 +35,8 @@ export const workspaceKeys = {
3335
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
3436
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
3537
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
38+
apiExecutionEntitlement: (id: string) =>
39+
[...workspaceKeys.detail(id), 'apiExecutionEntitlement'] as const,
3640
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
3741
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
3842
}
@@ -108,6 +112,30 @@ export function useWorkspaceCreationPolicy(enabled = true) {
108112
})
109113
}
110114

115+
async function fetchWorkspaceApiExecutionEntitlement(
116+
workspaceId: string,
117+
signal?: AbortSignal
118+
): Promise<WorkspaceApiExecutionEntitlement> {
119+
return requestJson(getWorkspaceApiExecutionEntitlementContract, {
120+
params: { id: workspaceId },
121+
signal,
122+
})
123+
}
124+
125+
/**
126+
* Whether the workspace may run workflows programmatically — the UI mirror of the
127+
* server gate. Reflects the workspace's billed-account plan, not the viewer's
128+
* individual plan, so a free member of a paid workspace isn't gated.
129+
*/
130+
export function useWorkspaceApiExecutionEntitlement(workspaceId?: string) {
131+
return useQuery({
132+
queryKey: workspaceKeys.apiExecutionEntitlement(workspaceId ?? ''),
133+
queryFn: ({ signal }) => fetchWorkspaceApiExecutionEntitlement(workspaceId as string, signal),
134+
enabled: Boolean(workspaceId),
135+
staleTime: 60 * 1000,
136+
})
137+
}
138+
111139
type CreateWorkspaceParams = Pick<ContractBodyInput<typeof createWorkspaceContract>, 'name'>
112140

113141
/**

apps/sim/lib/api/contracts/workspaces.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ export const getWorkspaceContract = defineRouteContract({
181181
},
182182
})
183183

184+
export const workspaceApiExecutionEntitlementSchema = z.object({
185+
/** Whether this workspace may run workflows programmatically (mirrors the server gate). */
186+
entitled: z.boolean(),
187+
})
188+
189+
export type WorkspaceApiExecutionEntitlement = z.output<
190+
typeof workspaceApiExecutionEntitlementSchema
191+
>
192+
193+
export const getWorkspaceApiExecutionEntitlementContract = defineRouteContract({
194+
method: 'GET',
195+
path: '/api/workspaces/[id]/api-execution-entitlement',
196+
params: workspaceParamsSchema,
197+
response: {
198+
mode: 'json',
199+
schema: workspaceApiExecutionEntitlementSchema,
200+
},
201+
})
202+
184203
export const updateWorkspaceContract = defineRouteContract({
185204
method: 'PATCH',
186205
path: '/api/workspaces/[id]',

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 826,
13-
zodRoutes: 826,
12+
totalRoutes: 827,
13+
zodRoutes: 827,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)