Skip to content

feat(billing): gate programmatic workflow execution behind a paid plan#5036

Merged
TheodoreSpeaks merged 5 commits into
stagingfrom
feat/disable-free-api-deployment
Jun 14, 2026
Merged

feat(billing): gate programmatic workflow execution behind a paid plan#5036
TheodoreSpeaks merged 5 commits into
stagingfrom
feat/disable-free-api-deployment

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Free-plan accounts (hosted only) are blocked from running workflows programmatically: API-key/public execute, MCP server, A2A agent server, generic ("custom") webhooks, and cross-origin chat embeds. Returns 402 (403 for chat embeds) with an upgrade message.
  • Untouched: provider webhooks (Slack/GitHub/…), session/browser runs, internal-JWT executor traffic, and self-hosted (no billing).
  • New isApiExecutionEntitled / isWorkspaceApiExecutionEntitled helpers (hosted-only, roll up org subs) gate each route inline.
  • Deploy modal: API/MCP/A2A tabs show an "Explore plans" upgrade prompt for free users (footers hidden).
  • Upgrade page: Pro feature renamed to "Deploy workflows as APIs"; comparison-table API-endpoint row shows 0 for Free.

Type of Change

  • New feature

Testing

Tested manually (curl: free API key → 402 on execute/MCP/A2A; generic webhook with valid token → 402; cross-origin chat → 403; first-party/session unaffected). Added/updated unit + route tests. bun run lint and bun run check:api-validation:strict pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Block free-plan accounts (hosted only) from running workflows programmatically:
API-key/public execute, MCP server, A2A agent server, generic webhooks, and
cross-origin chat embeds. Returns 402 (403 for chat embeds) with an upgrade
message; provider webhooks, session/browser runs, internal-JWT executor traffic,
and self-hosted are unaffected.

- Add isApiExecutionEntitled / isWorkspaceApiExecutionEntitled gate helpers
- Gate the execute, mcp/serve, a2a/serve, webhooks/trigger, and chat routes
- Deploy modal: show an upgrade prompt on the API/MCP/A2A tabs for free users
- Upgrade page: rename Pro feature to 'Deploy workflows as APIs'; API endpoint
  rate-limit row shows 0 for Free
@vercel

vercel Bot commented Jun 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 14, 2026 12:51am

Request Review

@cursor

cursor Bot commented Jun 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches multiple public execution entry points and billing resolution; misconfiguration of flags or entitlement logic could block paid workspaces or leave free programmatic access open until the gate is enabled.

Overview
Adds a dark-rollout paywall for programmatic workflow execution on hosted when BILLING_ENABLED and FREE_API_DEPLOYMENT_GATE_ENABLED are both on. New helpers isApiExecutionEntitled / isWorkspaceApiExecutionEntitled check the workspace billed account’s plan (with org subscription rollup).

API enforcement: Workflow execute (API key and public access), MCP serve, A2A serve, and generic webhook triggers return 402 with a shared upgrade message when the workspace is on free. Provider webhooks are unchanged. Chat adds assertChatEmbedAllowed on GET/POST: cross-origin embeds (non–*.sim.ai / app URL) get 403 unless the workspace is paid; first-party and missing Origin are not gated.

Product UI: Deploy modal API/MCP/A2A tabs show DeployUpgradeGate for free users (after subscription loads); tab footers hide while gated. Upgrade copy updates (Pro feature “Deploy workflows as APIs”; comparison table shows 0 API endpoints on Free).

Tests cover entitlement helpers, embed rules, and route 402/403 behavior.

Reviewed by Cursor Bugbot for commit 9687476. Bugbot is set up for automated code reviews on this repo. Configure here.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread apps/sim/app/api/workflows/[id]/execute/route.ts
Comment thread apps/sim/lib/billing/core/api-access.ts
Comment thread apps/sim/app/api/webhooks/trigger/[path]/route.ts
@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR gates programmatic workflow execution (API key, public API, MCP server, A2A agent server, generic webhooks, cross-origin chat embeds) behind a paid plan on hosted deployments, returning 402 for free-plan callers. Provider webhooks (Slack, GitHub, etc.), session-based runs, and self-hosted deployments are explicitly left unaffected.

  • New isApiExecutionEntitled / isWorkspaceApiExecutionEntitled helpers in lib/billing/core/api-access.ts gate each route on the workspace billed account (consistent across all programmatic surfaces), guarded by a dual feature flag (BILLING_ENABLED + FREE_API_DEPLOYMENT_GATE_ENABLED) for dark rollout.
  • The execute route was corrected to gate on wf.workspaceId rather than wf.userId (the workflow creator), matching the workspace-scoped approach used by MCP/A2A/webhooks.
  • The deploy modal adds an upgrade gate component (DeployUpgradeGate) for API/MCP/A2A tabs with a loading-state guard to prevent flashing the wall at paid users before the subscription query resolves.

Confidence Score: 4/5

Safe to merge for security correctness; one UI/API mismatch in the deploy modal should be addressed before the dark-rollout flag is used in production.

All API routes correctly gate on the workspace billed account and return 402. The one concrete defect is in the deploy modal: its gate condition reads NEXT_PUBLIC_BILLING_ENABLED but not the new server-only FREE_API_DEPLOYMENT_GATE_ENABLED, so during the intended dark-rollout phase (billing on, gate off) the upgrade wall appears in the UI while the API still accepts free-plan requests. No unauthorised execution is possible — the API enforcement is sound — but users would see conflicting signals.

apps/sim/app/workspace/.../deploy-modal/deploy-modal.tsx — the gateProgrammaticDeploy condition needs a client-accessible counterpart of FREE_API_DEPLOYMENT_GATE_ENABLED to stay in sync with the API gate during dark rollout.

Important Files Changed

Filename Overview
apps/sim/lib/billing/core/api-access.ts New helper module gating programmatic execution; correctly delegates to subscription + workspace-billed-account lookups, but fails open when workspaceId is undefined (pre-existing documented concern)
apps/sim/app/api/webhooks/trigger/[path]/route.ts Multi-webhook all-free-plan 500→402 fix correctly applied via billingBlocked sentinel; fan-out mixed-provider path works as intended
apps/sim/app/api/workflows/[id]/execute/route.ts API-key and public-API paths now gate on workspace billed account (not workflow creator); extra DB query for API-key callers is minimal overhead
apps/sim/app/api/a2a/serve/[agentId]/route.ts Billing gate placed after the explicit billedUserId null-guard that already existed, so no new fail-open risk
apps/sim/app/api/mcp/serve/[serverId]/route.ts Billing check inserted at the top of authorizeMcpServeRequest before public-server short-circuit; applies to both public and private MCP servers
apps/sim/app/api/chat/utils.ts assertChatEmbedAllowed correctly skips first-party origins and falls through on missing workflow (fail-open); extra DB round-trip is a pre-existing concern noted by prior review
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx Loading-state flash fixed with isLoadingSubscription guard, but gate condition doesn't include the server-only FREE_API_DEPLOYMENT_GATE_ENABLED flag, causing UI/API divergence during dark rollout
apps/sim/lib/billing/core/api-access.test.ts Good coverage of billing and feature-flag combinations; null billed-account case not tested (pre-existing gap)
apps/sim/lib/core/config/feature-flags.ts New isFreeApiDeploymentGateEnabled constant mirrors isBillingEnabled pattern; no client-side (NEXT_PUBLIC_*) counterpart was added

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incoming request] --> B{Auth type?}
    B -->|Session / Internal JWT| Z[Proceed - not gated]
    B -->|API key| C[isWorkspaceApiExecutionEntitled]
    B -->|Public API| C
    B -->|MCP serve| C
    B -->|A2A serve| D[isApiExecutionEntitled via billedUserId]
    B -->|Generic webhook| C
    B -->|Cross-origin chat embed| C

    C --> E{isBillingEnabled AND isFreeApiDeploymentGateEnabled?}
    D --> E
    E -->|No - self-hosted or gate off| Z
    E -->|Yes| F[getWorkspaceBilledAccountUserId]
    F -->|null| Z2[Fail-open - allow]
    F -->|userId| G[getHighestPrioritySubscription]
    G -->|paid plan| Z
    G -->|free / null| H[Return 402 / 403]

    style H fill:#f66,color:#fff
    style Z fill:#6a6,color:#fff
    style Z2 fill:#fa6,color:#fff
Loading

Reviews (6): Last reviewed commit: "feat(billing): add FREE_API_DEPLOYMENT_G..." | Re-trigger Greptile

Comment thread apps/sim/app/api/webhooks/trigger/[path]/route.ts
These pre-existing tests exercise gated routes with API-key/generic-webhook
paths; on hosted (CI) the gate now returns 402. Mock the gate as entitled by
default and add a generic-webhook 402 coverage case.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR gates programmatic workflow execution (API key, public execute, MCP, A2A, generic webhooks, cross-origin chat embeds) behind a paid plan on hosted deployments, returning 402/403 for free-tier accounts. New isApiExecutionEntitled / isWorkspaceApiExecutionEntitled helpers centralize the billing check, the deploy modal shows an upgrade prompt instead of the configuration tabs for free users, and the upgrade page copy is updated to reflect the restriction.

  • lib/billing/core/api-access.ts introduces two entitlement helpers (self-hosted and missing-identity always pass); each route integrates the appropriate variant with good test coverage.
  • The webhook route gates generic webhooks inside the per-webhook fan-out loop; the continue path leaves responses empty when all entries are blocked, resulting in a 500 instead of 402.
  • The deploy modal derives gateProgrammaticDeploy from isFree(subscriptionData?.data?.plan) without guarding for the loading state, so paid users briefly see the upgrade gate while the subscription query resolves.

Confidence Score: 4/5

The core billing helpers and most route integrations are correct and well-tested; the two points of concern are an error-response regression in the multi-webhook fan-out path and a visual flash in the deploy modal that does not affect backend enforcement.

The webhook fan-out loop silently swallows 402 responses when multiple generic webhooks are all blocked, returning a misleading 500 to the caller. This is a real, reproducible failure: any credential-set fan-out with every entry being a blocked generic webhook triggers it. The execute route also uses the workflow creator's user ID for the public-API billing check rather than the workspace billing account, which could mismatch after org membership changes.

apps/sim/app/api/webhooks/trigger/[path]/route.ts — the fan-out billing gate; apps/sim/app/api/workflows/[id]/execute/route.ts — the public-API billing identity.

Important Files Changed

Filename Overview
apps/sim/lib/billing/core/api-access.ts New module providing isApiExecutionEntitled (user-scoped) and isWorkspaceApiExecutionEntitled (workspace-scoped) billing gates; self-hosted and missing-user cases short-circuit to allowed; well-tested.
apps/sim/app/api/webhooks/trigger/[path]/route.ts Adds billing gate for generic webhooks inside the fan-out loop; when multiple generic webhooks are all blocked and continue is used, responses ends up empty and a 500 is returned instead of 402.
apps/sim/app/api/workflows/[id]/execute/route.ts Billing gate applied before body parsing for API-key and public-API callers; public-API path uses workflow creator's user ID rather than workspace billing account, creating a potential mismatch if the creator's org membership changes.
apps/sim/app/api/mcp/serve/[serverId]/route.ts Billing check placed at the top of authorizeMcpServeRequest, using workspace-scoped entitlement; correct for gating public servers too.
apps/sim/app/api/a2a/serve/[agentId]/route.ts Billing gate inserted after billedUserId is already resolved, using the user-scoped helper with the workspace billing account; consistent with how the rest of A2A resolves billing.
apps/sim/app/api/chat/utils.ts New assertChatEmbedAllowed correctly identifies first-party *.sim.ai origins, short-circuits on self-hosted, and gates cross-origin embeds via workspace entitlement.
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx UI gate wraps API/MCP/A2A tabs with DeployUpgradeGate; isFree(undefined) is true so the gate flashes briefly while subscription data loads for paid users before the query resolves.
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx New upgrade-gate component; prefetches the upgrade route and subscription queries on mouse enter/focus for a smooth redirect; clean and self-contained.
apps/sim/lib/billing/core/api-access.test.ts Comprehensive unit tests for both entitlement helpers covering free, paid, self-hosted, missing-userId, and missing-workspaceId cases; all edge paths exercised.
apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts API endpoint row updated from '30' to '0' for the Free tier to reflect the new restriction.
apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts Pro feature label changed from 'Higher rate limits' to 'Deploy workflows as APIs' to reflect the new billing gate.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incoming programmatic request] --> B{Route type?}
    B -->|API key / public execute| C[execute/route.ts]
    B -->|MCP server| D[mcp/serve/route.ts]
    B -->|A2A agent| E[a2a/serve/route.ts]
    B -->|Webhook| F[webhooks/trigger/route.ts]
    B -->|Chat embed| G[chat/utils.ts assertChatEmbedAllowed]
    C --> H{isApiExecutionEntitled userId}
    D --> I{isWorkspaceApiExecutionEntitled workspaceId}
    E --> J{isApiExecutionEntitled billedUserId}
    F --> K{provider === generic?}
    G --> L{isHosted AND cross-origin?}
    K -->|yes| M{isWorkspaceApiExecutionEntitled workspaceId}
    K -->|no provider webhook| N[Allow]
    H -->|not entitled| O[402]
    I -->|not entitled| O
    J -->|not entitled| O
    M -->|not entitled single webhook| O
    M -->|not entitled multi webhook| P[continue - may produce 500]
    L -->|yes| Q{isWorkspaceApiExecutionEntitled}
    H -->|entitled| R[Execute]
    I -->|entitled| R
    J -->|entitled| R
    M -->|entitled| R
    Q -->|not entitled| S[403]
    Q -->|entitled| R
    L -->|same origin| R
Loading

Reviews (2): Last reviewed commit: "test(billing): mock api-execution gate i..." | Re-trigger Greptile

Comment thread apps/sim/app/api/webhooks/trigger/[path]/route.ts
Comment thread apps/sim/app/api/workflows/[id]/execute/route.ts Outdated
Comment thread apps/sim/app/api/chat/utils.ts
Comment thread apps/sim/lib/billing/core/api-access.ts
- execute route: gate on the workflow's workspace billed account (like MCP/A2A/
  webhooks/chat) instead of the caller/creator's personal plan, so a paid
  workspace is never 402'd because an individual is on free
- webhooks: all-generic-all-free fan-out now returns 402, not a 500 'No webhooks
  processed' fallback
- deploy modal: hold the gate closed until the subscription query resolves
  (isFree(undefined) is true) to avoid flashing the upgrade wall at paid users
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Addressed the Bugbot/Greptile findings in 57a0f47:

  • Execute gates on caller's personal plan (High / P2): switched the execute route to gate on the workflow's workspace billed account (isWorkspaceApiExecutionEntitled), matching MCP/A2A/webhooks/chat. A paid workspace is no longer 402'd because the key holder / public-API workflow creator is on free.
  • Multi-generic-webhook fan-out 500 (Medium / P1): all-generic-all-free now returns 402, not the No webhooks processed 500 fallback. Added a fan-out test.
  • Deploy modal gate flash (P1/P2): gate is held closed until the subscription query resolves (isFree(undefined) is true), so paid users don't see an upgrade-wall flash.

Intentionally left as-is:

  • Fail-open on missing billing identity (Bugbot High): the helpers allow when workspaceId/billed account can't be resolved. This is a deliberate fail-open for a revenue gate (not a security boundary) so a data gap never blocks a legitimate run; the affected entities (server.workspaceId, etc.) are non-null in practice and a workflow with no workspace can't execute anyway.
  • Deploy modal uses viewer plan, not workspace billed account (Bugbot Medium): the client uses useSubscriptionData (the viewer's rolled-up plan) as a best-effort hint; the server is the source of truth and enforces the workspace-billed-account check. Resolving the workspace billing account client-side isn't worth the extra surface for a UI affordance.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Re-review on 57a0f47 re-raised a few items — status:

Already fixed in 57a0f47 (stale re-raises):

  • "Workspace keys use wrong billing" (execute) — execute now gates on isWorkspaceApiExecutionEntitled(gateWorkspaceId) (the workflow's workspace billed account), not the caller's userId. See route.ts L441-461.
  • "Multiple generic webhooks return 500" — now tracked via billingBlocked and returns 402 when the loop ends empty for that reason. Covered by a new fan-out test.

Fail-open findings (api-access.ts L39, chat utils L87) — not a real leak, leaving as-is:
workspace.billedAccountUserId is NOT NULL (schema.ts L1231-1233), so getWorkspaceBilledAccountUserId never returns null for a real workspace. The only path that fail-opens is a missing/archived workflow (no workspaceId), which can't execute or serve a chat anyway. So there's no paid-bypass; denying there would only add a failure mode for already-broken state.

The paywall should follow billing enforcement, not the hostname. Keying off
isHosted would still 402 free users on a hosted deployment with BILLING_ENABLED
unset. Switch the server gate (api-access, chat embed) to isBillingEnabled and
the deploy-modal UI to the client NEXT_PUBLIC_BILLING_ENABLED flag (matching the
Inbox paywall), so a billing-disabled deployment skips the gate entirely.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Gate the programmatic-execution paywall behind a dedicated backend env flag
(combined with BILLING_ENABLED), off by default, so it can ship dark and be
enabled per-deployment after a backend sanity check. Backend only — the deploy
modal UI is unchanged.
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9687476. Configure here.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Both Bugbot items on the latest commit are re-raises of already-addressed findings (Bugbot re-reviews the full diff each push):

  • execute "wrong billing" — fixed in 57a0f47: the gate now calls isWorkspaceApiExecutionEntitled(gateWorkspaceId) (workspace billed account), not the caller's userId. See route.ts L446-461.
  • "missing billing identity" (fail-open) — not a real leak: workspace.billedAccountUserId is NOT NULL, so the only fail-open path is a missing/archived workflow that can't execute anyway.

No new changes needed.

@TheodoreSpeaks TheodoreSpeaks merged commit 522ba8e into staging Jun 14, 2026
15 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the feat/disable-free-api-deployment branch June 14, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant