From 8183314a8877f6bfb08f8df14942338ca2c7d8c1 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Thu, 9 Jul 2026 14:32:42 -0700
Subject: [PATCH 1/8] fix(invites): preserve active organization for external
access
Keep organization activation server-owned so failed membership checks cannot clear a valid session context.
---
apps/sim/app/invite/[id]/invite.test.tsx | 160 +++++++++++++++++++++++
apps/sim/app/invite/[id]/invite.tsx | 8 --
2 files changed, 160 insertions(+), 8 deletions(-)
create mode 100644 apps/sim/app/invite/[id]/invite.test.tsx
diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx
new file mode 100644
index 00000000000..ef043006658
--- /dev/null
+++ b/apps/sim/app/invite/[id]/invite.test.tsx
@@ -0,0 +1,160 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockInvalidateQueries,
+ mockPush,
+ mockRequestJson,
+ mockSearchParams,
+ mockSetActive,
+ mockSignOut,
+ mockUseSession,
+} = vi.hoisted(() => ({
+ mockInvalidateQueries: vi.fn(),
+ mockPush: vi.fn(),
+ mockRequestJson: vi.fn(),
+ mockSearchParams: {
+ get: (key: string) => (key === 'token' ? 'token-1' : null),
+ },
+ mockSetActive: vi.fn(),
+ mockSignOut: vi.fn(),
+ mockUseSession: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ id: 'invitation-1' }),
+ useRouter: () => ({ push: mockPush }),
+ useSearchParams: () => mockSearchParams,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ client: {
+ organization: { setActive: mockSetActive },
+ signOut: mockSignOut,
+ },
+ useSession: mockUseSession,
+}))
+
+vi.mock('@/app/invite/components', () => ({
+ InviteLayout: ({ children }: { children: ReactNode }) => children,
+ InviteStatusCard: ({
+ actions = [],
+ }: {
+ actions?: Array<{ label: string; onClick: () => void }>
+ }) => (
+ <>
+ {actions.map((action) => (
+
+ {action.label}
+
+ ))}
+ >
+ ),
+}))
+
+import Invite from '@/app/invite/[id]/invite'
+
+let container: HTMLDivElement
+let root: Root
+
+async function flush(): Promise {
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.useFakeTimers()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+
+ mockUseSession.mockReturnValue({
+ data: { user: { id: 'user-1', email: 'invitee@example.com' } },
+ isPending: false,
+ })
+ mockInvalidateQueries.mockResolvedValue(undefined)
+ mockRequestJson.mockImplementation((contract: { method?: string }) => {
+ if (contract.method === 'GET') {
+ return Promise.resolve({
+ invitation: {
+ id: 'invitation-1',
+ kind: 'workspace',
+ email: 'invitee@example.com',
+ organizationId: 'organization-2',
+ organizationName: 'External Team',
+ membershipIntent: 'external',
+ role: 'admin',
+ status: 'pending',
+ expiresAt: '2026-07-10T00:00:00.000Z',
+ createdAt: '2026-07-09T00:00:00.000Z',
+ inviterName: 'Inviter',
+ inviterEmail: 'inviter@example.com',
+ grants: [
+ {
+ workspaceId: 'workspace-1',
+ workspaceName: 'External Workspace',
+ permission: 'admin',
+ },
+ ],
+ },
+ })
+ }
+
+ return Promise.resolve({
+ success: true,
+ redirectPath: '/workspace/workspace-1',
+ invitation: {
+ id: 'invitation-1',
+ kind: 'workspace',
+ organizationId: 'organization-2',
+ acceptedWorkspaceIds: ['workspace-1'],
+ },
+ })
+ })
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.clearAllMocks()
+})
+
+describe('Invite', () => {
+ it('does not replace the active organization after accepting an external workspace invite', async () => {
+ act(() => {
+ root.render( )
+ })
+ await flush()
+
+ const acceptButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Accept Invitation'
+ )
+ expect(acceptButton).toBeDefined()
+
+ await act(async () => {
+ acceptButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+
+ expect(mockSetActive).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx
index b84e1f50fcb..8bf5b82fd86 100644
--- a/apps/sim/app/invite/[id]/invite.tsx
+++ b/apps/sim/app/invite/[id]/invite.tsx
@@ -235,14 +235,6 @@ export default function Invite() {
body: { token: token ?? undefined },
})
- if (invitation.organizationId) {
- try {
- await client.organization.setActive({ organizationId: invitation.organizationId })
- } catch (setActiveError) {
- logger.warn('Failed to set active organization after accept', setActiveError)
- }
- }
-
await Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
queryClient.invalidateQueries({ queryKey: organizationKeys.all }),
From d9856da2490c8e276f85b0d39a74afed4fa33b34 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Fri, 10 Jul 2026 17:04:59 -0700
Subject: [PATCH 2/8] feat(admin, billing, settings): cleanup settings
visibility, billing actor resolution, new admin routes
---
.../docs/de/api-reference/authentication.mdx | 11 +-
.../docs/en/api-reference/authentication.mdx | 11 +-
apps/docs/content/docs/en/platform/costs.mdx | 7 +-
.../docs/es/api-reference/authentication.mdx | 11 +-
.../docs/fr/api-reference/authentication.mdx | 11 +-
.../docs/ja/api-reference/authentication.mdx | 11 +-
.../docs/zh/api-reference/authentication.mdx | 11 +-
.../[executionId]/resume-page-client.tsx | 12 +
.../providers/session-provider.test.tsx | 104 +-
.../app/_shell/providers/session-provider.tsx | 9 +-
.../app/account/settings/[section]/page.tsx | 54 +
.../billing/credit-usage/page.test.ts | 55 +
.../settings/billing/credit-usage/page.tsx | 27 +
apps/sim/app/account/settings/layout.tsx | 16 +
apps/sim/app/account/settings/page.tsx | 6 +
.../app/api/audit-logs/export/route.test.ts | 5 +-
apps/sim/app/api/audit-logs/export/route.ts | 16 +-
apps/sim/app/api/audit-logs/route.ts | 18 +-
apps/sim/app/api/billing/route.test.ts | 310 +
apps/sim/app/api/billing/route.ts | 332 +-
apps/sim/app/api/billing/switch-plan/route.ts | 30 +-
.../app/api/billing/update-cost/route.test.ts | 492 +-
apps/sim/app/api/billing/update-cost/route.ts | 190 +-
.../app/api/chat/[identifier]/route.test.ts | 10 -
apps/sim/app/api/chat/[identifier]/route.ts | 14 +-
.../copilot/api-keys/validate/route.test.ts | 373 +-
.../api/copilot/api-keys/validate/route.ts | 215 +-
apps/sim/app/api/copilot/chat/abort/route.ts | 42 +-
.../sim/app/api/files/multipart/route.test.ts | 25 +-
apps/sim/app/api/files/multipart/route.ts | 10 +-
.../app/api/guardrails/validate/route.test.ts | 122 +-
apps/sim/app/api/guardrails/validate/route.ts | 72 +-
.../api/invitations/[id]/accept/route.test.ts | 88 +
.../app/api/invitations/[id]/accept/route.ts | 26 +-
.../connectors/[connectorId]/route.test.ts | 34 +-
.../[id]/connectors/[connectorId]/route.ts | 11 +-
.../[connectorId]/sync/route.test.ts | 34 +-
.../connectors/[connectorId]/sync/route.ts | 29 +-
.../knowledge/[id]/connectors/route.test.ts | 196 +
.../api/knowledge/[id]/connectors/route.ts | 34 +-
.../[id]/documents/[documentId]/route.test.ts | 3 +-
.../[id]/documents/[documentId]/route.ts | 21 +-
.../app/api/knowledge/[id]/documents/route.ts | 37 +-
.../[id]/documents/upsert/route.test.ts | 9 +
.../knowledge/[id]/documents/upsert/route.ts | 34 +-
.../api/knowledge/connectors/sync/route.ts | 18 +-
.../app/api/knowledge/search/route.test.ts | 68 +
apps/sim/app/api/knowledge/search/route.ts | 126 +-
apps/sim/app/api/knowledge/utils.test.ts | 41 +-
.../api/mcp/serve/[serverId]/route.test.ts | 238 +-
.../sim/app/api/mcp/serve/[serverId]/route.ts | 64 +-
apps/sim/app/api/mcp/tools/execute/route.ts | 388 +-
apps/sim/app/api/mothership/execute/route.ts | 16 +-
.../organizations/[id]/roster/route.test.ts | 272 +
.../api/organizations/[id]/roster/route.ts | 113 +-
.../[executionId]/[contextId]/route.test.ts | 367 +
.../[executionId]/[contextId]/route.ts | 185 +-
apps/sim/app/api/resume/poll/route.test.ts | 565 +
apps/sim/app/api/resume/poll/route.ts | 377 +-
.../app/api/schedules/execute/route.test.ts | 159 +-
apps/sim/app/api/schedules/execute/route.ts | 209 +-
apps/sim/app/api/speech/token/route.test.ts | 87 +-
apps/sim/app/api/speech/token/route.ts | 95 +-
.../sim/app/api/tools/jupyter/upload/route.ts | 15 +-
apps/sim/app/api/usage/route.test.ts | 53 +
apps/sim/app/api/usage/route.ts | 5 +-
apps/sim/app/api/users/me/api-keys/route.ts | 7 +-
.../subscription/[id]/transfer/route.test.ts | 44 +
.../me/subscription/[id]/transfer/route.ts | 20 +
apps/sim/app/api/v1/admin/dashboard/actor.ts | 16 +
.../[id]/retry/route.ts | 36 +
.../enterprise-provisioning/route.ts | 49 +
.../v1/admin/dashboard/global-work/route.ts | 32 +
.../organizations/[id]/credits/route.ts | 35 +
.../external-collaborators/[userId]/route.ts | 47 +
.../organizations/[id]/limits/route.ts | 33 +
.../[id]/members/[memberId]/route.ts | 64 +
.../organizations/[id]/members/route.ts | 33 +
.../dashboard/organizations/[id]/route.ts | 30 +
.../organizations/[id]/seats/route.ts | 33 +
.../[id]/transfer-ownership/route.ts | 33 +
.../v1/admin/dashboard/organizations/route.ts | 30 +
.../dashboard/users/[id]/credits/route.ts | 35 +
.../app/api/v1/admin/dashboard/users/route.ts | 30 +
.../dashboard/workspaces/[id]/move/route.ts | 49 +
.../workspaces/[id]/preflight/route.ts | 48 +
.../v1/admin/dashboard/workspaces/route.ts | 31 +
.../api/v1/admin/outbox/[id]/requeue/route.ts | 32 +-
apps/sim/app/api/v1/audit-logs/auth.test.ts | 84 +
apps/sim/app/api/v1/audit-logs/auth.ts | 17 +-
.../v1/knowledge/[id]/documents/route.test.ts | 71 +
.../api/v1/knowledge/[id]/documents/route.ts | 29 +-
.../app/api/v1/knowledge/search/route.test.ts | 80 +-
apps/sim/app/api/v1/knowledge/search/route.ts | 27 +-
apps/sim/app/api/v1/middleware.ts | 20 +-
.../v1/tables/[tableId]/rows/[rowId]/route.ts | 7 +-
.../app/api/v1/tables/[tableId]/rows/route.ts | 29 +-
.../v1/tables/[tableId]/rows/upsert/route.ts | 7 +-
apps/sim/app/api/wand/route.ts | 56 +-
.../app/api/webhooks/outbox/process/route.ts | 4 +
.../api/webhooks/trigger/[path]/route.test.ts | 226 +-
.../app/api/webhooks/trigger/[path]/route.ts | 37 +-
.../[id]/execute/route.async.test.ts | 624 +-
.../app/api/workflows/[id]/execute/route.ts | 249 +-
.../[id]/executions/[executionId]/route.ts | 5 +
.../app/api/workflows/[id]/log/route.test.ts | 249 +-
apps/sim/app/api/workflows/[id]/log/route.ts | 84 +-
.../workspaces/[id]/api-keys/route.test.ts | 94 +
.../app/api/workspaces/[id]/api-keys/route.ts | 8 +-
.../[id]/credit-availability/route.test.ts | 113 +
.../[id]/credit-availability/route.ts | 35 +
.../[id]/files/presigned/route.test.ts | 31 +-
.../workspaces/[id]/files/presigned/route.ts | 8 +-
.../[id]/files/register/route.test.ts | 6 +
.../[id]/host-context/route.test.ts | 91 +
.../api/workspaces/[id]/host-context/route.ts | 29 +
.../workspaces/[id]/usage-gate/route.test.ts | 121 +
.../api/workspaces/[id]/usage-gate/route.ts | 33 +
apps/sim/app/invite/[id]/invite.test.tsx | 77 +-
apps/sim/app/invite/[id]/invite.tsx | 2 +
.../settings/[section]/page.tsx | 72 +
.../[organizationId]/settings/layout.tsx | 35 +
.../[organizationId]/settings/page.tsx | 11 +
.../settings/unavailable/page.tsx | 5 +
.../components/workspace-access-denied.tsx | 26 +
.../credits-chip/credits-chip.test.tsx | 143 +
.../components/credits-chip/credits-chip.tsx | 99 +-
.../components/special-tags/special-tags.tsx | 34 +-
.../resource-content/resource-content.tsx | 29 +-
.../add-connector-modal.tsx | 11 +-
.../components/connector-entitlements.test.ts | 70 +
.../[id]/components/connector-entitlements.ts | 10 +
.../edit-connector-modal.tsx | 11 +-
.../workspace/[workspaceId]/layout.test.tsx | 181 +
.../app/workspace/[workspaceId]/layout.tsx | 72 +-
.../app/workspace/[workspaceId]/prefetch.ts | 54 +-
.../providers/custom-blocks-loader.test.tsx | 102 +
.../providers/custom-blocks-loader.tsx | 8 +-
.../providers/workspace-host-provider.tsx | 51 +
.../[workspaceId]/settings/[section]/page.tsx | 141 +-
.../settings/[section]/prefetch.ts | 78 -
.../settings/[section]/settings.tsx | 151 -
.../credit-usage/credit-usage-view.tsx | 54 +-
.../settings/billing/credit-usage/loading.tsx | 29 +-
.../settings/billing/credit-usage/page.tsx | 42 +-
.../settings/components/admin/admin.tsx | 46 +-
.../settings/components/api-keys/api-keys.tsx | 50 +-
.../create-api-key-modal.tsx | 6 +-
.../components/billing/billing.test.tsx | 352 +
.../settings/components/billing/billing.tsx | 208 +-
.../credit-usage-section.tsx | 10 +-
.../components/byok/byok-key-manager.tsx | 9 +-
.../byok/byok-provider-keys-modal.tsx | 27 +-
.../settings/components/byok/byok.tsx | 5 +
.../components/custom-tools/custom-tools.tsx | 134 +-
.../settings/components/inbox/inbox.tsx | 24 +-
.../settings/components/mcp/mcp.tsx | 198 +-
.../components/mothership/mothership.tsx | 52 +-
.../recently-deleted/recently-deleted.tsx | 6 +-
.../secrets-manager/secrets-manager.tsx | 17 +-
.../settings-header/settings-header.tsx | 228 +-
.../settings-panel/settings-panel.tsx | 97 +-
.../organization-member-lists.tsx | 73 +-
.../team-seats-overview.tsx | 26 +-
.../team-management/team-management.tsx | 112 +-
.../components/teammates/teammates.tsx | 48 +-
.../workflow-mcp-servers.tsx | 629 +-
.../hooks/use-settings-before-unload.ts | 22 +-
.../hooks/use-settings-unsaved-guard.ts | 64 +-
.../[workspaceId]/settings/layout.tsx | 5 +
.../[workspaceId]/settings/navigation.ts | 327 -
.../workspace/[workspaceId]/settings/page.tsx | 3 +-
.../upgrade/hooks/use-upgrade-state.test.tsx | 143 +
.../upgrade/hooks/use-upgrade-state.ts | 132 +-
.../[workspaceId]/upgrade/upgrade.tsx | 51 +-
.../components/chat/chat.test.tsx | 381 +
.../w/[workflowId]/components/chat/chat.tsx | 278 +-
.../components/chat/hooks/index.ts | 7 +-
.../chat/hooks/use-chat-file-upload.test.tsx | 105 +
.../chat/hooks/use-chat-file-upload.ts | 16 +-
.../components/deploy-modal/deploy-modal.tsx | 2 +-
.../panel/components/toolbar/toolbar.tsx | 7 +-
.../panel/hooks/use-usage-limits.ts | 28 +-
.../w/[workflowId]/components/panel/panel.tsx | 46 +-
.../hooks/use-workflow-execution.test.tsx | 473 +
.../hooks/use-workflow-execution.ts | 174 +-
.../utils/workflow-attachment-upload.ts | 125 +
.../settings-sidebar/settings-sidebar.tsx | 411 +-
.../create-workspace-modal.test.ts | 26 +
.../create-workspace-modal.tsx | 27 +-
.../invite-modal/invite-modal.test.tsx | 125 +
.../components/invite-modal/invite-modal.tsx | 12 +-
.../workspace-header/workspace-header.tsx | 21 +-
.../hooks/use-workspace-management.test.tsx | 94 +
.../sidebar/hooks/use-workspace-management.ts | 37 +-
.../async-preprocessing-correlation.test.ts | 131 +-
.../knowledge-connector-sync.test.ts | 77 +
.../background/knowledge-connector-sync.ts | 63 +-
.../background/knowledge-processing.test.ts | 139 +
apps/sim/background/knowledge-processing.ts | 79 +-
apps/sim/background/resume-execution.ts | 124 +-
apps/sim/background/schedule-execution.ts | 77 +-
apps/sim/background/webhook-execution.test.ts | 74 +-
apps/sim/background/webhook-execution.ts | 70 +-
.../background/workflow-column-execution.ts | 256 +-
apps/sim/background/workflow-execution.ts | 28 +-
.../settings/account-settings-renderer.tsx | 53 +
.../components/settings/navigation.test.ts | 343 +
apps/sim/components/settings/navigation.ts | 634 +
.../organization-settings-renderer.tsx | 71 +
.../components/settings/settings-header.tsx | 201 +
.../components/settings/settings-panel.tsx | 70 +
.../components/settings/settings-sidebar.tsx | 171 +
.../settings/settings-unavailable.tsx | 28 +
.../standalone-settings-shell.test.ts | 35 +
.../settings/standalone-settings-shell.tsx | 119 +
.../settings/use-settings-before-unload.ts | 18 +
.../settings/use-settings-unsaved-guard.ts | 56 +
.../settings/workspace-settings-renderer.tsx | 89 +
.../components/access-control.tsx | 22 +-
.../ee/audit-logs/components/audit-logs.tsx | 9 +-
.../ee/audit-logs/hooks/audit-logs.test.tsx | 133 +
apps/sim/ee/audit-logs/hooks/audit-logs.ts | 16 +-
.../components/custom-blocks.tsx | 20 +-
.../components/data-drains-settings.tsx | 47 +-
.../components/data-retention-settings.tsx | 37 +-
.../ee/sso/components/sso-settings.test.tsx | 177 +
apps/sim/ee/sso/components/sso-settings.tsx | 74 +-
apps/sim/ee/sso/hooks/sso.test.tsx | 122 +
apps/sim/ee/sso/hooks/sso.ts | 3 +-
.../components/branding-provider.test.tsx | 101 +
.../components/branding-provider.tsx | 18 +-
.../components/whitelabeling-settings.tsx | 51 +-
.../ee/workspace-forking/components/forks.tsx | 2 +-
.../execution/snapshot-serializer.test.ts | 26 +
.../executor/execution/snapshot-serializer.ts | 1 +
apps/sim/executor/execution/types.ts | 3 +
.../mothership/mothership-handler.test.ts | 25 +-
.../handlers/mothership/mothership-handler.ts | 10 +
apps/sim/executor/types.ts | 4 +
apps/sim/hooks/queries/api-keys.test.ts | 50 +
apps/sim/hooks/queries/api-keys.ts | 49 +-
apps/sim/hooks/queries/organization.test.tsx | 196 +
apps/sim/hooks/queries/organization.ts | 19 +-
apps/sim/hooks/queries/resume-execution.ts | 1 +
apps/sim/hooks/queries/session.ts | 20 +-
apps/sim/hooks/queries/workspace-host.ts | 37 +
.../sim/hooks/queries/workspace-usage.test.ts | 68 +
apps/sim/hooks/queries/workspace-usage.ts | 58 +
.../sim/hooks/use-settings-navigation.test.ts | 97 +
apps/sim/hooks/use-settings-navigation.ts | 83 +-
.../lib/admin/dashboard-credit-grant.test.ts | 223 +
apps/sim/lib/admin/dashboard.ts | 902 +
.../lib/admin/external-collaborators.test.ts | 107 +
apps/sim/lib/admin/external-collaborators.ts | 74 +
.../lib/admin/organization-economics.test.ts | 54 +
apps/sim/lib/admin/organization-economics.ts | 48 +
apps/sim/lib/api/contracts/api-keys.ts | 24 +-
apps/sim/lib/api/contracts/audit-logs.ts | 2 +
apps/sim/lib/api/contracts/copilot.ts | 34 +-
.../lib/api/contracts/subscription.test.ts | 129 +
apps/sim/lib/api/contracts/subscription.ts | 52 +-
.../v1/admin/dashboard-workspaces.ts | 113 +
.../api/contracts/v1/admin/dashboard.test.ts | 19 +
.../lib/api/contracts/v1/admin/dashboard.ts | 315 +
.../lib/api/contracts/v1/admin/global-work.ts | 58 +
apps/sim/lib/api/contracts/v1/admin/index.ts | 3 +
apps/sim/lib/api/contracts/workflows.ts | 1 +
apps/sim/lib/api/contracts/workspaces.ts | 67 +
apps/sim/lib/auth/auth.ts | 5 +-
apps/sim/lib/auth/hybrid.test.ts | 115 +
apps/sim/lib/auth/hybrid.ts | 35 +-
apps/sim/lib/billing/authorization.test.ts | 57 +
apps/sim/lib/billing/authorization.ts | 21 +
.../calculations/usage-monitor.test.ts | 118 +-
.../lib/billing/calculations/usage-monitor.ts | 212 +-
.../calculations/usage-reservation.test.ts | 332 +-
.../billing/calculations/usage-reservation.ts | 589 +-
apps/sim/lib/billing/client/upgrade.ts | 40 +-
apps/sim/lib/billing/core/access.ts | 73 +-
apps/sim/lib/billing/core/api-access.test.ts | 49 +-
apps/sim/lib/billing/core/api-access.ts | 10 +-
.../core/billing-attribution-cache.test.ts | 209 +
.../billing/core/billing-attribution-cache.ts | 354 +
.../billing/core/billing-attribution.test.ts | 624 +
.../lib/billing/core/billing-attribution.ts | 605 +
apps/sim/lib/billing/core/billing.test.ts | 125 +
apps/sim/lib/billing/core/billing.ts | 160 +-
.../lib/billing/core/limit-notifications.ts | 16 +-
apps/sim/lib/billing/core/plan.ts | 6 +-
.../sim/lib/billing/core/subscription.test.ts | 105 +-
apps/sim/lib/billing/core/subscription.ts | 54 +-
apps/sim/lib/billing/core/usage-log.test.ts | 100 +-
apps/sim/lib/billing/core/usage-log.ts | 143 +-
apps/sim/lib/billing/core/usage.test.ts | 97 +-
apps/sim/lib/billing/core/usage.ts | 107 +-
.../lib/billing/core/workspace-access.test.ts | 91 +-
apps/sim/lib/billing/core/workspace-access.ts | 45 +-
.../billing/core/workspace-usage-gate.test.ts | 219 +
.../lib/billing/core/workspace-usage-gate.ts | 83 +
.../billing/enterprise-credit-limits.test.ts | 73 +
.../lib/billing/enterprise-credit-limits.ts | 40 +
.../sim/lib/billing/enterprise-outbox.test.ts | 258 +
apps/sim/lib/billing/enterprise-outbox.ts | 270 +
.../billing/enterprise-provisioning.test.ts | 519 +
.../lib/billing/enterprise-provisioning.ts | 1013 +
apps/sim/lib/billing/organization.test.ts | 133 +-
apps/sim/lib/billing/organization.ts | 278 +-
.../billing/organizations/lock-order.test.ts | 249 +-
.../organizations/member-limits.test.ts | 91 +-
.../billing/organizations/member-limits.ts | 58 +-
.../lib/billing/organizations/membership.ts | 870 +-
.../organizations/provision-seat.test.ts | 173 +-
.../billing/organizations/provision-seat.ts | 162 +-
.../billing/organizations/seat-drift.test.ts | 12 +
.../lib/billing/organizations/seat-drift.ts | 4 +-
.../lib/billing/organizations/seats.test.ts | 13 +
apps/sim/lib/billing/organizations/seats.ts | 4 +-
apps/sim/lib/billing/storage/context.test.ts | 64 +
apps/sim/lib/billing/storage/context.ts | 50 +
apps/sim/lib/billing/storage/entity.ts | 16 +
apps/sim/lib/billing/storage/index.ts | 13 +-
apps/sim/lib/billing/storage/limits.test.ts | 196 +
apps/sim/lib/billing/storage/limits.ts | 362 +-
apps/sim/lib/billing/storage/tracking.test.ts | 246 +
apps/sim/lib/billing/storage/tracking.ts | 219 +-
apps/sim/lib/billing/threshold-billing.ts | 31 +-
.../lib/billing/validation/seat-management.ts | 13 +-
.../enterprise-reconciliation-lease.test.ts | 110 +
.../enterprise-reconciliation-lease.ts | 147 +
apps/sim/lib/billing/webhooks/enterprise.ts | 491 +-
apps/sim/lib/billing/webhooks/idempotency.ts | 12 +-
.../billing/webhooks/outbox-handlers.test.ts | 24 +-
.../lib/billing/webhooks/outbox-handlers.ts | 10 +-
.../lib/billing/workspace-permissions.test.ts | 132 +
apps/sim/lib/billing/workspace-permissions.ts | 57 +
apps/sim/lib/copilot/chat/post.test.ts | 21 +
apps/sim/lib/copilot/chat/post.ts | 9 +-
.../copilot/generated/billing-protocol-v1.ts | 56 +
apps/sim/lib/copilot/generated/metrics-v1.ts | 8 +
.../lib/copilot/generated/tool-catalog-v1.ts | 634 +-
.../lib/copilot/generated/tool-schemas-v1.ts | 692 +-
.../generated/trace-attribute-values-v1.ts | 41 +-
.../copilot/generated/trace-attributes-v1.ts | 32 +
.../lib/copilot/request/lifecycle/run.test.ts | 227 +-
apps/sim/lib/copilot/request/lifecycle/run.ts | 142 +-
.../copilot/request/lifecycle/start.test.ts | 145 +-
.../lib/copilot/request/lifecycle/start.ts | 38 +-
.../request/session/explicit-abort.test.ts | 51 +
.../copilot/request/session/explicit-abort.ts | 5 +
.../lib/copilot/request/tools/billing.test.ts | 115 +
apps/sim/lib/copilot/request/tools/billing.ts | 17 +-
.../sim/lib/copilot/request/tools/executor.ts | 35 +-
.../request/tools/workflow-context.test.ts | 198 +
.../copilot/request/tools/workflow-context.ts | 183 +
apps/sim/lib/copilot/tool-executor/types.ts | 2 +
.../sim/lib/copilot/tools/handlers/context.ts | 23 +-
.../tools/handlers/workflow/mutations.test.ts | 527 +-
.../tools/handlers/workflow/mutations.ts | 106 +-
.../tools/registry/server-tool-adapter.ts | 1 +
.../sim/lib/copilot/tools/server/base-tool.ts | 2 +
.../server/knowledge/knowledge-base.test.ts | 194 +
.../tools/server/knowledge/knowledge-base.ts | 71 +-
apps/sim/lib/core/admission/gate.test.ts | 26 +
apps/sim/lib/core/admission/gate.ts | 10 +-
.../core/admission/transient-failure.test.ts | 104 +
.../lib/core/admission/transient-failure.ts | 117 +
apps/sim/lib/core/config/env-flags.ts | 8 +
apps/sim/lib/core/config/env.ts | 2 +
apps/sim/lib/core/idempotency/cleanup.test.ts | 65 +
apps/sim/lib/core/idempotency/cleanup.ts | 8 +-
apps/sim/lib/core/idempotency/service.ts | 28 +-
.../lib/core/idempotency/transaction.test.ts | 101 +
apps/sim/lib/core/idempotency/transaction.ts | 100 +
apps/sim/lib/core/outbox/service.test.ts | 40 +-
apps/sim/lib/core/outbox/service.ts | 66 +-
apps/sim/lib/execution/preprocessing.test.ts | 432 +-
apps/sim/lib/execution/preprocessing.ts | 404 +-
.../preprocessing.webhook-correlation.test.ts | 18 +-
apps/sim/lib/global-work/summary.test.ts | 190 +
apps/sim/lib/global-work/summary.ts | 346 +
.../lib/guardrails/validate_hallucination.ts | 7 +-
apps/sim/lib/invitations/core.test.ts | 362 +-
apps/sim/lib/invitations/core.ts | 550 +-
apps/sim/lib/invitations/locks.ts | 32 +
apps/sim/lib/invitations/send.ts | 21 +-
.../lib/invitations/workspace-invitations.ts | 1 -
.../lib/knowledge/connectors/queue.test.ts | 138 +
apps/sim/lib/knowledge/connectors/queue.ts | 157 +
.../lib/knowledge/connectors/sync-engine.ts | 114 +-
.../knowledge/documents/processing-payload.ts | 191 +
.../documents/processing-queue.test.ts | 130 +
apps/sim/lib/knowledge/documents/service.ts | 311 +-
.../documents/storage-billing.test.ts | 87 +
apps/sim/lib/knowledge/embeddings.test.ts | 115 +
apps/sim/lib/knowledge/embeddings.ts | 30 +-
apps/sim/lib/logs/execution/logger.test.ts | 158 +-
apps/sim/lib/logs/execution/logger.ts | 253 +-
.../logs/execution/logging-factory.test.ts | 59 +
.../sim/lib/logs/execution/logging-factory.ts | 61 +-
.../logs/execution/logging-session.test.ts | 66 +
.../sim/lib/logs/execution/logging-session.ts | 37 +-
apps/sim/lib/logs/types.ts | 6 +
apps/sim/lib/mcp/middleware.ts | 4 +-
apps/sim/lib/mothership/inbox/executor.ts | 26 +-
.../lib/organizations/settings-access.test.ts | 88 +
apps/sim/lib/organizations/settings-access.ts | 61 +
apps/sim/lib/posthog/events.ts | 1 +
.../lib/table/__tests__/update-row.test.ts | 17 +
apps/sim/lib/table/admission-retry.test.ts | 86 +
apps/sim/lib/table/admission-retry.ts | 54 +
apps/sim/lib/table/billing.test.ts | 55 +-
apps/sim/lib/table/billing.ts | 27 +-
apps/sim/lib/table/cell-write.test.ts | 265 +
apps/sim/lib/table/cell-write.ts | 253 +-
apps/sim/lib/table/dispatcher.ts | 36 +-
apps/sim/lib/table/rows/executions.test.ts | 83 +
apps/sim/lib/table/rows/executions.ts | 33 +-
apps/sim/lib/table/rows/service.ts | 22 +-
apps/sim/lib/table/types.ts | 17 +-
apps/sim/lib/table/workflow-columns.test.ts | 117 +-
apps/sim/lib/table/workflow-columns.ts | 48 +-
.../lib/uploads/client/api-fallback.test.ts | 83 +
apps/sim/lib/uploads/client/api-fallback.ts | 143 +-
.../workspace/workspace-file-manager.ts | 28 +-
.../workspace-file-storage-billing.test.ts | 109 +
apps/sim/lib/webhooks/processor.test.ts | 271 +-
apps/sim/lib/webhooks/processor.ts | 198 +-
.../executor/execute-workflow.test.ts | 170 +
.../workflows/executor/execute-workflow.ts | 18 +
.../lib/workflows/executor/execution-core.ts | 2 +
.../executor/execution-id-claim.test.ts | 83 +
.../workflows/executor/execution-id-claim.ts | 93 +
.../human-in-the-loop-manager.test.ts | 293 +-
.../executor/human-in-the-loop-manager.ts | 282 +-
.../executor/paused-execution-metadata.ts | 98 +
.../executor/paused-execution-policy.ts | 5 +
.../lib/workflows/executor/resume-policy.ts | 26 +
.../sim/lib/workflows/schedules/retry.test.ts | 40 +
apps/sim/lib/workflows/schedules/retry.ts | 20 +
apps/sim/lib/workspaces/admin-move.test.ts | 183 +
apps/sim/lib/workspaces/admin-move.ts | 906 +
apps/sim/lib/workspaces/host-context.test.ts | 149 +
apps/sim/lib/workspaces/host-context.ts | 53 +
.../invitation-migration-plan.test.ts | 67 +
.../workspaces/invitation-migration-plan.ts | 59 +
.../organization-workspaces.test.ts | 133 +-
.../lib/workspaces/organization-workspaces.ts | 357 +-
.../lib/workspaces/permissions/utils.test.ts | 16 +
apps/sim/lib/workspaces/permissions/utils.ts | 47 +-
apps/sim/providers/meta/index.ts | 10 +-
...ling-attribution-cutover-inventory.test.ts | 308 +
.../billing-attribution-cutover-inventory.ts | 741 +
apps/sim/tools/index.test.ts | 19 +
apps/sim/tools/index.ts | 37 +
package.json | 2 +
.../0258_admin_dashboard_support.sql | 13 +
.../db/migrations/meta/0258_snapshot.json | 16801 ++++++++++++++++
packages/db/migrations/meta/_journal.json | 7 +
packages/db/schema.ts | 13 +
packages/testing/src/mocks/schema.mock.ts | 13 +
scripts/check-api-validation-contracts.ts | 4 +-
scripts/generate-mship-contracts.ts | 18 +-
scripts/sync-billing-protocol-contract.ts | 273 +
464 files changed, 59411 insertions(+), 7608 deletions(-)
create mode 100644 apps/sim/app/account/settings/[section]/page.tsx
create mode 100644 apps/sim/app/account/settings/billing/credit-usage/page.test.ts
create mode 100644 apps/sim/app/account/settings/billing/credit-usage/page.tsx
create mode 100644 apps/sim/app/account/settings/layout.tsx
create mode 100644 apps/sim/app/account/settings/page.tsx
create mode 100644 apps/sim/app/api/billing/route.test.ts
create mode 100644 apps/sim/app/api/invitations/[id]/accept/route.test.ts
create mode 100644 apps/sim/app/api/knowledge/[id]/connectors/route.test.ts
create mode 100644 apps/sim/app/api/organizations/[id]/roster/route.test.ts
create mode 100644 apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts
create mode 100644 apps/sim/app/api/resume/poll/route.test.ts
create mode 100644 apps/sim/app/api/usage/route.test.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/actor.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/[id]/retry/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/global-work/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/credits/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/external-collaborators/[userId]/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/limits/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/[memberId]/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/seats/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/[id]/transfer-ownership/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/organizations/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/users/[id]/credits/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/users/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/move/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/preflight/route.ts
create mode 100644 apps/sim/app/api/v1/admin/dashboard/workspaces/route.ts
create mode 100644 apps/sim/app/api/v1/audit-logs/auth.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/credit-availability/route.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/host-context/route.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/host-context/route.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/usage-gate/route.ts
create mode 100644 apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx
create mode 100644 apps/sim/app/organization/[organizationId]/settings/layout.tsx
create mode 100644 apps/sim/app/organization/[organizationId]/settings/page.tsx
create mode 100644 apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/layout.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx
delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx
delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.test.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx
create mode 100644 apps/sim/background/knowledge-connector-sync.test.ts
create mode 100644 apps/sim/background/knowledge-processing.test.ts
create mode 100644 apps/sim/components/settings/account-settings-renderer.tsx
create mode 100644 apps/sim/components/settings/navigation.test.ts
create mode 100644 apps/sim/components/settings/navigation.ts
create mode 100644 apps/sim/components/settings/organization-settings-renderer.tsx
create mode 100644 apps/sim/components/settings/settings-header.tsx
create mode 100644 apps/sim/components/settings/settings-panel.tsx
create mode 100644 apps/sim/components/settings/settings-sidebar.tsx
create mode 100644 apps/sim/components/settings/settings-unavailable.tsx
create mode 100644 apps/sim/components/settings/standalone-settings-shell.test.ts
create mode 100644 apps/sim/components/settings/standalone-settings-shell.tsx
create mode 100644 apps/sim/components/settings/use-settings-before-unload.ts
create mode 100644 apps/sim/components/settings/use-settings-unsaved-guard.ts
create mode 100644 apps/sim/components/settings/workspace-settings-renderer.tsx
create mode 100644 apps/sim/ee/audit-logs/hooks/audit-logs.test.tsx
create mode 100644 apps/sim/ee/sso/components/sso-settings.test.tsx
create mode 100644 apps/sim/ee/sso/hooks/sso.test.tsx
create mode 100644 apps/sim/ee/whitelabeling/components/branding-provider.test.tsx
create mode 100644 apps/sim/hooks/queries/api-keys.test.ts
create mode 100644 apps/sim/hooks/queries/organization.test.tsx
create mode 100644 apps/sim/hooks/queries/workspace-host.ts
create mode 100644 apps/sim/hooks/queries/workspace-usage.test.ts
create mode 100644 apps/sim/hooks/queries/workspace-usage.ts
create mode 100644 apps/sim/hooks/use-settings-navigation.test.ts
create mode 100644 apps/sim/lib/admin/dashboard-credit-grant.test.ts
create mode 100644 apps/sim/lib/admin/dashboard.ts
create mode 100644 apps/sim/lib/admin/external-collaborators.test.ts
create mode 100644 apps/sim/lib/admin/external-collaborators.ts
create mode 100644 apps/sim/lib/admin/organization-economics.test.ts
create mode 100644 apps/sim/lib/admin/organization-economics.ts
create mode 100644 apps/sim/lib/api/contracts/subscription.test.ts
create mode 100644 apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts
create mode 100644 apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts
create mode 100644 apps/sim/lib/api/contracts/v1/admin/dashboard.ts
create mode 100644 apps/sim/lib/api/contracts/v1/admin/global-work.ts
create mode 100644 apps/sim/lib/auth/hybrid.test.ts
create mode 100644 apps/sim/lib/billing/authorization.test.ts
create mode 100644 apps/sim/lib/billing/core/billing-attribution-cache.test.ts
create mode 100644 apps/sim/lib/billing/core/billing-attribution-cache.ts
create mode 100644 apps/sim/lib/billing/core/billing-attribution.test.ts
create mode 100644 apps/sim/lib/billing/core/billing-attribution.ts
create mode 100644 apps/sim/lib/billing/core/billing.test.ts
create mode 100644 apps/sim/lib/billing/core/workspace-usage-gate.test.ts
create mode 100644 apps/sim/lib/billing/core/workspace-usage-gate.ts
create mode 100644 apps/sim/lib/billing/enterprise-credit-limits.test.ts
create mode 100644 apps/sim/lib/billing/enterprise-credit-limits.ts
create mode 100644 apps/sim/lib/billing/enterprise-outbox.test.ts
create mode 100644 apps/sim/lib/billing/enterprise-outbox.ts
create mode 100644 apps/sim/lib/billing/enterprise-provisioning.test.ts
create mode 100644 apps/sim/lib/billing/enterprise-provisioning.ts
create mode 100644 apps/sim/lib/billing/storage/context.test.ts
create mode 100644 apps/sim/lib/billing/storage/context.ts
create mode 100644 apps/sim/lib/billing/storage/entity.ts
create mode 100644 apps/sim/lib/billing/storage/limits.test.ts
create mode 100644 apps/sim/lib/billing/storage/tracking.test.ts
create mode 100644 apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.test.ts
create mode 100644 apps/sim/lib/billing/webhooks/enterprise-reconciliation-lease.ts
create mode 100644 apps/sim/lib/billing/workspace-permissions.test.ts
create mode 100644 apps/sim/lib/billing/workspace-permissions.ts
create mode 100644 apps/sim/lib/copilot/generated/billing-protocol-v1.ts
create mode 100644 apps/sim/lib/copilot/request/session/explicit-abort.test.ts
create mode 100644 apps/sim/lib/copilot/request/tools/billing.test.ts
create mode 100644 apps/sim/lib/copilot/request/tools/workflow-context.test.ts
create mode 100644 apps/sim/lib/copilot/request/tools/workflow-context.ts
create mode 100644 apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts
create mode 100644 apps/sim/lib/core/admission/gate.test.ts
create mode 100644 apps/sim/lib/core/admission/transient-failure.test.ts
create mode 100644 apps/sim/lib/core/admission/transient-failure.ts
create mode 100644 apps/sim/lib/core/idempotency/cleanup.test.ts
create mode 100644 apps/sim/lib/core/idempotency/transaction.test.ts
create mode 100644 apps/sim/lib/core/idempotency/transaction.ts
create mode 100644 apps/sim/lib/global-work/summary.test.ts
create mode 100644 apps/sim/lib/global-work/summary.ts
create mode 100644 apps/sim/lib/invitations/locks.ts
create mode 100644 apps/sim/lib/knowledge/connectors/queue.test.ts
create mode 100644 apps/sim/lib/knowledge/connectors/queue.ts
create mode 100644 apps/sim/lib/knowledge/documents/processing-payload.ts
create mode 100644 apps/sim/lib/knowledge/documents/processing-queue.test.ts
create mode 100644 apps/sim/lib/knowledge/documents/storage-billing.test.ts
create mode 100644 apps/sim/lib/knowledge/embeddings.test.ts
create mode 100644 apps/sim/lib/organizations/settings-access.test.ts
create mode 100644 apps/sim/lib/organizations/settings-access.ts
create mode 100644 apps/sim/lib/table/admission-retry.test.ts
create mode 100644 apps/sim/lib/table/admission-retry.ts
create mode 100644 apps/sim/lib/table/cell-write.test.ts
create mode 100644 apps/sim/lib/table/rows/executions.test.ts
create mode 100644 apps/sim/lib/uploads/client/api-fallback.test.ts
create mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts
create mode 100644 apps/sim/lib/workflows/executor/execute-workflow.test.ts
create mode 100644 apps/sim/lib/workflows/executor/execution-id-claim.test.ts
create mode 100644 apps/sim/lib/workflows/executor/execution-id-claim.ts
create mode 100644 apps/sim/lib/workflows/executor/paused-execution-metadata.ts
create mode 100644 apps/sim/lib/workflows/executor/paused-execution-policy.ts
create mode 100644 apps/sim/lib/workflows/executor/resume-policy.ts
create mode 100644 apps/sim/lib/workflows/schedules/retry.test.ts
create mode 100644 apps/sim/lib/workflows/schedules/retry.ts
create mode 100644 apps/sim/lib/workspaces/admin-move.test.ts
create mode 100644 apps/sim/lib/workspaces/admin-move.ts
create mode 100644 apps/sim/lib/workspaces/host-context.test.ts
create mode 100644 apps/sim/lib/workspaces/host-context.ts
create mode 100644 apps/sim/lib/workspaces/invitation-migration-plan.test.ts
create mode 100644 apps/sim/lib/workspaces/invitation-migration-plan.ts
create mode 100644 apps/sim/scripts/billing-attribution-cutover-inventory.test.ts
create mode 100644 apps/sim/scripts/billing-attribution-cutover-inventory.ts
create mode 100644 packages/db/migrations/0258_admin_dashboard_support.sql
create mode 100644 packages/db/migrations/meta/0258_snapshot.json
create mode 100644 scripts/sync-billing-protocol-contract.ts
diff --git a/apps/docs/content/docs/de/api-reference/authentication.mdx b/apps/docs/content/docs/de/api-reference/authentication.mdx
index f6df71865ac..61ddd59a034 100644
--- a/apps/docs/content/docs/de/api-reference/authentication.mdx
+++ b/apps/docs/content/docs/de/api-reference/authentication.mdx
@@ -12,18 +12,23 @@ To access the Sim API, you need an API key. Sim supports two types of API keys
| | **Personal Keys** | **Workspace Keys** |
| --- | --- | --- |
-| **Billed to** | Your individual account | Workspace owner |
+| **Billing** | Workspace payer for workspace-hosted usage | Workspace payer |
| **Scope** | Across workspaces you have access to | Shared across the workspace |
| **Managed by** | Each user individually | Workspace admins |
| **Permissions** | Must be enabled at workspace level | Require admin permissions |
- Workspace admins can disable personal API key usage for their workspace. If disabled, only workspace keys can be used.
+ Personal keys identify the user making a request; they do not select who pays.
+ Hosted usage is billed to the workspace's organization or personal billing
+ account and, for organizations, is attributed to the actor's member cap.
+ Workspace admins can disable personal API key usage for their workspace. If
+ disabled, only workspace keys can be used.
## Generating API Keys
-To generate a key, open the Sim dashboard and navigate to **Settings**, then go to **Sim Keys** and click **Create**.
+To generate a personal key, open **Account settings** → **Sim API keys**. Workspace
+administrators can create shared keys from **Workspace settings** → **Sim API keys**.
API keys are only shown once when generated. Store your key securely — you will not be able to view it again.
diff --git a/apps/docs/content/docs/en/api-reference/authentication.mdx b/apps/docs/content/docs/en/api-reference/authentication.mdx
index f6df71865ac..61ddd59a034 100644
--- a/apps/docs/content/docs/en/api-reference/authentication.mdx
+++ b/apps/docs/content/docs/en/api-reference/authentication.mdx
@@ -12,18 +12,23 @@ To access the Sim API, you need an API key. Sim supports two types of API keys
| | **Personal Keys** | **Workspace Keys** |
| --- | --- | --- |
-| **Billed to** | Your individual account | Workspace owner |
+| **Billing** | Workspace payer for workspace-hosted usage | Workspace payer |
| **Scope** | Across workspaces you have access to | Shared across the workspace |
| **Managed by** | Each user individually | Workspace admins |
| **Permissions** | Must be enabled at workspace level | Require admin permissions |
- Workspace admins can disable personal API key usage for their workspace. If disabled, only workspace keys can be used.
+ Personal keys identify the user making a request; they do not select who pays.
+ Hosted usage is billed to the workspace's organization or personal billing
+ account and, for organizations, is attributed to the actor's member cap.
+ Workspace admins can disable personal API key usage for their workspace. If
+ disabled, only workspace keys can be used.
## Generating API Keys
-To generate a key, open the Sim dashboard and navigate to **Settings**, then go to **Sim Keys** and click **Create**.
+To generate a personal key, open **Account settings** → **Sim API keys**. Workspace
+administrators can create shared keys from **Workspace settings** → **Sim API keys**.
API keys are only shown once when generated. Store your key securely — you will not be able to view it again.
diff --git a/apps/docs/content/docs/en/platform/costs.mdx b/apps/docs/content/docs/en/platform/costs.mdx
index 934cc91fd8f..ac52de4ecad 100644
--- a/apps/docs/content/docs/en/platform/costs.mdx
+++ b/apps/docs/content/docs/en/platform/costs.mdx
@@ -305,7 +305,9 @@ By default, your usage is capped at the credits included in your plan. To allow
- **Disable On-Demand**: Resets your usage limit back to the plan's included amount (only available if your current usage hasn't already exceeded it).
- On-demand billing is managed by workspace admins for team plans. Non-admin team members cannot toggle on-demand billing.
+ On-demand billing for team plans is managed by organization owners and
+ administrators. Workspace admin permission alone does not grant billing
+ authority.
## Plan Limits
@@ -371,7 +373,8 @@ Sim uses a **base subscription + overage** billing model:
**Team Plans:**
- Usage is pooled across internal team members in the organization
-- External workspace members keep their own organization or personal billing context for runs where they are the billing actor
+- Usage in an organization-owned workspace is billed to that workspace's organization, including work performed by external collaborators
+- Each authenticated actor's usage is also counted toward their member cap for that organization
- Overage is calculated from total team usage against the pooled limit
- Organization owner receives one bill
diff --git a/apps/docs/content/docs/es/api-reference/authentication.mdx b/apps/docs/content/docs/es/api-reference/authentication.mdx
index f6df71865ac..61ddd59a034 100644
--- a/apps/docs/content/docs/es/api-reference/authentication.mdx
+++ b/apps/docs/content/docs/es/api-reference/authentication.mdx
@@ -12,18 +12,23 @@ To access the Sim API, you need an API key. Sim supports two types of API keys
| | **Personal Keys** | **Workspace Keys** |
| --- | --- | --- |
-| **Billed to** | Your individual account | Workspace owner |
+| **Billing** | Workspace payer for workspace-hosted usage | Workspace payer |
| **Scope** | Across workspaces you have access to | Shared across the workspace |
| **Managed by** | Each user individually | Workspace admins |
| **Permissions** | Must be enabled at workspace level | Require admin permissions |
- Workspace admins can disable personal API key usage for their workspace. If disabled, only workspace keys can be used.
+ Personal keys identify the user making a request; they do not select who pays.
+ Hosted usage is billed to the workspace's organization or personal billing
+ account and, for organizations, is attributed to the actor's member cap.
+ Workspace admins can disable personal API key usage for their workspace. If
+ disabled, only workspace keys can be used.
## Generating API Keys
-To generate a key, open the Sim dashboard and navigate to **Settings**, then go to **Sim Keys** and click **Create**.
+To generate a personal key, open **Account settings** → **Sim API keys**. Workspace
+administrators can create shared keys from **Workspace settings** → **Sim API keys**.
API keys are only shown once when generated. Store your key securely — you will not be able to view it again.
diff --git a/apps/docs/content/docs/fr/api-reference/authentication.mdx b/apps/docs/content/docs/fr/api-reference/authentication.mdx
index f6df71865ac..61ddd59a034 100644
--- a/apps/docs/content/docs/fr/api-reference/authentication.mdx
+++ b/apps/docs/content/docs/fr/api-reference/authentication.mdx
@@ -12,18 +12,23 @@ To access the Sim API, you need an API key. Sim supports two types of API keys
| | **Personal Keys** | **Workspace Keys** |
| --- | --- | --- |
-| **Billed to** | Your individual account | Workspace owner |
+| **Billing** | Workspace payer for workspace-hosted usage | Workspace payer |
| **Scope** | Across workspaces you have access to | Shared across the workspace |
| **Managed by** | Each user individually | Workspace admins |
| **Permissions** | Must be enabled at workspace level | Require admin permissions |
- Workspace admins can disable personal API key usage for their workspace. If disabled, only workspace keys can be used.
+ Personal keys identify the user making a request; they do not select who pays.
+ Hosted usage is billed to the workspace's organization or personal billing
+ account and, for organizations, is attributed to the actor's member cap.
+ Workspace admins can disable personal API key usage for their workspace. If
+ disabled, only workspace keys can be used.
## Generating API Keys
-To generate a key, open the Sim dashboard and navigate to **Settings**, then go to **Sim Keys** and click **Create**.
+To generate a personal key, open **Account settings** → **Sim API keys**. Workspace
+administrators can create shared keys from **Workspace settings** → **Sim API keys**.
API keys are only shown once when generated. Store your key securely — you will not be able to view it again.
diff --git a/apps/docs/content/docs/ja/api-reference/authentication.mdx b/apps/docs/content/docs/ja/api-reference/authentication.mdx
index f6df71865ac..61ddd59a034 100644
--- a/apps/docs/content/docs/ja/api-reference/authentication.mdx
+++ b/apps/docs/content/docs/ja/api-reference/authentication.mdx
@@ -12,18 +12,23 @@ To access the Sim API, you need an API key. Sim supports two types of API keys
| | **Personal Keys** | **Workspace Keys** |
| --- | --- | --- |
-| **Billed to** | Your individual account | Workspace owner |
+| **Billing** | Workspace payer for workspace-hosted usage | Workspace payer |
| **Scope** | Across workspaces you have access to | Shared across the workspace |
| **Managed by** | Each user individually | Workspace admins |
| **Permissions** | Must be enabled at workspace level | Require admin permissions |
- Workspace admins can disable personal API key usage for their workspace. If disabled, only workspace keys can be used.
+ Personal keys identify the user making a request; they do not select who pays.
+ Hosted usage is billed to the workspace's organization or personal billing
+ account and, for organizations, is attributed to the actor's member cap.
+ Workspace admins can disable personal API key usage for their workspace. If
+ disabled, only workspace keys can be used.
## Generating API Keys
-To generate a key, open the Sim dashboard and navigate to **Settings**, then go to **Sim Keys** and click **Create**.
+To generate a personal key, open **Account settings** → **Sim API keys**. Workspace
+administrators can create shared keys from **Workspace settings** → **Sim API keys**.
API keys are only shown once when generated. Store your key securely — you will not be able to view it again.
diff --git a/apps/docs/content/docs/zh/api-reference/authentication.mdx b/apps/docs/content/docs/zh/api-reference/authentication.mdx
index d39185009e5..61ddd59a034 100644
--- a/apps/docs/content/docs/zh/api-reference/authentication.mdx
+++ b/apps/docs/content/docs/zh/api-reference/authentication.mdx
@@ -12,18 +12,23 @@ To access the Sim API, you need an API key. Sim supports two types of API keys
| | **Personal Keys** | **Workspace Keys** |
| --- | --- | --- |
-| **Billed to** | Your individual account | Workspace owner |
+| **Billing** | Workspace payer for workspace-hosted usage | Workspace payer |
| **Scope** | Across workspaces you have access to | Shared across the workspace |
| **Managed by** | Each user individually | Workspace admins |
| **Permissions** | Must be enabled at workspace level | Require admin permissions |
- Workspace admins can disable personal API key usage for their workspace. If disabled, only workspace keys can be used.
+ Personal keys identify the user making a request; they do not select who pays.
+ Hosted usage is billed to the workspace's organization or personal billing
+ account and, for organizations, is attributed to the actor's member cap.
+ Workspace admins can disable personal API key usage for their workspace. If
+ disabled, only workspace keys can be used.
## Generating API Keys
-To generate a key, open the Sim platform and navigate to **Settings**, then go to **Sim Keys** and click **Create**.
+To generate a personal key, open **Account settings** → **Sim API keys**. Workspace
+administrators can create shared keys from **Workspace settings** → **Sim API keys**.
API keys are only shown once when generated. Store your key securely — you will not be able to view it again.
diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx
index 88a7cf3edae..51de190af68 100644
--- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx
+++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx
@@ -819,6 +819,18 @@ export default function ResumeExecutionPage({
+ {selectedDetail.pausePoint.automaticResumeWaitingReason && (
+
+
Waiting to resume automatically
+
+ {selectedDetail.pausePoint.automaticResumeWaitingReason}
+
+
+ Sim will retry automatically.
+
+
+ )}
+
{/* Already resolved - show form fields with submitted values */}
{selectedStatus === 'resumed' || selectedStatus === 'failed' ? (
diff --git a/apps/sim/app/_shell/providers/session-provider.test.tsx b/apps/sim/app/_shell/providers/session-provider.test.tsx
index 4b0a2b68b88..a7831dd0201 100644
--- a/apps/sim/app/_shell/providers/session-provider.test.tsx
+++ b/apps/sim/app/_shell/providers/session-provider.test.tsx
@@ -6,23 +6,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockGetSession, mockSetActive, mockRequestJson } = vi.hoisted(() => ({
+const { mockGetSession, mockListOrganizations, mockSetActive } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
+ mockListOrganizations: vi.fn(),
mockSetActive: vi.fn(),
- mockRequestJson: vi.fn(),
}))
vi.mock('@/lib/auth/auth-client', () => ({
client: {
getSession: mockGetSession,
- organization: { setActive: mockSetActive },
+ organization: {
+ list: mockListOrganizations,
+ setActive: mockSetActive,
+ },
},
}))
-vi.mock('@/lib/api/client/request', () => ({
- requestJson: mockRequestJson,
-}))
-
vi.mock('posthog-js', () => ({
default: {
identify: vi.fn(),
@@ -66,6 +65,16 @@ const FRESH_SESSION: AppSession = {
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
}
+const NO_ACTIVE_ORGANIZATION_SESSION: AppSession = {
+ user: { id: 'user-1', email: 'u@x.com', name: 'No active organization' },
+ session: { id: 's1', userId: 'user-1' },
+}
+
+const RECOVERED_ORGANIZATION_SESSION: AppSession = {
+ user: { id: 'user-1', email: 'u@x.com', name: 'Recovered organization' },
+ session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-member' },
+}
+
interface Harness {
ctx: () => SessionHookResult | null
queryClient: QueryClient
@@ -152,6 +161,8 @@ describe('useSessionQuery', () => {
describe('SessionProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockListOrganizations.mockResolvedValue({ data: [], error: null })
+ mockSetActive.mockResolvedValue({ data: null, error: null })
setSearch('')
})
@@ -179,6 +190,85 @@ describe('SessionProvider', () => {
h.unmount()
})
+ it('preserves an intentional no-active-organization state on a normal load', async () => {
+ mockGetSession.mockResolvedValue({ data: NO_ACTIVE_ORGANIZATION_SESSION })
+ mockListOrganizations.mockResolvedValue({
+ data: [{ id: 'org-member', name: 'Member organization' }],
+ error: null,
+ })
+
+ const h = renderProvider()
+ await flushUntil(() => h.ctx()?.data != null)
+
+ expect(h.ctx()?.data).toEqual(NO_ACTIVE_ORGANIZATION_SESSION)
+ expect(mockListOrganizations).not.toHaveBeenCalled()
+ expect(mockSetActive).not.toHaveBeenCalled()
+
+ h.unmount()
+ })
+
+ it('does not auto-select an organization for an external-only user during recovery', async () => {
+ setSearch('?upgraded=true')
+ mockGetSession.mockResolvedValue({ data: NO_ACTIVE_ORGANIZATION_SESSION })
+ mockListOrganizations.mockResolvedValue({ data: [], error: null })
+
+ const h = renderProvider()
+ await flushUntil(() => mockListOrganizations.mock.calls.length > 0)
+
+ expect(mockListOrganizations).toHaveBeenCalledTimes(1)
+ expect(mockSetActive).not.toHaveBeenCalled()
+
+ h.unmount()
+ })
+
+ it('recovers the sole valid viewer organization membership when upgrade recovery is explicit', async () => {
+ window.history.replaceState({}, '', '/workspace/workspace-b?upgraded=true')
+ mockListOrganizations.mockResolvedValue({
+ data: [{ id: 'org-member', name: 'Member organization' }],
+ error: null,
+ })
+ mockGetSession.mockImplementation(() =>
+ Promise.resolve({
+ data:
+ mockSetActive.mock.calls.length > 0
+ ? RECOVERED_ORGANIZATION_SESSION
+ : NO_ACTIVE_ORGANIZATION_SESSION,
+ })
+ )
+
+ const h = renderProvider()
+ await flushUntil(
+ () =>
+ h.queryClient.getQueryData
(sessionKeys.detail())?.session
+ ?.activeOrganizationId === 'org-member'
+ )
+
+ expect(mockSetActive).toHaveBeenCalledWith({ organizationId: 'org-member' })
+ expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(RECOVERED_ORGANIZATION_SESSION)
+
+ h.unmount()
+ })
+
+ it('does not choose arbitrarily when multiple valid memberships need recovery', async () => {
+ setSearch('?upgraded=true')
+ mockGetSession.mockResolvedValue({ data: NO_ACTIVE_ORGANIZATION_SESSION })
+ mockListOrganizations.mockResolvedValue({
+ data: [
+ { id: 'org-a', name: 'Organization A' },
+ { id: 'org-b', name: 'Organization B' },
+ ],
+ error: null,
+ })
+
+ const h = renderProvider()
+ await flushUntil(() => mockListOrganizations.mock.calls.length > 0)
+
+ expect(mockListOrganizations).toHaveBeenCalledTimes(1)
+ expect(mockSetActive).not.toHaveBeenCalled()
+
+ h.unmount()
+ })
+
it('upgrade path: fresh disableCookieCache read wins even when the stale mount query resolves LAST', async () => {
setSearch('?upgraded=true')
diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx
index d94c07e48bd..7c56fbf2e7b 100644
--- a/apps/sim/app/_shell/providers/session-provider.tsx
+++ b/apps/sim/app/_shell/providers/session-provider.tsx
@@ -4,8 +4,6 @@ import type React from 'react'
import { createContext, useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
-import { requestJson } from '@/lib/api/client/request'
-import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
import { client } from '@/lib/auth/auth-client'
import {
type AppSession,
@@ -79,10 +77,9 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}
try {
- const orgData = await requestJson(listCreatorOrganizationsContract, {}).catch(() => null)
- if (!orgData) return
-
- const organizationId = orgData.organizations?.[0]?.id
+ const organizationsResponse = await client.organization.list()
+ const organizations = organizationsResponse.data ?? []
+ const organizationId = organizations.length === 1 ? organizations[0]?.id : null
if (!organizationId || isCancelled) {
return
diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx
new file mode 100644
index 00000000000..4d883e9ee3a
--- /dev/null
+++ b/apps/sim/app/account/settings/[section]/page.tsx
@@ -0,0 +1,54 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
+import {
+ ACCOUNT_SETTINGS_ITEMS,
+ ACCOUNT_SETTINGS_PATH_ALIASES,
+ getAccountSettingsHref,
+ getSettingsSectionMeta,
+ parseSettingsPathSection,
+} from '@/components/settings/navigation'
+import { getSession } from '@/lib/auth'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isPlatformAdmin } from '@/lib/permissions/super-user'
+
+interface AccountSettingsSectionPageProps {
+ params: Promise<{ section: string }>
+}
+
+export async function generateMetadata({
+ params,
+}: AccountSettingsSectionPageProps): Promise {
+ const { section } = await params
+ const parsed = parseSettingsPathSection({
+ path: section,
+ items: ACCOUNT_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: ACCOUNT_SETTINGS_PATH_ALIASES,
+ })
+ const meta = parsed ? getSettingsSectionMeta('account', parsed) : null
+ return { title: meta ? `${meta.label} - Account settings` : 'Account settings' }
+}
+
+export default async function AccountSettingsSectionPage({
+ params,
+}: AccountSettingsSectionPageProps) {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+
+ const { section } = await params
+ const parsed = parseSettingsPathSection({
+ path: section,
+ items: ACCOUNT_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: ACCOUNT_SETTINGS_PATH_ALIASES,
+ })
+ if (!parsed) notFound()
+ if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general'))
+ if (parsed === 'admin' || parsed === 'mothership') {
+ const isSuperUser = await isPlatformAdmin(session.user.id)
+ if (!isSuperUser) notFound()
+ }
+
+ return
+}
diff --git a/apps/sim/app/account/settings/billing/credit-usage/page.test.ts b/apps/sim/app/account/settings/billing/credit-usage/page.test.ts
new file mode 100644
index 00000000000..fdccd046247
--- /dev/null
+++ b/apps/sim/app/account/settings/billing/credit-usage/page.test.ts
@@ -0,0 +1,55 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetPersonalSubscription, mockGetSession, mockIsEnterprise, mockRedirect } = vi.hoisted(
+ () => ({
+ mockGetPersonalSubscription: vi.fn(),
+ mockGetSession: vi.fn(),
+ mockIsEnterprise: vi.fn(),
+ mockRedirect: vi.fn(),
+ })
+)
+
+vi.mock('next/navigation', () => ({
+ redirect: mockRedirect,
+}))
+
+vi.mock('@/lib/auth', () => ({
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/billing/core/plan', () => ({
+ getHighestPriorityPersonalSubscription: mockGetPersonalSubscription,
+}))
+
+vi.mock('@/lib/billing/plan-helpers', () => ({
+ isEnterprise: mockIsEnterprise,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view', () => ({
+ CreditUsageView: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading', () => ({
+ default: () => null,
+}))
+
+import AccountCreditUsagePage from '@/app/account/settings/billing/credit-usage/page'
+
+describe('AccountCreditUsagePage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } })
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_6000' })
+ mockIsEnterprise.mockReturnValue(false)
+ })
+
+ it('checks only the viewer personal subscription', async () => {
+ await AccountCreditUsagePage()
+
+ expect(mockGetPersonalSubscription).toHaveBeenCalledWith('viewer-a')
+ expect(mockRedirect).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/account/settings/billing/credit-usage/page.tsx b/apps/sim/app/account/settings/billing/credit-usage/page.tsx
new file mode 100644
index 00000000000..a9c04b9febd
--- /dev/null
+++ b/apps/sim/app/account/settings/billing/credit-usage/page.tsx
@@ -0,0 +1,27 @@
+import { Suspense } from 'react'
+import type { Metadata } from 'next'
+import { redirect } from 'next/navigation'
+import { getAccountSettingsHref } from '@/components/settings/navigation'
+import { getSession } from '@/lib/auth'
+import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
+import { isEnterprise } from '@/lib/billing/plan-helpers'
+import { CreditUsageView } from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view'
+import CreditUsageLoading from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading'
+
+export const metadata: Metadata = {
+ title: 'Credit usage - Account settings',
+}
+
+export default async function AccountCreditUsagePage() {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+
+ const subscription = await getHighestPriorityPersonalSubscription(session.user.id)
+ if (isEnterprise(subscription?.plan)) redirect(getAccountSettingsHref('billing'))
+
+ return (
+ }>
+
+
+ )
+}
diff --git a/apps/sim/app/account/settings/layout.tsx b/apps/sim/app/account/settings/layout.tsx
new file mode 100644
index 00000000000..cafd6d2f3c8
--- /dev/null
+++ b/apps/sim/app/account/settings/layout.tsx
@@ -0,0 +1,16 @@
+import { redirect } from 'next/navigation'
+import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
+import { getSession } from '@/lib/auth'
+import { isPlatformAdmin } from '@/lib/permissions/super-user'
+
+export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+ const isSuperUser = await isPlatformAdmin(session.user.id)
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/account/settings/page.tsx b/apps/sim/app/account/settings/page.tsx
new file mode 100644
index 00000000000..a5a4272a9e6
--- /dev/null
+++ b/apps/sim/app/account/settings/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from 'next/navigation'
+import { getAccountSettingsHref } from '@/components/settings/navigation'
+
+export default function AccountSettingsPage() {
+ redirect(getAccountSettingsHref('general'))
+}
diff --git a/apps/sim/app/api/audit-logs/export/route.test.ts b/apps/sim/app/api/audit-logs/export/route.test.ts
index 94072ebd42f..712d8db9e81 100644
--- a/apps/sim/app/api/audit-logs/export/route.test.ts
+++ b/apps/sim/app/api/audit-logs/export/route.test.ts
@@ -43,11 +43,13 @@ const MEMBER_IDS = ['admin-1']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
function makeRequest(query = '') {
+ const search = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query)
+ search.set('organizationId', ORG_ID)
return createMockRequest(
'GET',
undefined,
{},
- `http://localhost:3000/api/audit-logs/export${query}`
+ `http://localhost:3000/api/audit-logs/export?${search.toString()}`
)
}
@@ -105,6 +107,7 @@ describe('GET /api/audit-logs/export', () => {
const response = await GET(makeRequest())
expect(response.status).toBe(403)
+ expect(mockValidateEnterpriseAuditAccess).toHaveBeenCalledWith('admin-1', ORG_ID)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/audit-logs/export/route.ts b/apps/sim/app/api/audit-logs/export/route.ts
index b0238a0433c..089b1274811 100644
--- a/apps/sim/app/api/audit-logs/export/route.ts
+++ b/apps/sim/app/api/audit-logs/export/route.ts
@@ -43,13 +43,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const authResult = await validateEnterpriseAuditAccess(session.user.id)
- if (!authResult.success) {
- return authResult.response
- }
-
- const { organizationId, orgMemberIds } = authResult.context
-
const parsed = await parseRequest(
exportAuditLogsContract,
request,
@@ -64,6 +57,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
if (!parsed.success) return parsed.response
+ const authResult = await validateEnterpriseAuditAccess(
+ session.user.id,
+ parsed.data.query.organizationId
+ )
+ if (!authResult.success) {
+ return authResult.response
+ }
+
+ const { organizationId, orgMemberIds } = authResult.context
const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } =
parsed.data.query
diff --git a/apps/sim/app/api/audit-logs/route.ts b/apps/sim/app/api/audit-logs/route.ts
index 5f2a3522792..0b71d5ef7ef 100644
--- a/apps/sim/app/api/audit-logs/route.ts
+++ b/apps/sim/app/api/audit-logs/route.ts
@@ -25,13 +25,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const authResult = await validateEnterpriseAuditAccess(session.user.id)
- if (!authResult.success) {
- return authResult.response
- }
-
- const { organizationId, orgMemberIds } = authResult.context
-
const parsed = await parseRequest(
listAuditLogsContract,
request,
@@ -46,7 +39,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
if (!parsed.success) return parsed.response
+ const authResult = await validateEnterpriseAuditAccess(
+ session.user.id,
+ parsed.data.query.organizationId
+ )
+ if (!authResult.success) {
+ return authResult.response
+ }
+
+ const { organizationId, orgMemberIds } = authResult.context
+
const {
+ organizationId: _targetOrganizationId,
search,
action,
resourceType,
diff --git a/apps/sim/app/api/billing/route.test.ts b/apps/sim/app/api/billing/route.test.ts
new file mode 100644
index 00000000000..ee339be12c8
--- /dev/null
+++ b/apps/sim/app/api/billing/route.test.ts
@@ -0,0 +1,310 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetCreditBalanceForEntity,
+ mockGetOrganizationBillingData,
+ mockGetOrganizationSubscription,
+ mockGetPersonalBillingSummary,
+ mockGetSession,
+ mockResolveBillingInterval,
+} = vi.hoisted(() => ({
+ mockGetCreditBalanceForEntity: vi.fn(),
+ mockGetOrganizationBillingData: vi.fn(),
+ mockGetOrganizationSubscription: vi.fn(),
+ mockGetPersonalBillingSummary: vi.fn(),
+ mockGetSession: vi.fn(),
+ mockResolveBillingInterval: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => dbChainMock)
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/billing/core/billing', () => ({
+ getOrganizationSubscription: mockGetOrganizationSubscription,
+ getPersonalBillingSummary: mockGetPersonalBillingSummary,
+}))
+
+vi.mock('@/lib/billing/core/organization', () => ({
+ getOrganizationBillingData: mockGetOrganizationBillingData,
+}))
+
+vi.mock('@/lib/billing/core/subscription', () => ({
+ resolveBillingInterval: mockResolveBillingInterval,
+}))
+
+vi.mock('@/lib/billing/credits/balance', () => ({
+ getCreditBalanceForEntity: mockGetCreditBalanceForEntity,
+}))
+
+import { GET } from '@/app/api/billing/route'
+
+const PERSONAL_SUMMARY = {
+ type: 'individual',
+ plan: 'pro_6000',
+ currentUsage: 8,
+ usageLimit: 30,
+ percentUsed: 26.67,
+ isWarning: false,
+ isExceeded: false,
+ daysRemaining: 12,
+ creditBalance: 4,
+ billingInterval: 'month',
+ isPaid: true,
+ isPro: true,
+ isTeam: false,
+ isEnterprise: false,
+ isOrgScoped: false,
+ organizationId: null,
+ status: 'active',
+ seats: null,
+ metadata: null,
+ stripeSubscriptionId: 'sub_personal',
+ periodEnd: new Date('2026-08-01T00:00:00.000Z'),
+ cancelAtPeriodEnd: false,
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ usage: {
+ current: 8,
+ limit: 30,
+ percentUsed: 26.67,
+ isWarning: false,
+ isExceeded: false,
+ billingPeriodStart: new Date('2026-07-01T00:00:00.000Z'),
+ billingPeriodEnd: new Date('2026-08-01T00:00:00.000Z'),
+ lastPeriodCost: 5,
+ lastPeriodCopilotCost: 1,
+ daysRemaining: 12,
+ copilotCost: 2,
+ },
+} as const
+
+const ACTIVE_ORG_SUBSCRIPTION = {
+ id: 'org-subscription',
+ referenceId: 'org-target',
+ plan: 'team_25000',
+ status: 'active',
+ billingInterval: 'year',
+ cancelAtPeriodEnd: true,
+ periodStart: new Date('2026-07-01T00:00:00.000Z'),
+ periodEnd: new Date('2026-08-01T00:00:00.000Z'),
+} as const
+
+const FREE_ORG_SUBSCRIPTION = {
+ ...ACTIVE_ORG_SUBSCRIPTION,
+ id: 'org-free-subscription',
+ plan: 'free',
+ billingInterval: 'month',
+ cancelAtPeriodEnd: false,
+} as const
+
+interface OrganizationSubscriptionFixture {
+ id: string
+ referenceId: string
+ plan: string
+ status: string
+ billingInterval: 'month' | 'year'
+ cancelAtPeriodEnd: boolean
+ periodStart: Date
+ periodEnd: Date
+}
+
+const ACTIVE_ORG_BILLING = {
+ organizationId: 'org-target',
+ organizationName: 'Target organization',
+ subscriptionPlan: 'team_25000',
+ subscriptionStatus: 'active',
+ totalSeats: 3,
+ usedSeats: 2,
+ seatsCount: 3,
+ totalCurrentUsage: 21,
+ totalUsageLimit: 125,
+ minimumBillingAmount: 125,
+ averageUsagePerMember: 10.5,
+ billingPeriodStart: new Date('2026-07-01T00:00:00.000Z'),
+ billingPeriodEnd: new Date('2026-08-01T00:00:00.000Z'),
+ members: [],
+} as const
+
+function request(query: string) {
+ return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/billing?${query}`)
+}
+
+function mockOrganizationDbRows({
+ role = 'owner',
+ latestSubscription = ACTIVE_ORG_SUBSCRIPTION,
+ ownerId = 'owner-b',
+ billingBlocked = true,
+ billingBlockedReason = 'payment_failed',
+ upgradeWorkspaceId = 'workspace-target',
+}: {
+ role?: 'owner' | 'admin' | 'member'
+ latestSubscription?: OrganizationSubscriptionFixture | null
+ ownerId?: string
+ billingBlocked?: boolean
+ billingBlockedReason?: 'payment_failed' | 'dispute' | null
+ upgradeWorkspaceId?: string | null
+} = {}) {
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([{ role }])
+ .mockResolvedValueOnce([{ id: 'org-target', name: 'Target organization' }])
+ .mockResolvedValueOnce(latestSubscription ? [latestSubscription] : [])
+ .mockResolvedValueOnce([{ userId: ownerId }])
+ .mockResolvedValueOnce(upgradeWorkspaceId ? [{ id: upgradeWorkspaceId }] : [])
+ .mockResolvedValueOnce([{ billingBlocked, billingBlockedReason }])
+}
+
+describe('GET /api/billing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } })
+ mockGetPersonalBillingSummary.mockResolvedValue(PERSONAL_SUMMARY)
+ mockGetOrganizationBillingData.mockResolvedValue(ACTIVE_ORG_BILLING)
+ mockGetOrganizationSubscription.mockResolvedValue(ACTIVE_ORG_SUBSCRIPTION)
+ mockGetCreditBalanceForEntity.mockResolvedValue(17)
+ mockResolveBillingInterval.mockImplementation(
+ (subscription: { billingInterval?: string | null } | null) =>
+ subscription?.billingInterval === 'year' ? 'year' : 'month'
+ )
+ })
+
+ it('keeps account billing personal even when an organization ID is supplied', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'personal-workspace' }])
+
+ const response = await GET(request('context=user&id=org-a'))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(mockGetPersonalBillingSummary).toHaveBeenCalledWith('viewer-a', dbChainMock.db)
+ expect(mockGetOrganizationBillingData).not.toHaveBeenCalled()
+ expect(body.data).toMatchObject({
+ plan: 'pro_6000',
+ type: 'individual',
+ creditBalance: 4,
+ billingBlocked: false,
+ blockedByOrgOwner: false,
+ upgradeWorkspaceId: 'personal-workspace',
+ })
+ })
+
+ it('returns the exact organization payer fields for annual canceled plans', async () => {
+ mockOrganizationDbRows()
+
+ const response = await GET(request('context=organization&id=org-target'))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(mockGetCreditBalanceForEntity).toHaveBeenCalledWith(
+ 'organization',
+ 'org-target',
+ dbChainMock.db
+ )
+ expect(body.data).toMatchObject({
+ organizationId: 'org-target',
+ subscriptionState: 'active',
+ hasSubscription: true,
+ creditBalance: 17,
+ billingInterval: 'year',
+ cancelAtPeriodEnd: true,
+ billingBlocked: true,
+ billingBlockedReason: 'payment_failed',
+ blockedByOrgOwner: true,
+ upgradeWorkspaceId: 'workspace-target',
+ })
+ })
+
+ it('rejects ordinary organization members before loading admin billing data', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'member' }])
+
+ const response = await GET(request('context=organization&id=org-target'))
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Access denied - organization admin permission is required',
+ })
+ expect(mockGetOrganizationBillingData).not.toHaveBeenCalled()
+ expect(mockGetCreditBalanceForEntity).not.toHaveBeenCalled()
+ })
+
+ it('returns an explicit free state when the organization has no subscription', async () => {
+ mockGetOrganizationBillingData.mockResolvedValue(null)
+ mockGetOrganizationSubscription.mockResolvedValue(null)
+ mockOrganizationDbRows({
+ latestSubscription: null,
+ ownerId: 'viewer-a',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ })
+
+ const response = await GET(request('context=organization&id=org-target'))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data).toMatchObject({
+ subscriptionState: 'free',
+ hasSubscription: false,
+ subscriptionPlan: 'free',
+ subscriptionStatus: null,
+ creditBalance: 17,
+ billingInterval: 'month',
+ cancelAtPeriodEnd: false,
+ billingBlocked: false,
+ })
+ })
+
+ it('distinguishes an active free subscription from a paid active plan', async () => {
+ mockGetOrganizationBillingData.mockResolvedValue(null)
+ mockGetOrganizationSubscription.mockResolvedValue(FREE_ORG_SUBSCRIPTION)
+ mockOrganizationDbRows({
+ latestSubscription: FREE_ORG_SUBSCRIPTION,
+ ownerId: 'viewer-a',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ })
+
+ const response = await GET(request('context=organization&id=org-target'))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data).toMatchObject({
+ subscriptionState: 'free',
+ hasSubscription: true,
+ subscriptionPlan: 'free',
+ subscriptionStatus: 'active',
+ billingInterval: 'month',
+ cancelAtPeriodEnd: false,
+ })
+ })
+
+ it('retains the last target subscription as an explicit lapsed state', async () => {
+ const lapsedSubscription = {
+ ...ACTIVE_ORG_SUBSCRIPTION,
+ status: 'canceled',
+ cancelAtPeriodEnd: false,
+ }
+ mockGetOrganizationBillingData.mockResolvedValue(null)
+ mockGetOrganizationSubscription.mockResolvedValue(null)
+ mockOrganizationDbRows({ latestSubscription: lapsedSubscription })
+
+ const response = await GET(request('context=organization&id=org-target'))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data).toMatchObject({
+ subscriptionState: 'lapsed',
+ hasSubscription: true,
+ subscriptionPlan: 'team_25000',
+ subscriptionStatus: 'canceled',
+ billingInterval: 'year',
+ cancelAtPeriodEnd: false,
+ })
+ })
+})
diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts
index 364f4d707cc..4a34e511f5d 100644
--- a/apps/sim/app/api/billing/route.ts
+++ b/apps/sim/app/api/billing/route.ts
@@ -1,17 +1,97 @@
import { db, dbReplica } from '@sim/db'
-import { member } from '@sim/db/schema'
+import {
+ member,
+ organization as organizationTable,
+ subscription as subscriptionTable,
+ userStats,
+ workspace as workspaceTable,
+} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
+import { isOrgAdminRole } from '@sim/platform-authz/workspace'
+import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { billingQuerySchema } from '@/lib/api/contracts/subscription'
+import { getBillingContract } from '@/lib/api/contracts/subscription'
+import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
-import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
+import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
+import { resolveBillingInterval } from '@/lib/billing/core/subscription'
+import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
+import { isPaid } from '@/lib/billing/plan-helpers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('UnifiedBillingAPI')
+interface BillingBlockState {
+ billingBlocked: boolean
+ billingBlockedReason: 'payment_failed' | 'dispute' | null
+ blockedByOrgOwner: boolean
+}
+
+/**
+ * Finds an active workspace whose host billing identity is the requested payer.
+ */
+async function getUpgradeWorkspaceId(
+ target: { type: 'user'; id: string } | { type: 'organization'; id: string }
+): Promise {
+ const targetPredicate =
+ target.type === 'organization'
+ ? eq(workspaceTable.organizationId, target.id)
+ : and(
+ eq(workspaceTable.ownerId, target.id),
+ eq(workspaceTable.billedAccountUserId, target.id),
+ isNull(workspaceTable.organizationId)
+ )
+
+ const [workspace] = await dbReplica
+ .select({ id: workspaceTable.id })
+ .from(workspaceTable)
+ .where(and(targetPredicate, isNull(workspaceTable.archivedAt)))
+ .orderBy(asc(workspaceTable.createdAt), asc(workspaceTable.id))
+ .limit(1)
+
+ return workspace?.id ?? null
+}
+
+/**
+ * Reads the exact organization's payer block from its owner, without allowing
+ * the viewer's personal status or another organization membership to leak in.
+ */
+async function getOrganizationBillingBlockState(
+ organizationId: string,
+ viewerUserId: string
+): Promise {
+ const [owner] = await dbReplica
+ .select({ userId: member.userId })
+ .from(member)
+ .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
+ .limit(1)
+
+ if (!owner) {
+ return {
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ }
+ }
+
+ const [stats] = await dbReplica
+ .select({
+ billingBlocked: userStats.billingBlocked,
+ billingBlockedReason: userStats.billingBlockedReason,
+ })
+ .from(userStats)
+ .where(eq(userStats.userId, owner.userId))
+ .limit(1)
+
+ const billingBlocked = Boolean(stats?.billingBlocked)
+ return {
+ billingBlocked,
+ billingBlockedReason: billingBlocked ? (stats?.billingBlockedReason ?? null) : null,
+ blockedByOrgOwner: billingBlocked && owner.userId !== viewerUserId,
+ }
+}
+
/**
* Unified Billing Endpoint
*/
@@ -23,23 +103,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const { searchParams } = new URL(request.url)
- const parsedQuery = billingQuerySchema.safeParse({
- context: searchParams.get('context') || undefined,
- id: searchParams.get('id') || undefined,
- includeOrg: searchParams.get('includeOrg') === 'true',
- })
-
- if (!parsedQuery.success) {
- return NextResponse.json(
- { error: 'Invalid context. Must be "user" or "organization"' },
- { status: 400 }
- )
- }
-
- const { context, id: contextId, includeOrg } = parsedQuery.data
+ const parsed = await parseRequest(
+ getBillingContract,
+ request,
+ {},
+ {
+ validationErrorResponse: () =>
+ NextResponse.json(
+ { error: 'Invalid context. Must be "user" or "organization"' },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
- // For organization context, require contextId
+ const { context, id: contextId, includeOrg } = parsed.data.query
if (context === 'organization' && !contextId) {
return NextResponse.json(
{ error: 'Organization ID is required when context=organization' },
@@ -47,39 +125,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
}
- let billingData
-
if (context === 'user') {
- if (contextId) {
- const membership = await db
- .select({ role: member.role })
- .from(member)
- .where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id)))
- .limit(1)
- if (membership.length === 0) {
- return NextResponse.json(
- { error: 'Access denied - not a member of this organization' },
- { status: 403 }
- )
- }
- }
-
- const [billingResult, billingStatus] = await Promise.all([
- getSimplifiedBillingSummary(session.user.id, contextId || undefined, dbReplica),
- getEffectiveBillingStatus(session.user.id),
+ const [personalBilling, upgradeWorkspaceId] = await Promise.all([
+ getPersonalBillingSummary(session.user.id, dbReplica),
+ getUpgradeWorkspaceId({ type: 'user', id: session.user.id }),
])
- billingData = billingResult
+ let organizationMembership: { id: string; role: 'owner' | 'admin' | 'member' } | undefined
- billingData = {
- ...billingData,
- billingBlocked: billingStatus.billingBlocked,
- billingBlockedReason: billingStatus.billingBlockedReason,
- blockedByOrgOwner: billingStatus.blockedByOrgOwner,
- }
-
- // Optionally include organization membership and role
if (includeOrg) {
- const userMembership = await db
+ const [userMembership] = await db
.select({
organizationId: member.organizationId,
role: member.role,
@@ -88,89 +142,133 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.where(eq(member.userId, session.user.id))
.limit(1)
- if (userMembership.length > 0) {
- billingData = {
- ...billingData,
- organization: {
- id: userMembership[0].organizationId,
- role: userMembership[0].role as 'owner' | 'admin' | 'member',
- },
+ if (userMembership) {
+ organizationMembership = {
+ id: userMembership.organizationId,
+ role: userMembership.role as 'owner' | 'admin' | 'member',
}
}
}
- } else {
- // Get user role in organization for permission checks first
- const memberRecord = await db
- .select({ role: member.role })
- .from(member)
- .where(and(eq(member.organizationId, contextId!), eq(member.userId, session.user.id)))
- .limit(1)
-
- if (memberRecord.length === 0) {
- return NextResponse.json(
- { error: 'Access denied - not a member of this organization' },
- { status: 403 }
- )
- }
- // Get organization-specific billing
- const rawBillingData = await getOrganizationBillingData(contextId!, dbReplica)
+ return NextResponse.json({
+ success: true,
+ context,
+ data: {
+ ...personalBilling,
+ upgradeWorkspaceId,
+ ...(organizationMembership ? { organization: organizationMembership } : {}),
+ },
+ })
+ }
- if (!rawBillingData) {
- return NextResponse.json(
- { error: 'Organization not found or access denied' },
- { status: 404 }
- )
- }
+ const organizationId = contextId!
+ const [memberRecord] = await db
+ .select({ role: member.role })
+ .from(member)
+ .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
+ .limit(1)
- billingData = {
- organizationId: rawBillingData.organizationId,
- organizationName: rawBillingData.organizationName,
- subscriptionPlan: rawBillingData.subscriptionPlan,
- subscriptionStatus: rawBillingData.subscriptionStatus,
- totalSeats: rawBillingData.totalSeats,
- usedSeats: rawBillingData.usedSeats,
- seatsCount: rawBillingData.seatsCount,
- totalCurrentUsage: rawBillingData.totalCurrentUsage,
- totalUsageLimit: rawBillingData.totalUsageLimit,
- minimumBillingAmount: rawBillingData.minimumBillingAmount,
- averageUsagePerMember: rawBillingData.averageUsagePerMember,
- billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
- billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
- members: rawBillingData.members.map((m) => ({
- ...m,
- joinedAt: m.joinedAt.toISOString(),
- })),
- }
+ if (!memberRecord) {
+ return NextResponse.json(
+ { error: 'Access denied - not a member of this organization' },
+ { status: 403 }
+ )
+ }
+ if (!isOrgAdminRole(memberRecord.role)) {
+ return NextResponse.json(
+ { error: 'Access denied - organization admin permission is required' },
+ { status: 403 }
+ )
+ }
- const userRole = memberRecord[0].role
+ const [
+ rawBillingData,
+ entitledSubscription,
+ organizationRows,
+ latestSubscriptionRows,
+ creditBalance,
+ billingStatus,
+ upgradeWorkspaceId,
+ ] = await Promise.all([
+ getOrganizationBillingData(organizationId, dbReplica),
+ getOrganizationSubscription(organizationId, { executor: dbReplica, onError: 'throw' }),
+ dbReplica
+ .select({ id: organizationTable.id, name: organizationTable.name })
+ .from(organizationTable)
+ .where(eq(organizationTable.id, organizationId))
+ .limit(1),
+ dbReplica
+ .select()
+ .from(subscriptionTable)
+ .where(eq(subscriptionTable.referenceId, organizationId))
+ .orderBy(desc(subscriptionTable.periodStart), desc(subscriptionTable.id))
+ .limit(1),
+ getCreditBalanceForEntity('organization', organizationId, dbReplica),
+ getOrganizationBillingBlockState(organizationId, session.user.id),
+ getUpgradeWorkspaceId({ type: 'organization', id: organizationId }),
+ ])
- // Get effective billing blocked status (includes org owner check)
- const billingStatus = await getEffectiveBillingStatus(session.user.id)
+ const organizationRecord = organizationRows[0]
+ if (!organizationRecord) {
+ return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
+ }
- // Merge blocked flag into data for convenience
- billingData = {
- ...billingData,
- billingBlocked: billingStatus.billingBlocked,
- billingBlockedReason: billingStatus.billingBlockedReason,
- blockedByOrgOwner: billingStatus.blockedByOrgOwner,
- }
+ const latestSubscription = latestSubscriptionRows[0] ?? null
+ const activeSubscription =
+ entitledSubscription && isPaid(entitledSubscription.plan) ? entitledSubscription : null
+ const freeSubscription =
+ entitledSubscription && !isPaid(entitledSubscription.plan) ? entitledSubscription : null
+ const lapsedSubscription =
+ !entitledSubscription && latestSubscription && isPaid(latestSubscription.plan)
+ ? latestSubscription
+ : null
+ const displayedSubscription = activeSubscription ?? freeSubscription ?? lapsedSubscription
+ const subscriptionState = activeSubscription
+ ? ('active' as const)
+ : lapsedSubscription
+ ? ('lapsed' as const)
+ : ('free' as const)
- return NextResponse.json({
- success: true,
- context,
- data: billingData,
- userRole,
- billingBlocked: billingData.billingBlocked,
- billingBlockedReason: billingData.billingBlockedReason,
- blockedByOrgOwner: billingData.blockedByOrgOwner,
- })
+ const billingData = {
+ organizationId,
+ organizationName: rawBillingData?.organizationName ?? organizationRecord.name ?? '',
+ subscriptionState,
+ hasSubscription: displayedSubscription !== null,
+ subscriptionPlan: displayedSubscription?.plan ?? 'free',
+ subscriptionStatus: displayedSubscription?.status ?? null,
+ creditBalance,
+ billingInterval: resolveBillingInterval(displayedSubscription),
+ cancelAtPeriodEnd: displayedSubscription?.cancelAtPeriodEnd ?? false,
+ totalSeats: rawBillingData?.totalSeats ?? 0,
+ usedSeats: rawBillingData?.usedSeats ?? 0,
+ seatsCount: rawBillingData?.seatsCount ?? 0,
+ totalCurrentUsage: rawBillingData?.totalCurrentUsage ?? 0,
+ totalUsageLimit: rawBillingData?.totalUsageLimit ?? 0,
+ minimumBillingAmount: rawBillingData?.minimumBillingAmount ?? 0,
+ averageUsagePerMember: rawBillingData?.averageUsagePerMember ?? 0,
+ billingPeriodStart:
+ rawBillingData?.billingPeriodStart?.toISOString() ??
+ displayedSubscription?.periodStart?.toISOString() ??
+ null,
+ billingPeriodEnd:
+ rawBillingData?.billingPeriodEnd?.toISOString() ??
+ displayedSubscription?.periodEnd?.toISOString() ??
+ null,
+ members:
+ rawBillingData?.members.map((organizationMember) => ({
+ ...organizationMember,
+ joinedAt: organizationMember.joinedAt.toISOString(),
+ })) ?? [],
+ ...billingStatus,
+ upgradeWorkspaceId,
}
return NextResponse.json({
success: true,
context,
data: billingData,
+ userRole: memberRecord.role as 'owner' | 'admin' | 'member',
+ ...billingStatus,
})
} catch (error) {
logger.error('Failed to get billing data', {
diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts
index 7e56b9cba49..952006024bd 100644
--- a/apps/sim/app/api/billing/switch-plan/route.ts
+++ b/apps/sim/app/api/billing/switch-plan/route.ts
@@ -9,8 +9,12 @@ import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
+import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
-import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
+import {
+ getHighestPriorityPersonalSubscription,
+ getHighestPrioritySubscription,
+} from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
@@ -20,9 +24,11 @@ import {
hasUsableSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
+import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
const logger = createLogger('SwitchPlan')
@@ -52,15 +58,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(billingSwitchPlanContract, request, {})
if (!parsed.success) return parsed.response
- const { targetPlanName, interval } = parsed.data.body
+ const { targetPlanName, interval, workspaceId } = parsed.data.body
const userId = session.user.id
- const sub = await getHighestPrioritySubscription(userId)
+ const hostContext = workspaceId
+ ? await getWorkspaceHostContextForViewer(workspaceId, userId)
+ : null
+ if (workspaceId && (!hostContext || !canManageWorkspaceBilling(hostContext, userId))) {
+ return NextResponse.json(
+ { error: 'Only workspace billing administrators can change this plan' },
+ { status: 403 }
+ )
+ }
+
+ const sub = hostContext
+ ? hostContext.hostOrganizationId
+ ? await getOrganizationSubscription(hostContext.hostOrganizationId)
+ : await getHighestPriorityPersonalSubscription(hostContext.workspace.billedAccountUserId)
+ : await getHighestPrioritySubscription(userId)
if (!sub || !sub.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
- const billingStatus = await getEffectiveBillingStatus(userId)
+ const billingStatus = await getEffectiveBillingStatus(
+ hostContext?.workspace.billedAccountUserId ?? userId
+ )
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
}
diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts
index b2a2b1a37a8..c2fd1fa9cdf 100644
--- a/apps/sim/app/api/billing/update-cost/route.test.ts
+++ b/apps/sim/app/api/billing/update-cost/route.test.ts
@@ -9,11 +9,26 @@ const {
mockRecordUsage,
mockRecordCumulativeUsage,
mockCheckAndBillOverageThreshold,
+ mockCheckAndBillPayerOverageThreshold,
+ mockGetCachedAccountBillingDecision,
+ mockGetCachedBillingAttribution,
+ mockToBillingContext,
+ MockCumulativeUsageContextMismatchError,
+ billingState,
} = vi.hoisted(() => ({
mockCheckInternalApiKey: vi.fn(),
mockRecordUsage: vi.fn(),
mockRecordCumulativeUsage: vi.fn(),
mockCheckAndBillOverageThreshold: vi.fn(),
+ mockCheckAndBillPayerOverageThreshold: vi.fn(),
+ mockGetCachedAccountBillingDecision: vi.fn(),
+ mockGetCachedBillingAttribution: vi.fn(),
+ mockToBillingContext: vi.fn(),
+ MockCumulativeUsageContextMismatchError: class extends Error {},
+ billingState: {
+ isBillingEnabled: true,
+ isCopilotBillingAttributionV1Enabled: false,
+ },
}))
vi.mock('@sim/db', () => dbChainMock)
@@ -32,45 +47,239 @@ vi.mock('@/lib/copilot/request/otel', () => ({
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
+ CumulativeUsageContextMismatchError: MockCumulativeUsageContextMismatchError,
recordUsage: mockRecordUsage,
recordCumulativeUsage: mockRecordCumulativeUsage,
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ BILLING_REQUEST_ID_HEADER: 'x-sim-billing-request-id',
+ COPILOT_BILLING_PROTOCOL: {
+ attributed: 'attribution-v1',
+ direct: 'direct-v1',
+ legacy: 'legacy-v0',
+ },
+ COPILOT_BILLING_PROTOCOL_HEADER: 'x-sim-billing-protocol',
+ toBillingContext: mockToBillingContext,
+}))
+
+vi.mock('@/lib/billing/core/billing-attribution-cache', () => ({
+ getCachedAccountBillingDecision: mockGetCachedAccountBillingDecision,
+ getCachedBillingAttribution: mockGetCachedBillingAttribution,
+}))
+
vi.mock('@/lib/billing/threshold-billing', () => ({
checkAndBillOverageThreshold: mockCheckAndBillOverageThreshold,
+ checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
- isBillingEnabled: true,
+ get isBillingEnabled() {
+ return billingState.isBillingEnabled
+ },
+ get isCopilotBillingAttributionV1Enabled() {
+ return billingState.isCopilotBillingAttributionV1Enabled
+ },
}))
import { POST } from '@/app/api/billing/update-cost/route'
+const ACCOUNT_BILLING_DECISION = {
+ userId: 'user-1',
+ billingEntity: { type: 'organization' as const, id: 'account-org' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+}
+
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
+ billingState.isBillingEnabled = true
+ billingState.isCopilotBillingAttributionV1Enabled = false
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockRecordUsage.mockResolvedValue(undefined)
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
+ mockCheckAndBillPayerOverageThreshold.mockResolvedValue(undefined)
+ mockGetCachedAccountBillingDecision.mockResolvedValue(ACCOUNT_BILLING_DECISION)
+ mockGetCachedBillingAttribution.mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billedAccountUserId: 'owner-1',
+ organizationId: 'org-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ })
+ mockToBillingContext.mockReturnValue({
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })
dbChainMockFns.limit.mockResolvedValue([{ id: 'ws-1' }])
})
- it('stamps workspaceId onto recorded usage when provided (no idempotency key)', async () => {
+ it('returns 401 for a billing-disabled request without valid internal auth', async () => {
+ billingState.isBillingEnabled = false
+ mockCheckInternalApiKey.mockReturnValue({ success: false, error: 'Invalid internal API key' })
+
+ const res = await POST(
+ createMockRequest('POST', {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'gpt',
+ source: 'copilot',
+ })
+ )
+
+ expect(res.status).toBe(401)
+ await expect(res.json()).resolves.toEqual({
+ success: false,
+ error: 'Invalid internal API key',
+ })
+ expect(mockCheckInternalApiKey).toHaveBeenCalledTimes(1)
+ expect(mockRecordUsage).not.toHaveBeenCalled()
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
+ })
+
+ it('returns the no-op success for a valid billing-disabled request', async () => {
+ billingState.isBillingEnabled = false
+
const res = await POST(
createMockRequest(
'POST',
- { userId: 'user-1', cost: 0.5, model: 'gpt', source: 'mcp_copilot', workspaceId: 'ws-1' },
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'gpt',
+ source: 'copilot',
+ },
{ 'x-api-key': 'internal' }
)
)
+
expect(res.status).toBe(200)
- expect(mockRecordUsage).toHaveBeenCalledTimes(1)
- expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
- userId: 'user-1',
- workspaceId: 'ws-1',
+ await expect(res.json()).resolves.toMatchObject({
+ success: true,
+ message: 'Billing disabled, cost update skipped',
+ data: { billingEnabled: false },
})
+ expect(mockCheckInternalApiKey).toHaveBeenCalledTimes(1)
+ expect(mockRecordUsage).not.toHaveBeenCalled()
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
+ })
+
+ it('accepts markerless legacy callbacks only during the Sim-first rollout stage', async () => {
+ mockGetCachedBillingAttribution.mockResolvedValueOnce(undefined)
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'gpt',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: 'legacy-message-billing',
+ },
+ { 'x-api-key': 'internal' }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+ )
+ expect(mockRecordCumulativeUsage.mock.calls[0][0]).not.toHaveProperty('billingEntity')
+ })
+
+ it('rejects markerless callbacks after the attribution rollout flag is enabled', async () => {
+ billingState.isCopilotBillingAttributionV1Enabled = true
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'gpt',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: 'markerless-modern',
+ },
+ { 'x-api-key': 'internal' }
+ )
+ )
+
+ expect(res.status).toBe(400)
+ expect(mockGetCachedBillingAttribution).not.toHaveBeenCalled()
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
+ })
+
+ it('allows explicitly labeled legacy callbacks to drain after strict attribution rollout', async () => {
+ billingState.isCopilotBillingAttributionV1Enabled = true
+ mockGetCachedBillingAttribution.mockResolvedValueOnce(undefined)
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'gpt',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: 'legacy-title-billing',
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'legacy-v0',
+ }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
+ expect(mockCheckAndBillOverageThreshold).toHaveBeenCalledWith('user-1')
+ expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
+ })
+
+ it('rejects a direct-v1 callback without its cached account decision', async () => {
+ const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
+ mockGetCachedAccountBillingDecision.mockResolvedValueOnce(undefined)
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'gpt',
+ source: 'mcp_copilot',
+ workspaceId: 'local-self-hosted-workspace',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ }
+ )
+ )
+ expect(res.status).toBe(500)
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})
it('records cumulative cost via monotonic top-up when an idempotency key is present', async () => {
@@ -100,8 +309,209 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
model: 'claude-opus-4.8',
cost: 0.4662453,
eventKey: 'update-cost:msg-1-billing',
+ billingEntity: { type: 'organization', id: 'org-1' },
})
- expect(mockCheckAndBillOverageThreshold).toHaveBeenCalledWith('user-1')
+ expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
+ type: 'organization',
+ id: 'org-1',
+ })
+ expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
+ })
+
+ it('bills direct-v1 to the cached hosted account and ignores the local workspace', async () => {
+ const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ workspaceId: 'local-self-hosted-workspace',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockGetCachedAccountBillingDecision).toHaveBeenCalledWith(billingRequestId)
+ expect(mockGetCachedBillingAttribution).not.toHaveBeenCalled()
+ expect(dbChainMockFns.limit).not.toHaveBeenCalled()
+ expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ workspaceId: undefined,
+ billingEntity: { type: 'organization', id: 'account-org' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })
+ )
+ expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
+ type: 'organization',
+ id: 'account-org',
+ })
+ expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
+ })
+
+ it('rejects a direct-v1 callback that changes the cached hosted account', async () => {
+ const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
+ mockGetCachedAccountBillingDecision.mockResolvedValueOnce({
+ ...ACCOUNT_BILLING_DECISION,
+ userId: 'different-user',
+ })
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ }
+ )
+ )
+
+ expect(res.status).toBe(409)
+ await expect(res.json()).resolves.toMatchObject({
+ code: 'BILLING_CONTEXT_MISMATCH',
+ error: 'Idempotency key is already bound to a different billing context',
+ })
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
+ })
+
+ it('reuses the pre-hosted-work snapshot for cumulative cost flushes', async () => {
+ mockGetCachedBillingAttribution.mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billedAccountUserId: 'owner-1',
+ organizationId: 'org-original',
+ billingEntity: { type: 'organization', id: 'org-original' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ })
+ mockToBillingContext.mockReturnValue({
+ billingEntity: { type: 'organization', id: 'org-original' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ idempotencyKey: 'msg-1-billing',
+ },
+ { 'x-api-key': 'internal' }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockGetCachedBillingAttribution).toHaveBeenCalledWith('msg-1-billing')
+ expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ billingEntity: { type: 'organization', id: 'org-original' },
+ })
+ )
+ })
+
+ it.each([
+ ['actor', { actorUserId: 'different-user' }, 'ws-1'],
+ ['workspace', {}, 'different-workspace'],
+ ])(
+ 'returns a deterministic conflict for a cached %s mismatch',
+ async (_, override, requestWorkspaceId) => {
+ mockGetCachedBillingAttribution.mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billedAccountUserId: 'owner-1',
+ organizationId: 'org-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ ...override,
+ })
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ workspaceId: requestWorkspaceId,
+ idempotencyKey: 'msg-1-billing',
+ },
+ { 'x-api-key': 'internal' }
+ )
+ )
+
+ expect(res.status).toBe(409)
+ await expect(res.json()).resolves.toMatchObject({
+ success: false,
+ code: 'BILLING_CONTEXT_MISMATCH',
+ error: 'Idempotency key is already bound to a different billing context',
+ })
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
+ }
+ )
+
+ it('surfaces a cumulative ledger context mismatch as a deterministic conflict', async () => {
+ mockRecordCumulativeUsage.mockRejectedValue(
+ new MockCumulativeUsageContextMismatchError('different billing context')
+ )
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: 'msg-1-billing',
+ },
+ { 'x-api-key': 'internal' }
+ )
+ )
+
+ expect(res.status).toBe(409)
+ await expect(res.json()).resolves.toMatchObject({
+ success: false,
+ code: 'BILLING_CONTEXT_MISMATCH',
+ error: 'Idempotency key is already bound to a different billing context',
+ })
+ expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
+ expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
})
it('returns 409 and skips overage when the cumulative is not higher (duplicate flush)', async () => {
@@ -121,7 +531,11 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
)
)
expect(res.status).toBe(409)
+ await expect(res.json()).resolves.toMatchObject({
+ code: 'DUPLICATE_BILLING_EVENT',
+ })
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
+ expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
})
it('records unattributed when workspaceId is omitted (headless client)', async () => {
@@ -142,6 +556,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
})
it('records unattributed when the workspace does not exist in this deployment (self-hosted client)', async () => {
+ mockGetCachedBillingAttribution.mockResolvedValue(undefined)
dbChainMockFns.limit.mockResolvedValue([])
const res = await POST(
createMockRequest(
@@ -165,4 +580,65 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
eventKey: 'update-cost:msg-1-billing',
})
})
+
+ it('binds an attributed-v1 callback to the exact hosted actor and workspace snapshot', async () => {
+ const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockGetCachedBillingAttribution).toHaveBeenCalledWith(billingRequestId)
+ expect(mockGetCachedAccountBillingDecision).not.toHaveBeenCalled()
+ expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ )
+ })
+
+ it('fails closed when a hosted attributed-v1 snapshot is missing from the cache', async () => {
+ const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
+ mockGetCachedBillingAttribution.mockResolvedValue(undefined)
+
+ const res = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ userId: 'user-1',
+ cost: 0.5,
+ model: 'claude-opus-4.8',
+ source: 'workspace-chat',
+ workspaceId: 'ws-1',
+ idempotencyKey: billingRequestId,
+ },
+ {
+ 'x-api-key': 'internal',
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': billingRequestId,
+ }
+ )
+ )
+
+ expect(res.status).toBe(500)
+ expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
index f2f65d482d8..2c604f664af 100644
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -7,19 +7,52 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
-import { recordCumulativeUsage, recordUsage } from '@/lib/billing/core/usage-log'
-import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
+import {
+ BILLING_REQUEST_ID_HEADER,
+ COPILOT_BILLING_PROTOCOL,
+ COPILOT_BILLING_PROTOCOL_HEADER,
+ type CopilotBillingProtocol,
+ toBillingContext,
+} from '@/lib/billing/core/billing-attribution'
+import {
+ getCachedAccountBillingDecision,
+ getCachedBillingAttribution,
+} from '@/lib/billing/core/billing-attribution-cache'
+import {
+ type CumulativeUsageContextField,
+ CumulativeUsageContextMismatchError,
+ recordCumulativeUsage,
+ recordUsage,
+} from '@/lib/billing/core/usage-log'
+import {
+ checkAndBillOverageThreshold,
+ checkAndBillPayerOverageThreshold,
+} from '@/lib/billing/threshold-billing'
+import { BILLING_CALLBACK_OUTCOME } from '@/lib/copilot/generated/billing-protocol-v1'
import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isBillingEnabled, isCopilotBillingAttributionV1Enabled } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('BillingUpdateCostAPI')
+function invalidBillingProtocolResponse(requestId: string, span: Span): NextResponse {
+ span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody)
+ span.setAttribute(TraceAttr.HttpStatusCode, 400)
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Invalid billing protocol',
+ requestId,
+ },
+ { status: 400 }
+ )
+}
+
/**
* Resolves the request-supplied workspace to one that exists in this
* deployment. Workspace attribution on the usage ledger is best-effort:
@@ -27,8 +60,10 @@ const logger = createLogger('BillingUpdateCostAPI')
* IDs from their own databases, and `usage_log.workspace_id` carries an FK to
* `workspace`, so stamping a foreign ID would fail the entire flush with an
* FK violation and strand real cost in the caller's dead-letter queue.
- * Unknown workspaces are recorded unattributed instead — billing is keyed on
- * the user's billing entity and never depends on the workspace.
+ * Unknown workspaces are recorded without local workspace attribution because
+ * this deployment cannot resolve their payer. Direct-v1 account callbacks do
+ * not call this helper at all: their self-hosted workspace is not hosted payer
+ * identity.
*/
async function resolveAttributableWorkspaceId(
requestId: string,
@@ -77,20 +112,6 @@ async function updateCostInner(req: NextRequest, span: Span): Promise 0) {
+ throw new CumulativeUsageContextMismatchError(
+ `update-cost:${idempotencyKey}`,
+ mismatchedFields
+ )
+ }
+ }
+
+ const resolvedWorkspaceId = isDirectProtocol
+ ? undefined
+ : await resolveAttributableWorkspaceId(
+ requestId,
+ workspaceId ?? cachedBillingAttribution?.workspaceId
+ )
+ const billingAttribution = cachedBillingAttribution
+ const billingContext = billingAttribution
+ ? toBillingContext(billingAttribution)
+ : cachedAccountDecision
+ ? {
+ billingEntity: cachedAccountDecision.billingEntity,
+ billingPeriod: {
+ start: new Date(cachedAccountDecision.billingPeriod.start),
+ end: new Date(cachedAccountDecision.billingPeriod.end),
+ },
+ }
+ : undefined
// Go sends the request's CUMULATIVE cost, possibly more than once (a
// mid-loop provider-error flush, then the recovered terminal flush, plus
@@ -175,7 +290,8 @@ async function updateCostInner(req: NextRequest, span: Span): Promise {
workspaceId: 'test-workspace-id',
variables: {},
},
- userSubscription: {
- plan: 'pro',
- status: 'active',
- },
- rateLimitInfo: {
- allowed: true,
- remaining: 100,
- resetAt: new Date(),
- },
})
mockValidateChatAuth.mockResolvedValue({ authorized: true })
@@ -404,7 +395,6 @@ describe('Chat Identifier API Route', () => {
error: {
message: 'Workflow is not deployed',
statusCode: 403,
- logCreated: false,
},
})
diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts
index 2425df34db3..747644ad9e9 100644
--- a/apps/sim/app/api/chat/[identifier]/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.ts
@@ -129,6 +129,7 @@ export const POST = withRouteHandler(
stackTrace: undefined,
},
traceSpans: [],
+ skipCost: true,
})
return createErrorResponse('This chat is currently unavailable', 403)
@@ -193,8 +194,8 @@ export const POST = withRouteHandler(
)
}
- const { actorUserId, workflowRecord } = preprocessResult
- const workspaceOwnerId = actorUserId!
+ const { actorUserId, billingAttribution, workflowRecord } = preprocessResult
+ const resolvedActorUserId = actorUserId!
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
@@ -243,7 +244,9 @@ export const POST = withRouteHandler(
logger.error(`[${requestId}] Failed to process chat files:`, fileError)
await loggingSession.safeStart({
- userId: workspaceOwnerId,
+ userId: resolvedActorUserId,
+ actorUserId: resolvedActorUserId,
+ billingAttribution,
workspaceId,
variables: {},
})
@@ -278,13 +281,13 @@ export const POST = withRouteHandler(
executionId,
workspaceId,
workflowId: deployment.workflowId,
- userId: workspaceOwnerId,
+ userId: resolvedActorUserId,
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
executeWorkflow(
workflowForExecution,
requestId,
workflowInput,
- workspaceOwnerId,
+ resolvedActorUserId,
{
enabled: true,
selectedOutputs,
@@ -295,6 +298,7 @@ export const POST = withRouteHandler(
skipLoggingComplete: true,
abortSignal,
executionMode: 'stream',
+ billingAttribution,
},
executionId
),
diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts
index 33eabf86451..7f56b3dcc94 100644
--- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts
+++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts
@@ -5,28 +5,105 @@ import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
- mockFlags,
mockDbLimit,
mockCheckInternalApiKey,
- mockCheckServerSideUsageLimits,
+ mockCheckAttributedUsageLimits,
mockCheckOrgMemberUsageLimit,
+ mockCheckServerSideUsageLimits,
+ mockDeriveBillingContext,
+ mockGetHighestPrioritySubscription,
+ mockBillingAttributionsEqual,
+ mockCacheAccountBillingDecisionOrThrow,
+ mockGetCachedBillingAttribution,
+ mockRequireBillingAttributionHeader,
+ mockRequireBillingRequestIdHeader,
+ mockResolveBillingAttribution,
+ mockGetUserEntityPermissions,
+ mockGetWorkspaceBillingSettings,
+ mockFlags,
} = vi.hoisted(() => ({
- mockFlags: { isHosted: true },
mockDbLimit: vi.fn(),
mockCheckInternalApiKey: vi.fn(),
- mockCheckServerSideUsageLimits: vi.fn(),
+ mockCheckAttributedUsageLimits: vi.fn(),
mockCheckOrgMemberUsageLimit: vi.fn(),
+ mockCheckServerSideUsageLimits: vi.fn(),
+ mockDeriveBillingContext: vi.fn(),
+ mockGetHighestPrioritySubscription: vi.fn(),
+ mockBillingAttributionsEqual: vi.fn(),
+ mockCacheAccountBillingDecisionOrThrow: vi.fn(),
+ mockGetCachedBillingAttribution: vi.fn(),
+ mockRequireBillingAttributionHeader: vi.fn(),
+ mockRequireBillingRequestIdHeader: vi.fn(),
+ mockResolveBillingAttribution: vi.fn(),
+ mockGetUserEntityPermissions: vi.fn(),
+ mockGetWorkspaceBillingSettings: vi.fn(),
+ mockFlags: {
+ isCopilotBillingAttributionV1Enabled: false,
+ isHosted: true,
+ },
}))
+const ATTRIBUTION = {
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billedAccountUserId: 'owner-1',
+ organizationId: 'org-1',
+ billingEntity: { type: 'organization' as const, id: 'org-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
+const ACCOUNT_SUBSCRIPTION = { id: 'account-subscription' }
+const ACCOUNT_BILLING_DECISION = {
+ userId: 'user-1',
+ billingEntity: { type: 'organization' as const, id: 'account-org' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+}
+
vi.mock('@sim/db', () => ({
db: {
select: () => ({ from: () => ({ where: () => ({ limit: mockDbLimit }) }) }),
},
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ billingAttributionsEqual: mockBillingAttributionsEqual,
+ BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution',
+ BILLING_REQUEST_ID_HEADER: 'x-sim-billing-request-id',
+ checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
+ COPILOT_BILLING_PROTOCOL: {
+ attributed: 'attribution-v1',
+ direct: 'direct-v1',
+ legacy: 'legacy-v0',
+ },
+ COPILOT_BILLING_PROTOCOL_HEADER: 'x-sim-billing-protocol',
+ requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
+ requireBillingRequestIdHeader: mockRequireBillingRequestIdHeader,
+ resolveBillingAttribution: mockResolveBillingAttribution,
+}))
+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
- checkServerSideUsageLimits: mockCheckServerSideUsageLimits,
checkOrgMemberUsageLimit: mockCheckOrgMemberUsageLimit,
+ checkServerSideUsageLimits: mockCheckServerSideUsageLimits,
+}))
+
+vi.mock('@/lib/billing/core/billing-attribution-cache', () => ({
+ cacheAccountBillingDecisionOrThrow: mockCacheAccountBillingDecisionOrThrow,
+ getCachedBillingAttribution: mockGetCachedBillingAttribution,
+}))
+
+vi.mock('@/lib/billing/core/plan', () => ({
+ getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
+}))
+
+vi.mock('@/lib/billing/core/usage-log', () => ({
+ deriveBillingContext: mockDeriveBillingContext,
}))
vi.mock('@/lib/copilot/request/http', () => ({
@@ -43,23 +120,67 @@ vi.mock('@/lib/copilot/request/otel', () => ({
}))
vi.mock('@/lib/core/config/env-flags', () => ({
+ get isCopilotBillingAttributionV1Enabled() {
+ return mockFlags.isCopilotBillingAttributionV1Enabled
+ },
get isHosted() {
return mockFlags.isHosted
},
}))
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getUserEntityPermissions: mockGetUserEntityPermissions,
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
+}))
+
import { POST } from '@/app/api/copilot/api-keys/validate/route'
-function request(body: Record) {
- return createMockRequest('POST', body, { 'x-api-key': 'internal' })
+function request(body: Record, headers: Record = {}) {
+ return createMockRequest('POST', body, { 'x-api-key': 'internal', ...headers })
}
-describe('POST /api/copilot/api-keys/validate — per-member enforcement', () => {
+describe('POST /api/copilot/api-keys/validate billing protocols', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockFlags.isCopilotBillingAttributionV1Enabled = false
mockFlags.isHosted = true
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockDbLimit.mockResolvedValue([{ id: 'user-1' }])
+ mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION)
+ mockRequireBillingRequestIdHeader.mockImplementation((headers: Headers) => {
+ const value = headers.get('x-sim-billing-request-id')
+ if (!value) throw new Error('missing billing request ID')
+ return value
+ })
+ mockRequireBillingAttributionHeader.mockImplementation((headers: Headers) => {
+ if (!headers.get('x-sim-billing-attribution')) {
+ throw new Error('missing billing attribution')
+ }
+ return ATTRIBUTION
+ })
+ mockGetCachedBillingAttribution.mockResolvedValue(ATTRIBUTION)
+ mockCacheAccountBillingDecisionOrThrow.mockResolvedValue(undefined)
+ mockGetHighestPrioritySubscription.mockResolvedValue(ACCOUNT_SUBSCRIPTION)
+ mockDeriveBillingContext.mockReturnValue({
+ billingEntity: ACCOUNT_BILLING_DECISION.billingEntity,
+ billingPeriod: {
+ start: new Date(ACCOUNT_BILLING_DECISION.billingPeriod.start),
+ end: new Date(ACCOUNT_BILLING_DECISION.billingPeriod.end),
+ },
+ })
+ mockBillingAttributionsEqual.mockReturnValue(true)
+ mockGetUserEntityPermissions.mockResolvedValue('read')
+ mockGetWorkspaceBillingSettings.mockResolvedValue({
+ billedAccountUserId: 'owner-1',
+ allowPersonalApiKeys: true,
+ })
+ mockCheckAttributedUsageLimits.mockResolvedValue({
+ isExceeded: false,
+ payerUsage: { currentUsage: 0, limit: 100 },
+ })
mockCheckServerSideUsageLimits.mockResolvedValue({
isExceeded: false,
currentUsage: 0,
@@ -72,7 +193,7 @@ describe('POST /api/copilot/api-keys/validate — per-member enforcement', () =>
})
})
- it('returns 402 when the pooled/personal limit is exceeded (existing behavior)', async () => {
+ it('preserves legacy account admission when the hosted account is exceeded', async () => {
mockCheckServerSideUsageLimits.mockResolvedValue({
isExceeded: true,
currentUsage: 200,
@@ -80,10 +201,9 @@ describe('POST /api/copilot/api-keys/validate — per-member enforcement', () =>
})
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(402)
- expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
- it('returns 402 when the per-member org-workspace cap is exceeded', async () => {
+ it('preserves the legacy hosted per-member gate', async () => {
mockCheckOrgMemberUsageLimit.mockResolvedValue({
isExceeded: true,
currentUsage: 5,
@@ -91,7 +211,6 @@ describe('POST /api/copilot/api-keys/validate — per-member enforcement', () =>
})
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(402)
- expect(mockCheckOrgMemberUsageLimit).toHaveBeenCalledWith('user-1', 'ws-1')
})
it('returns 200 when under both limits', async () => {
@@ -99,16 +218,238 @@ describe('POST /api/copilot/api-keys/validate — per-member enforcement', () =>
expect(res.status).toBe(200)
})
- it('rejects with 400 when workspaceId is omitted (contract-required, fail closed)', async () => {
+ it('rejects markerless legacy admission when workspaceId is omitted', async () => {
const res = await POST(request({ userId: 'user-1' }))
expect(res.status).toBe(400)
- expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
+ expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
})
- it('skips the per-member check when not hosted', async () => {
- mockFlags.isHosted = false
+ it('preserves markerless legacy account and member admission', async () => {
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(200)
+ expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
+ expect(mockCheckOrgMemberUsageLimit).toHaveBeenCalledWith('user-1', 'ws-1')
+ expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
+ })
+
+ it('rejects markerless callbacks after the attributed-v1 cutover', async () => {
+ mockFlags.isCopilotBillingAttributionV1Enabled = true
+
+ const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
+
+ expect(res.status).toBe(400)
+ expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
+ })
+
+ it('allows explicitly labeled legacy requests to drain after cutover', async () => {
+ mockFlags.isCopilotBillingAttributionV1Enabled = true
+
+ const res = await POST(
+ request({ userId: 'user-1', workspaceId: 'ws-1' }, { 'x-sim-billing-protocol': 'legacy-v0' })
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
+ expect(mockCheckOrgMemberUsageLimit).toHaveBeenCalledWith('user-1', 'ws-1')
+ })
+
+ it('uses the exact frozen attribution for attributed-v1 admission', async () => {
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'ws-1' },
+ {
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ 'x-sim-billing-attribution': 'serialized-attribution',
+ }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockGetCachedBillingAttribution).toHaveBeenCalledWith(
+ '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
+ )
+ expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(expect.anything(), {
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+ expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(ATTRIBUTION)
+ expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
+ expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
+ })
+
+ it('fails attributed-v1 closed when attribution is missing', async () => {
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'ws-1' },
+ {
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ }
+ )
+ )
+
+ expect(res.status).toBe(400)
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
+ })
+
+ it('fails attributed-v1 closed when attribution mismatches actor or workspace', async () => {
+ mockRequireBillingAttributionHeader.mockImplementationOnce(() => {
+ throw new Error('billing attribution mismatch')
+ })
+
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'ws-1' },
+ {
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ 'x-sim-billing-attribution': 'serialized-attribution',
+ }
+ )
+ )
+
+ expect(res.status).toBe(400)
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ })
+
+ it('fails attributed-v1 closed when the callback alias snapshot differs', async () => {
+ mockBillingAttributionsEqual.mockReturnValueOnce(false)
+
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'ws-1' },
+ {
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ 'x-sim-billing-attribution': 'serialized-attribution',
+ }
+ )
+ )
+
+ expect(res.status).toBe(400)
+ expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
+ })
+
+ it('admits a Copilot/Chat key against its hosted account while ignoring a local workspace ID', async () => {
+ mockGetUserEntityPermissions.mockResolvedValueOnce(null)
+ mockGetWorkspaceBillingSettings.mockResolvedValueOnce({
+ billedAccountUserId: 'different-owner',
+ allowPersonalApiKeys: false,
+ })
+
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'local-self-hosted-workspace' },
+ {
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
+ expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
+ expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
+ expect(mockGetWorkspaceBillingSettings).not.toHaveBeenCalled()
+ expect(mockCacheAccountBillingDecisionOrThrow).toHaveBeenCalledWith(
+ '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ ACCOUNT_BILLING_DECISION,
+ 'Unable to preserve direct account billing decision'
+ )
+ expect(mockCheckServerSideUsageLimits.mock.invocationCallOrder[0]).toBeLessThan(
+ mockCacheAccountBillingDecisionOrThrow.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('admits direct-v1 account billing when workspaceId is omitted', async () => {
+ const res = await POST(
+ request(
+ { userId: 'user-1' },
+ {
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ }
+ )
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
+ expect(mockCacheAccountBillingDecisionOrThrow).toHaveBeenCalledWith(
+ '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ ACCOUNT_BILLING_DECISION,
+ 'Unable to preserve direct account billing decision'
+ )
+ })
+
+ it('fails direct-v1 admission closed when its payer cannot be resolved', async () => {
+ mockGetHighestPrioritySubscription.mockRejectedValueOnce(new Error('database unavailable'))
+
+ const res = await POST(
+ request(
+ { userId: 'user-1' },
+ {
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ }
+ )
+ )
+
+ expect(res.status).toBe(500)
+ expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
+ expect(mockCacheAccountBillingDecisionOrThrow).not.toHaveBeenCalled()
+ })
+
+ it('does not cache a direct-v1 account decision when usage admission returns 402', async () => {
+ mockCheckServerSideUsageLimits.mockResolvedValueOnce({
+ isExceeded: true,
+ currentUsage: 200,
+ limit: 100,
+ })
+
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'ws-1' },
+ {
+ 'x-sim-billing-protocol': 'direct-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ }
+ )
+ )
+
+ expect(res.status).toBe(402)
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockCacheAccountBillingDecisionOrThrow).not.toHaveBeenCalled()
+ })
+
+ it('rejects trusted billing material before parsing for an untrusted caller', async () => {
+ mockCheckInternalApiKey.mockReturnValueOnce({
+ success: false,
+ response: new Response(null, { status: 401 }),
+ })
+
+ const res = await POST(
+ request(
+ { userId: 'user-1', workspaceId: 'ws-1' },
+ {
+ 'x-sim-billing-protocol': 'attribution-v1',
+ 'x-sim-billing-request-id': '0190c03f-9f7d-4b79-8b58-e7f779fd29e1',
+ 'x-sim-billing-attribution': 'serialized-attribution',
+ }
+ )
+ )
+
+ expect(res.status).toBe(401)
+ await expect(res.text()).resolves.toBe('')
+ expect(mockRequireBillingAttributionHeader).not.toHaveBeenCalled()
+ expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
+ expect(mockGetWorkspaceBillingSettings).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
})
})
diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts
index b13d2b11777..f79f3d04250 100644
--- a/apps/sim/app/api/copilot/api-keys/validate/route.ts
+++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts
@@ -9,16 +9,184 @@ import {
checkOrgMemberUsageLimit,
checkServerSideUsageLimits,
} from '@/lib/billing/calculations/usage-monitor'
+import {
+ type BillingAttributionSnapshot,
+ billingAttributionsEqual,
+ checkAttributedUsageLimits,
+ requireBillingAttributionHeader,
+ requireBillingRequestIdHeader,
+} from '@/lib/billing/core/billing-attribution'
+import {
+ type AccountBillingDecision,
+ cacheAccountBillingDecisionOrThrow,
+ getCachedBillingAttribution,
+} from '@/lib/billing/core/billing-attribution-cache'
+import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
+import { deriveBillingContext } from '@/lib/billing/core/usage-log'
+import {
+ BILLING_ATTRIBUTION_HEADER,
+ BILLING_REQUEST_ID_HEADER,
+ COPILOT_BILLING_PROTOCOL,
+ COPILOT_BILLING_PROTOCOL_HEADER,
+ type CopilotBillingProtocol,
+} from '@/lib/copilot/generated/billing-protocol-v1'
import { CopilotValidateOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
-import { isHosted } from '@/lib/core/config/env-flags'
+import { isCopilotBillingAttributionV1Enabled, isHosted } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotApiKeysValidate')
+function invalidBillingProtocolResponse(): NextResponse {
+ return NextResponse.json({ error: 'Invalid billing attribution protocol' }, { status: 400 })
+}
+
+type AdmissionBillingDecision =
+ | {
+ kind: 'attributed'
+ attribution: BillingAttributionSnapshot
+ }
+ | {
+ kind: 'direct-account'
+ userId: string
+ billingRequestId: string
+ }
+ | {
+ kind: 'legacy-account'
+ userId: string
+ workspaceId: string
+ }
+
+/**
+ * Resolves admission against the versioned Go callback protocol.
+ *
+ * Markerless callbacks are accepted only for the Sim-first deployment window
+ * while old Go instances drain. Direct-v1 is scoped only to the authenticated
+ * Chat/Copilot key owner's hosted account; its self-hosted workspace is opaque.
+ * Attributed-v1 never falls back if its frozen hosted workspace decision is
+ * absent or inconsistent.
+ */
+async function resolveAdmissionBillingDecision(
+ req: NextRequest,
+ protocol: CopilotBillingProtocol | undefined,
+ actorUserId: string,
+ workspaceId: string | undefined
+): Promise {
+ const hasBillingRequestId = Boolean(req.headers.get(BILLING_REQUEST_ID_HEADER))
+ const hasBillingAttribution = Boolean(req.headers.get(BILLING_ATTRIBUTION_HEADER))
+
+ if (protocol === COPILOT_BILLING_PROTOCOL.attributed) {
+ if (!workspaceId) {
+ return invalidBillingProtocolResponse()
+ }
+ try {
+ const billingRequestId = requireBillingRequestIdHeader(req.headers)
+ const attribution = requireBillingAttributionHeader(req.headers, {
+ actorUserId,
+ workspaceId,
+ })
+ const cachedAttribution = await getCachedBillingAttribution(billingRequestId)
+ if (!cachedAttribution || !billingAttributionsEqual(cachedAttribution, attribution)) {
+ return invalidBillingProtocolResponse()
+ }
+ return { kind: 'attributed', attribution }
+ } catch {
+ return invalidBillingProtocolResponse()
+ }
+ }
+
+ if (protocol === COPILOT_BILLING_PROTOCOL.direct) {
+ if (hasBillingAttribution) {
+ return invalidBillingProtocolResponse()
+ }
+
+ let billingRequestId: string
+ try {
+ billingRequestId = requireBillingRequestIdHeader(req.headers)
+ } catch {
+ return invalidBillingProtocolResponse()
+ }
+
+ return {
+ kind: 'direct-account',
+ userId: actorUserId,
+ billingRequestId,
+ }
+ }
+
+ if (protocol !== undefined && protocol !== COPILOT_BILLING_PROTOCOL.legacy) {
+ return invalidBillingProtocolResponse()
+ }
+
+ if (protocol === undefined && isCopilotBillingAttributionV1Enabled) {
+ return invalidBillingProtocolResponse()
+ }
+
+ if (hasBillingRequestId || hasBillingAttribution) {
+ return invalidBillingProtocolResponse()
+ }
+ if (!workspaceId) {
+ return invalidBillingProtocolResponse()
+ }
+
+ return {
+ kind: 'legacy-account',
+ userId: actorUserId,
+ workspaceId,
+ }
+}
+
+async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise<{
+ isExceeded: boolean
+ currentUsage: number
+ limit: number
+ scope: string
+ accountBillingDecision?: AccountBillingDecision
+}> {
+ if (admission.kind === 'attributed') {
+ const usage = await checkAttributedUsageLimits(admission.attribution)
+ return {
+ isExceeded: usage.isExceeded,
+ currentUsage: usage.payerUsage?.currentUsage ?? 0,
+ limit: usage.payerUsage?.limit ?? 0,
+ scope: usage.scope ?? 'payer',
+ }
+ }
+
+ if (admission.kind === 'direct-account') {
+ const subscription = await getHighestPrioritySubscription(admission.userId, {
+ onError: 'throw',
+ })
+ const billingContext = deriveBillingContext(admission.userId, subscription)
+ const usage = await checkServerSideUsageLimits(admission.userId, subscription)
+ return {
+ isExceeded: usage.isExceeded,
+ currentUsage: usage.currentUsage,
+ limit: usage.limit,
+ scope: 'account',
+ accountBillingDecision: {
+ userId: admission.userId,
+ billingEntity: billingContext.billingEntity,
+ billingPeriod: {
+ start: billingContext.billingPeriod.start.toISOString(),
+ end: billingContext.billingPeriod.end.toISOString(),
+ },
+ },
+ }
+ }
+
+ const usage = await checkServerSideUsageLimits(admission.userId)
+ return {
+ isExceeded: usage.isExceeded,
+ currentUsage: usage.currentUsage,
+ limit: usage.limit,
+ scope: 'account',
+ }
+}
+
/**
* Incoming-from-Go: extracts traceparent so this handler's work shows up as
* a child of the Go-side `sim.validate_api_key` span in the same trace. If
@@ -76,6 +244,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
if (!parsed.success) return parsed.response
const { userId, workspaceId } = parsed.data.body
+ const protocol = parsed.data.headers?.[COPILOT_BILLING_PROTOCOL_HEADER]
span.setAttribute(TraceAttr.UserId, userId)
const [existingUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
@@ -87,37 +256,46 @@ export const POST = withRouteHandler((req: NextRequest) =>
}
logger.info('[API VALIDATION] Validating usage limit', { userId })
- const { isExceeded, currentUsage, limit } = await checkServerSideUsageLimits(userId)
+ const admission = await resolveAdmissionBillingDecision(req, protocol, userId, workspaceId)
+ if (admission instanceof NextResponse) {
+ span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody)
+ span.setAttribute(TraceAttr.HttpStatusCode, admission.status)
+ return admission
+ }
+ const usage = await checkAdmissionUsage(admission)
+ const { currentUsage, limit } = usage
span.setAttributes({
[TraceAttr.BillingUsageCurrent]: currentUsage,
[TraceAttr.BillingUsageLimit]: limit,
- [TraceAttr.BillingUsageExceeded]: isExceeded,
+ [TraceAttr.BillingUsageExceeded]: usage.isExceeded,
})
logger.info('[API VALIDATION] Usage limit validated', {
userId,
currentUsage,
limit,
- isExceeded,
+ isExceeded: usage.isExceeded,
+ scope: usage.scope,
})
- if (isExceeded) {
- logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
+ if (usage.isExceeded) {
+ logger.info('[API VALIDATION] Usage exceeded', {
+ userId,
+ currentUsage,
+ limit,
+ scope: usage.scope,
+ })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}
- // Per-member org-workspace cap (hosted-only). Blocks the mothership/copilot
- // chat request itself when the user is over their personal credit limit for
- // the org that owns this workspace, independent of the pooled org limit.
- // workspaceId is contract-required, so the gate can't be silently skipped.
- if (isHosted) {
- const memberCheck = await checkOrgMemberUsageLimit(userId, workspaceId)
+ if (admission.kind === 'legacy-account' && isHosted) {
+ const memberCheck = await checkOrgMemberUsageLimit(userId, admission.workspaceId)
if (memberCheck.isExceeded) {
logger.info('[API VALIDATION] Per-member org usage limit exceeded', {
userId,
- workspaceId,
+ workspaceId: admission.workspaceId,
currentUsage: memberCheck.currentUsage,
limit: memberCheck.limit,
})
@@ -130,6 +308,17 @@ export const POST = withRouteHandler((req: NextRequest) =>
}
}
+ if (admission.kind === 'direct-account') {
+ if (!usage.accountBillingDecision) {
+ throw new Error('Direct account billing decision is unavailable')
+ }
+ await cacheAccountBillingDecisionOrThrow(
+ admission.billingRequestId,
+ usage.accountBillingDecision,
+ 'Unable to preserve direct account billing decision'
+ )
+ }
+
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.Ok)
span.setAttribute(TraceAttr.HttpStatusCode, 200)
return new NextResponse(null, { status: 200 })
diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts
index 6a81e6e1d88..17630d34be0 100644
--- a/apps/sim/app/api/copilot/chat/abort/route.ts
+++ b/apps/sim/app/api/copilot/chat/abort/route.ts
@@ -7,7 +7,6 @@ import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
-import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { withCopilotSpan, withIncomingGoSpan } from '@/lib/copilot/request/otel'
import {
@@ -15,8 +14,7 @@ import {
releasePendingChatStream,
waitForPendingChatStream,
} from '@/lib/copilot/request/session'
-import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
-import { env } from '@/lib/core/config/env'
+import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotChatAbortAPI')
@@ -80,37 +78,13 @@ export const POST = withRouteHandler((request: NextRequest) =>
let goAbortOk = false
try {
- const headers: Record = { 'Content-Type': 'application/json' }
- if (env.COPILOT_API_KEY) {
- headers['x-api-key'] = env.COPILOT_API_KEY
- }
- Object.assign(headers, getMothershipSourceEnvHeaders())
- const controller = new AbortController()
- const timeout = setTimeout(
- () => controller.abort('timeout:go_explicit_abort_fetch'),
- GO_EXPLICIT_ABORT_TIMEOUT_MS
- )
- const mothershipBaseURL = await getMothershipBaseURL({ userId: authenticatedUserId })
- const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
- method: 'POST',
- headers,
- signal: controller.signal,
- body: JSON.stringify({
- messageId: streamId,
- userId: authenticatedUserId,
- ...(chatId ? { chatId } : {}),
- ...(workspaceId ? { workspaceId } : {}),
- }),
- spanName: 'sim → go /api/streams/explicit-abort',
- operation: 'explicit_abort',
- attributes: {
- [TraceAttr.StreamId]: streamId,
- ...(chatId ? { [TraceAttr.ChatId]: chatId } : {}),
- },
- }).finally(() => clearTimeout(timeout))
- if (!response.ok) {
- throw new Error(`Explicit abort marker request failed: ${response.status}`)
- }
+ await requestExplicitStreamAbort({
+ streamId,
+ userId: authenticatedUserId,
+ chatId,
+ workspaceId,
+ timeoutMs: GO_EXPLICIT_ABORT_TIMEOUT_MS,
+ })
goAbortOk = true
} catch (err) {
logger.warn('Explicit abort marker request failed after local abort', {
diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts
index 798f86303ef..081f3c54ad4 100644
--- a/apps/sim/app/api/files/multipart/route.test.ts
+++ b/apps/sim/app/api/files/multipart/route.test.ts
@@ -53,17 +53,28 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
-const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
- mockCheckStorageQuota: vi.fn(),
- mockInitiateS3MultipartUpload: vi.fn(),
-}))
+const { mockCheckStorageQuota, mockInitiateS3MultipartUpload, mockResolveStorageBillingContext } =
+ vi.hoisted(() => ({
+ mockCheckStorageQuota: vi.fn(),
+ mockInitiateS3MultipartUpload: vi.fn(),
+ mockResolveStorageBillingContext: vi.fn(),
+ }))
vi.mock('@/lib/billing/storage', () => ({
- checkStorageQuota: mockCheckStorageQuota,
+ checkStorageQuotaForBillingContext: mockCheckStorageQuota,
+ resolveStorageBillingContext: mockResolveStorageBillingContext,
}))
import { POST } from '@/app/api/files/multipart/route'
+const STORAGE_CONTEXT = {
+ workspaceId: 'ws-1',
+ billedAccountUserId: 'workspace-owner',
+ billingEntity: { type: 'organization' as const, id: 'workspace-org' },
+ plan: 'team_25000',
+ customStorageLimitGB: null,
+}
+
const tokenPayload = {
uploadId: 'upload-1',
key: 'workspace/ws-1/123-abc-file.bin',
@@ -226,6 +237,7 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
mockGetStorageProvider.mockReturnValue('s3')
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
mockSignUploadToken.mockReturnValue('signed-token')
+ mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
})
@@ -258,7 +270,8 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
const response = await POST(res)
expect(response.status).toBe(200)
- expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 99999)
+ expect(mockResolveStorageBillingContext).toHaveBeenCalledWith('ws-1')
+ expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, 99999)
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts
index 1e570c3b9a9..2cf356d11d8 100644
--- a/apps/sim/app/api/files/multipart/route.ts
+++ b/apps/sim/app/api/files/multipart/route.ts
@@ -166,8 +166,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const config = getStorageConfig(storageContext)
if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) {
- const { checkStorageQuota } = await import('@/lib/billing/storage')
- const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0)
+ const { checkStorageQuotaForBillingContext, resolveStorageBillingContext } = await import(
+ '@/lib/billing/storage'
+ )
+ const storageBillingContext = await resolveStorageBillingContext(workspaceId)
+ const quotaCheck = await checkStorageQuotaForBillingContext(
+ storageBillingContext,
+ fileSize ?? 0
+ )
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
diff --git a/apps/sim/app/api/guardrails/validate/route.test.ts b/apps/sim/app/api/guardrails/validate/route.test.ts
index 5c4f91b9109..298f4cb0efa 100644
--- a/apps/sim/app/api/guardrails/validate/route.test.ts
+++ b/apps/sim/app/api/guardrails/validate/route.test.ts
@@ -4,12 +4,29 @@
import { createMockRequest, hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockAuthorizeCredentialUse, mockCheckActorUsageLimits, mockValidateHallucination } =
- vi.hoisted(() => ({
- mockAuthorizeCredentialUse: vi.fn(),
- mockCheckActorUsageLimits: vi.fn(),
- mockValidateHallucination: vi.fn(),
- }))
+const {
+ mockAuthorizeCredentialUse,
+ mockCheckActorUsageLimits,
+ mockCheckAttributedUsageLimits,
+ mockRequireBillingAttributionHeader,
+ mockResolveBillingAttribution,
+ mockSerializeBillingAttributionHeader,
+ mockToBillingContext,
+ mockValidateHallucination,
+ mockRecordUsage,
+ mockCheckAndBillPayerOverageThreshold,
+} = vi.hoisted(() => ({
+ mockAuthorizeCredentialUse: vi.fn(),
+ mockCheckActorUsageLimits: vi.fn(),
+ mockCheckAttributedUsageLimits: vi.fn(),
+ mockRequireBillingAttributionHeader: vi.fn(),
+ mockResolveBillingAttribution: vi.fn(),
+ mockSerializeBillingAttributionHeader: vi.fn(),
+ mockToBillingContext: vi.fn(),
+ mockValidateHallucination: vi.fn(),
+ mockRecordUsage: vi.fn(),
+ mockCheckAndBillPayerOverageThreshold: vi.fn(),
+}))
vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
@@ -19,6 +36,22 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: mockCheckActorUsageLimits,
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
+ requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
+ resolveBillingAttribution: mockResolveBillingAttribution,
+ serializeBillingAttributionHeader: mockSerializeBillingAttributionHeader,
+ toBillingContext: mockToBillingContext,
+}))
+
+vi.mock('@/lib/billing/core/usage-log', () => ({
+ recordUsage: mockRecordUsage,
+}))
+
+vi.mock('@/lib/billing/threshold-billing', () => ({
+ checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
+}))
+
vi.mock('@/lib/guardrails/validate_hallucination', () => ({
validateHallucination: mockValidateHallucination,
}))
@@ -56,6 +89,25 @@ describe('POST /api/guardrails/validate', () => {
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
})
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockResolveBillingAttribution.mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ mockRequireBillingAttributionHeader.mockReturnValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution')
+ mockToBillingContext.mockReturnValue({
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })
mockValidateHallucination.mockResolvedValue({ passed: true, score: 8 })
})
@@ -156,4 +208,62 @@ describe('POST /api/guardrails/validate', () => {
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
expect(mockValidateHallucination).toHaveBeenCalled()
})
+
+ it('bills a hosted hallucination check against the exact workspace payer', async () => {
+ mockValidateHallucination.mockResolvedValue({ passed: true, score: 8, cost: 0.01 })
+
+ const res = await POST(
+ createMockRequest('POST', {
+ validationType: 'hallucination',
+ input: 'test input',
+ knowledgeBaseId: 'kb-1',
+ model: 'gpt-4o',
+ workflowId: 'wf-1',
+ })
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockRecordUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ )
+ expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
+ type: 'organization',
+ id: 'org-1',
+ })
+ })
+
+ it('requires and forwards immutable attribution for internal hallucination checks', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+
+ const request = createMockRequest('POST', {
+ validationType: 'hallucination',
+ input: 'test input',
+ knowledgeBaseId: 'kb-1',
+ model: 'gpt-4o',
+ workflowId: 'wf-1',
+ })
+ const res = await POST(request)
+
+ expect(res.status).toBe(200)
+ expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(request.headers, {
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockValidateHallucination).toHaveBeenCalledWith(
+ expect.objectContaining({
+ authHeaders: expect.objectContaining({
+ billingAttribution: 'serialized-attribution',
+ }),
+ })
+ )
+ })
})
diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts
index f4dea3d3367..69ee4833220 100644
--- a/apps/sim/app/api/guardrails/validate/route.ts
+++ b/apps/sim/app/api/guardrails/validate/route.ts
@@ -4,8 +4,16 @@ import { type NextRequest, NextResponse } from 'next/server'
import { guardrailsValidateContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import {
+ type BillingAttributionSnapshot,
+ checkAttributedUsageLimits,
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+ serializeBillingAttributionHeader,
+ toBillingContext,
+} from '@/lib/billing/core/billing-attribution'
+import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateHallucination } from '@/lib/guardrails/validate_hallucination'
@@ -123,6 +131,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
let resolvedWorkspaceId: string | undefined
+ let billingAttribution: BillingAttributionSnapshot | undefined
if (validationType === 'hallucination' && model) {
if (!workflowId || typeof workflowId !== 'string') {
@@ -157,6 +166,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
resolvedWorkspaceId = authorization.workflow.workspaceId
+ billingAttribution =
+ auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(request.headers, {
+ actorUserId: auth.userId,
+ workspaceId: resolvedWorkspaceId,
+ })
+ : await resolveBillingAttribution({
+ actorUserId: auth.userId,
+ workspaceId: resolvedWorkspaceId,
+ })
try {
await assertPermissionsAllowed({
@@ -182,7 +201,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// Gate the actor's usage before incurring hosted LLM + RAG cost. In a normal
// workflow run this already passed at preprocessing; this also blocks direct
// calls to this route by an over-limit or frozen actor.
- const usage = await checkActorUsageLimits(auth.userId, resolvedWorkspaceId)
+ const usage = await checkAttributedUsageLimits(billingAttribution)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
@@ -218,6 +237,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const authHeaders = {
cookie: request.headers.get('cookie') || undefined,
authorization: request.headers.get('authorization') || undefined,
+ billingAttribution:
+ auth.authType === AuthType.INTERNAL_JWT && billingAttribution
+ ? serializeBillingAttributionHeader(billingAttribution)
+ : undefined,
}
const validationResult = await executeValidation(
@@ -248,31 +271,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
requestId
)
- // Bill the guardrail's LLM scoring cost (hallucination only; BYOK/non-hosted
- // already resolve to 0). Attributed to the caller + the workflow's workspace
- // so it lands in the per-member meter. Best-effort — never fail validation on
- // a billing error.
+ /**
+ * Hallucination scoring records the caller as actor and the workflow
+ * workspace as payer. BYOK and non-hosted validation resolve to zero cost.
+ */
if (
resolvedWorkspaceId &&
+ billingAttribution &&
typeof validationResult.cost === 'number' &&
validationResult.cost > 0
) {
const { recordUsage } = await import('@/lib/billing/core/usage-log')
- await recordUsage({
- userId: auth.userId,
- workspaceId: resolvedWorkspaceId,
- entries: [
- {
- category: 'model',
- source: 'workflow',
- description: `guardrail-hallucination:${model ?? 'unknown'}`,
- cost: validationResult.cost,
- sourceReference: `guardrail:${workflowId ?? 'unknown'}:${requestId}`,
- },
- ],
- }).catch((billingError) => {
+ try {
+ await recordUsage({
+ userId: auth.userId,
+ workspaceId: resolvedWorkspaceId,
+ ...toBillingContext(billingAttribution),
+ entries: [
+ {
+ category: 'model',
+ source: 'workflow',
+ description: `guardrail-hallucination:${model ?? 'unknown'}`,
+ cost: validationResult.cost,
+ sourceReference: `guardrail:${workflowId ?? 'unknown'}:${requestId}`,
+ },
+ ],
+ })
+ await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity)
+ } catch (billingError) {
logger.error(`[${requestId}] Failed to record guardrail usage`, { error: billingError })
- })
+ }
}
logger.info(`[${requestId}] Validation completed`, {
@@ -351,7 +379,7 @@ async function executeValidation(
piiEntityTypes: string[] | undefined,
piiMode: string | undefined,
piiLanguage: string | undefined,
- authHeaders: { cookie?: string; authorization?: string } | undefined,
+ authHeaders: { cookie?: string; authorization?: string; billingAttribution?: string } | undefined,
requestId: string
): Promise<{
passed: boolean
diff --git a/apps/sim/app/api/invitations/[id]/accept/route.test.ts b/apps/sim/app/api/invitations/[id]/accept/route.test.ts
new file mode 100644
index 00000000000..dd6f3833f2b
--- /dev/null
+++ b/apps/sim/app/api/invitations/[id]/accept/route.test.ts
@@ -0,0 +1,88 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockAcceptInvitation, mockGetSession } = vi.hoisted(() => ({
+ mockAcceptInvitation: vi.fn(),
+ mockGetSession: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/invitations/core', () => ({
+ acceptInvitation: mockAcceptInvitation,
+}))
+
+import { POST } from '@/app/api/invitations/[id]/accept/route'
+
+function createInvitationRequest() {
+ return createMockRequest(
+ 'POST',
+ { token: 'tok-1' },
+ {
+ 'user-agent': 'InvitationRouteTest/1.0',
+ 'x-forwarded-for': '203.0.113.10',
+ },
+ 'http://localhost/api/invitations/inv-1/accept'
+ )
+}
+
+describe('POST /api/invitations/[id]/accept', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({
+ user: {
+ id: 'invitee-user',
+ name: 'Invitee User',
+ email: 'invitee@example.com',
+ },
+ })
+ })
+
+ it('returns 409 when an organization invitation conflicts with existing membership', async () => {
+ mockAcceptInvitation.mockResolvedValue({
+ success: false,
+ kind: 'already-in-organization',
+ })
+
+ const response = await POST(createInvitationRequest(), {
+ params: Promise.resolve({ id: 'inv-1' }),
+ })
+
+ expect(response.status).toBe(409)
+ await expect(response.json()).resolves.toEqual({ error: 'already-in-organization' })
+ })
+
+ it('forwards actor identity and request provenance to post-commit acceptance effects', async () => {
+ mockAcceptInvitation.mockResolvedValue({
+ success: true,
+ invitation: {
+ id: 'inv-1',
+ kind: 'workspace',
+ organizationId: 'org-1',
+ },
+ acceptedWorkspaceIds: ['workspace-1'],
+ redirectPath: '/workspace/workspace-1/home',
+ membershipAlreadyExists: false,
+ })
+ const request = createInvitationRequest()
+
+ const response = await POST(request, {
+ params: Promise.resolve({ id: 'inv-1' }),
+ })
+
+ expect(response.status).toBe(200)
+ expect(mockAcceptInvitation).toHaveBeenCalledWith({
+ userId: 'invitee-user',
+ userEmail: 'invitee@example.com',
+ actorName: 'Invitee User',
+ invitationId: 'inv-1',
+ token: 'tok-1',
+ request,
+ })
+ })
+})
diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts
index abbd16f1656..6fb0af7972f 100644
--- a/apps/sim/app/api/invitations/[id]/accept/route.ts
+++ b/apps/sim/app/api/invitations/[id]/accept/route.ts
@@ -1,4 +1,3 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
@@ -25,8 +24,10 @@ export const POST = withRouteHandler(
const result = await acceptInvitation({
userId: session.user.id,
userEmail: session.user.email,
+ actorName: session.user.name ?? undefined,
invitationId: id,
token: parsed.data.body.token ?? null,
+ request,
})
if (!result.success) {
@@ -48,29 +49,6 @@ export const POST = withRouteHandler(
const inv = result.invitation
- recordAudit({
- workspaceId: result.acceptedWorkspaceIds[0] ?? null,
- actorId: session.user.id,
- actorName: session.user.name ?? undefined,
- actorEmail: session.user.email ?? undefined,
- action:
- inv.kind === 'workspace'
- ? AuditAction.INVITATION_ACCEPTED
- : AuditAction.ORG_INVITATION_ACCEPTED,
- resourceType:
- inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION,
- resourceId: inv.organizationId ?? result.acceptedWorkspaceIds[0] ?? inv.id,
- description: `Accepted ${inv.kind} invitation for ${inv.email}`,
- metadata: {
- invitationId: inv.id,
- targetEmail: inv.email,
- targetRole: inv.role,
- kind: inv.kind,
- workspaceIds: result.acceptedWorkspaceIds,
- },
- request,
- })
-
return NextResponse.json({
success: true,
redirectPath: result.redirectPath,
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts
index f904b9b5d35..4f8f7e7fca4 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts
@@ -11,7 +11,7 @@ import {
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockDbChain, mockValidateConfig } = vi.hoisted(() => {
+const { mockDbChain, mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
@@ -29,6 +29,7 @@ const { mockDbChain, mockValidateConfig } = vi.hoisted(() => {
}
return {
mockDbChain: chain,
+ mockHasWorkspaceLiveSyncAccess: vi.fn(),
mockValidateConfig: vi.fn(),
}
})
@@ -50,6 +51,9 @@ vi.mock('@/lib/knowledge/tags/service', () => ({
vi.mock('@/lib/knowledge/documents/service', () => ({
deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined),
}))
+vi.mock('@/lib/billing/core/subscription', () => ({
+ hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess,
+}))
vi.mock('@sim/audit', () => auditMock)
import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
@@ -179,10 +183,10 @@ describe('Knowledge Connector By ID API Route', () => {
expect(response.status).toBe(404)
})
- it('returns 200 and updates status', async () => {
+ it('allows a free external actor to enable live sync for a Max workspace', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
- userId: 'user-1',
+ userId: 'free-external-admin',
userName: 'Test',
userEmail: 'test@test.com',
})
@@ -190,17 +194,37 @@ describe('Knowledge Connector By ID API Route', () => {
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
+ mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true)
- const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
+ const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 }
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
- const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
+ const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.status).toBe('paused')
+ expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-1')
+ })
+
+ it('denies a paid actor when the knowledge base workspace is free', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'paid-external-admin',
+ })
+ mockCheckWriteAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: { workspaceId: 'ws-free', name: 'Free KB' },
+ })
+ mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false)
+
+ const req = createMockRequest('PATCH', { syncIntervalMinutes: 5 })
+ const response = await PATCH(req, { params: mockParams })
+
+ expect(response.status).toBe(403)
+ expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-free')
})
})
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
index 2e5bb4ee6cf..ea6bc0bc89a 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
@@ -14,7 +14,7 @@ import { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
+import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
@@ -113,7 +113,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
body.syncIntervalMinutes > 0 &&
body.syncIntervalMinutes < 60
) {
- const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
+ const workspaceId = writeCheck.knowledgeBase.workspaceId
+ if (!workspaceId) {
+ return NextResponse.json(
+ { error: 'Knowledge base is missing workspace billing context' },
+ { status: 409 }
+ )
+ }
+ const canUseLiveSync = await hasWorkspaceLiveSyncAccess(workspaceId)
if (!canUseLiveSync) {
return NextResponse.json(
{ error: 'Live sync requires a Max or Enterprise plan' },
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts
index b61ae4c7f27..ed4104cfb6c 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts
@@ -11,7 +11,7 @@ import {
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockDispatchSync, mockDbChain } = vi.hoisted(() => {
+const { mockDispatchSync, mockDbChain, mockResolveBillingAttribution } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
@@ -24,6 +24,7 @@ const { mockDispatchSync, mockDbChain } = vi.hoisted(() => {
return {
mockDispatchSync: vi.fn().mockResolvedValue(undefined),
mockDbChain: chain,
+ mockResolveBillingAttribution: vi.fn(),
}
})
@@ -31,7 +32,11 @@ const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWrit
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
-vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ requireBillingAttributionHeader: vi.fn(),
+ resolveBillingAttribution: mockResolveBillingAttribution,
+}))
+vi.mock('@/lib/knowledge/connectors/queue', () => ({
dispatchSync: mockDispatchSync,
}))
vi.mock('@sim/audit', () => auditMock)
@@ -94,9 +99,22 @@ describe('Connector Manual Sync API Route', () => {
})
it('dispatches sync on valid request', async () => {
+ const billingAttribution = {
+ actorUserId: 'external-admin',
+ workspaceId: 'ws-1',
+ organizationId: null,
+ billedAccountUserId: 'owner-1',
+ billingEntity: { type: 'user' as const, id: 'owner-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ }
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
- userId: 'user-1',
+ authType: 'session',
+ userId: 'external-admin',
userName: 'Test',
userEmail: 'test@test.com',
})
@@ -105,6 +123,7 @@ describe('Connector Manual Sync API Route', () => {
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
+ mockResolveBillingAttribution.mockResolvedValue(billingAttribution)
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
@@ -112,6 +131,13 @@ describe('Connector Manual Sync API Route', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(true)
- expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'external-admin',
+ workspaceId: 'ws-1',
+ })
+ expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', {
+ billingAttribution,
+ requestId: 'test-req-id',
+ })
})
})
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts
index 8b270ef5472..9ef9cae6099 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts
@@ -6,10 +6,14 @@ import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import {
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
+import { dispatchSync } from '@/lib/knowledge/connectors/queue'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
@@ -59,9 +63,26 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 })
}
+ const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId
+ if (!kbWorkspaceId) {
+ return NextResponse.json(
+ { error: 'Knowledge base is missing workspace billing context' },
+ { status: 409 }
+ )
+ }
+ const billingAttribution =
+ auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(request.headers, {
+ actorUserId: auth.userId,
+ workspaceId: kbWorkspaceId,
+ })
+ : await resolveBillingAttribution({
+ actorUserId: auth.userId,
+ workspaceId: kbWorkspaceId,
+ })
+
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
- const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_synced',
@@ -93,7 +114,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
request,
})
- dispatchSync(connectorId, { requestId }).catch((error) => {
+ dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
error
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts
new file mode 100644
index 00000000000..2ac24c18df0
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts
@@ -0,0 +1,196 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ auditMock,
+ authOAuthUtilsMock,
+ createMockRequest,
+ hybridAuthMockFns,
+ knowledgeApiUtilsMock,
+ knowledgeApiUtilsMockFns,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCaptureServerEvent,
+ mockDbChain,
+ mockDispatchSync,
+ mockEncryptApiKey,
+ mockHasWorkspaceLiveSyncAccess,
+ mockResolveBillingAttribution,
+ mockValidateConfig,
+} = vi.hoisted(() => {
+ const chain = {
+ select: vi.fn().mockReturnThis(),
+ from: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ limit: vi.fn(),
+ execute: vi.fn(),
+ transaction: vi.fn(),
+ insert: vi.fn().mockReturnThis(),
+ values: vi.fn(),
+ }
+ return {
+ mockCaptureServerEvent: vi.fn(),
+ mockDbChain: chain,
+ mockDispatchSync: vi.fn(),
+ mockEncryptApiKey: vi.fn(),
+ mockHasWorkspaceLiveSyncAccess: vi.fn(),
+ mockResolveBillingAttribution: vi.fn(),
+ mockValidateConfig: vi.fn(),
+ }
+})
+
+const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
+
+vi.mock('@sim/db', () => ({ db: mockDbChain }))
+vi.mock('@sim/audit', () => auditMock)
+vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
+vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
+vi.mock('@/connectors/registry.server', () => ({
+ CONNECTOR_REGISTRY: {
+ test: {
+ auth: { mode: 'apiKey' },
+ validateConfig: mockValidateConfig,
+ },
+ },
+}))
+vi.mock('@/lib/api-key/crypto', () => ({
+ encryptApiKey: mockEncryptApiKey,
+}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ requireBillingAttributionHeader: vi.fn(),
+ resolveBillingAttribution: mockResolveBillingAttribution,
+}))
+vi.mock('@/lib/billing/core/subscription', () => ({
+ hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess,
+}))
+vi.mock('@/lib/knowledge/connectors/queue', () => ({
+ dispatchSync: mockDispatchSync,
+}))
+vi.mock('@/lib/knowledge/tags/service', () => ({
+ createTagDefinition: vi.fn(),
+}))
+vi.mock('@/lib/posthog/server', () => ({
+ captureServerEvent: mockCaptureServerEvent,
+}))
+
+import { POST } from '@/app/api/knowledge/[id]/connectors/route'
+
+const BILLING_ATTRIBUTION = {
+ actorUserId: 'free-external-admin',
+ workspaceId: 'workspace-paid',
+ organizationId: 'organization-paid',
+ billedAccountUserId: 'workspace-owner',
+ billingEntity: { type: 'organization' as const, id: 'organization-paid' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: {
+ id: 'subscription-paid',
+ referenceId: 'organization-paid',
+ plan: 'team_25000',
+ status: 'active',
+ seats: 5,
+ periodStart: '2026-07-01T00:00:00.000Z',
+ periodEnd: '2026-08-01T00:00:00.000Z',
+ },
+}
+
+describe('Knowledge Connectors API Route', () => {
+ const context = { params: Promise.resolve({ id: 'knowledge-base-1' }) }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDbChain.select.mockReturnThis()
+ mockDbChain.from.mockReturnThis()
+ mockDbChain.where.mockReturnThis()
+ mockDbChain.insert.mockReturnThis()
+ mockDbChain.execute.mockResolvedValue(undefined)
+ mockDbChain.values.mockResolvedValue(undefined)
+ mockDbChain.transaction.mockImplementation(
+ async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain)
+ )
+ mockDispatchSync.mockResolvedValue(undefined)
+ mockEncryptApiKey.mockResolvedValue({ encrypted: 'encrypted-api-key' })
+ mockValidateConfig.mockResolvedValue({ valid: true })
+ })
+
+ it('queues the authenticated actor with the paid workspace payer', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ authType: 'session',
+ userId: 'free-external-admin',
+ userName: 'External Admin',
+ userEmail: 'external@example.com',
+ })
+ mockCheckWriteAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: {
+ id: 'knowledge-base-1',
+ name: 'Paid KB',
+ workspaceId: 'workspace-paid',
+ },
+ })
+ mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true)
+ mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION)
+ mockDbChain.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([
+ {
+ id: 'connector-1',
+ knowledgeBaseId: 'knowledge-base-1',
+ connectorType: 'test',
+ status: 'active',
+ },
+ ])
+
+ const request = createMockRequest('POST', {
+ connectorType: 'test',
+ apiKey: 'api-key',
+ sourceConfig: {},
+ syncIntervalMinutes: 5,
+ })
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(201)
+ expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-paid')
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'free-external-admin',
+ workspaceId: 'workspace-paid',
+ })
+ expect(mockDispatchSync).toHaveBeenCalledWith(expect.any(String), {
+ billingAttribution: BILLING_ATTRIBUTION,
+ requestId: expect.any(String),
+ })
+ })
+
+ it('denies a paid actor when the workspace payer lacks Max access', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ authType: 'session',
+ userId: 'paid-external-admin',
+ })
+ mockCheckWriteAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: {
+ id: 'knowledge-base-1',
+ name: 'Free KB',
+ workspaceId: 'workspace-free',
+ },
+ })
+ mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false)
+
+ const request = createMockRequest('POST', {
+ connectorType: 'test',
+ apiKey: 'api-key',
+ sourceConfig: {},
+ syncIntervalMinutes: 5,
+ })
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(403)
+ expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-free')
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockDispatchSync).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts
index 8cc3cc59621..b7df3198990 100644
--- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts
@@ -8,11 +8,15 @@ import { type NextRequest, NextResponse } from 'next/server'
import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { encryptApiKey } from '@/lib/api-key/crypto'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
+import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import {
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
+import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
+import { dispatchSync } from '@/lib/knowledge/connectors/queue'
import { allocateTagSlots } from '@/lib/knowledge/constants'
import { createTagDefinition } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -97,8 +101,16 @@ export const POST = withRouteHandler(
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } =
parsed.data.body
+ const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId
+ if (!kbWorkspaceId) {
+ return NextResponse.json(
+ { error: 'Knowledge base is missing workspace billing context' },
+ { status: 409 }
+ )
+ }
+
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
- const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
+ const canUseLiveSync = await hasWorkspaceLiveSyncAccess(kbWorkspaceId)
if (!canUseLiveSync) {
return NextResponse.json(
{ error: 'Live sync requires a Max or Enterprise plan' },
@@ -107,6 +119,17 @@ export const POST = withRouteHandler(
}
}
+ const billingAttribution =
+ auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(request.headers, {
+ actorUserId: auth.userId,
+ workspaceId: kbWorkspaceId,
+ })
+ : await resolveBillingAttribution({
+ actorUserId: auth.userId,
+ workspaceId: kbWorkspaceId,
+ })
+
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
if (!connectorConfig) {
return NextResponse.json(
@@ -258,7 +281,6 @@ export const POST = withRouteHandler(
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
- const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_added',
@@ -294,7 +316,7 @@ export const POST = withRouteHandler(
request,
})
- dispatchSync(connectorId, { requestId }).catch((error) => {
+ dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
error
diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts
index 26e76cbb3fe..7e08b67fa71 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts
@@ -387,7 +387,8 @@ describe('Document By ID API Route', () => {
fileSize: failedDocument.fileSize,
mimeType: failedDocument.mimeType,
},
- expect.any(String)
+ expect.any(String),
+ undefined
)
})
diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts
index 65b9d940857..7acdc821391 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts
@@ -3,7 +3,11 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import {
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
@@ -155,12 +159,25 @@ export const PUT = withRouteHandler(
fileSize: doc.fileSize,
mimeType: doc.mimeType,
}
+ const workspaceId = accessCheck.knowledgeBase?.workspaceId
+ const billingAttribution = workspaceId
+ ? auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(req.headers, {
+ actorUserId: userId,
+ workspaceId,
+ })
+ : await resolveBillingAttribution({
+ actorUserId: userId,
+ workspaceId,
+ })
+ : undefined
const result = await retryDocumentProcessing(
knowledgeBaseId,
documentId,
docData,
- requestId
+ requestId,
+ billingAttribution
)
return NextResponse.json({
diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts
index 82cd8f6bfa4..9025cea9891 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts
@@ -12,8 +12,13 @@ import {
} from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ checkAttributedUsageLimits,
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
bulkDocumentOperation,
@@ -176,13 +181,26 @@ export const POST = withRouteHandler(
}
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId
-
- // Gate KB indexing (pooled + per-member) before accepting work. Runs even for
- // legacy KBs with no workspace — the uploader's pooled/frozen status is still
- // enforced (per-member is simply skipped when there's no org workspace). The
- // authoritative backstop also runs in processDocumentAsync for non-HTTP paths
- // (connector/cron/retry).
- const usage = await checkActorUsageLimits(userId, kbWorkspaceId)
+ const billingAttribution = kbWorkspaceId
+ ? auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(req.headers, {
+ actorUserId: userId,
+ workspaceId: kbWorkspaceId,
+ })
+ : await resolveBillingAttribution({
+ actorUserId: userId,
+ workspaceId: kbWorkspaceId,
+ })
+ : undefined
+
+ /**
+ * Gate the workspace payer and uploader before accepting indexing work.
+ * Legacy workspace-less KBs retain account-only enforcement; asynchronous
+ * connector, cron, and retry paths apply the same backstop.
+ */
+ const usage = billingAttribution
+ ? await checkAttributedUsageLimits(billingAttribution)
+ : await checkActorUsageLimits(userId)
if (usage.isExceeded) {
return NextResponse.json(
{
@@ -235,7 +253,8 @@ export const POST = withRouteHandler(
createdDocuments,
knowledgeBaseId,
body.processingOptions ?? {},
- requestId
+ requestId,
+ billingAttribution
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts
index 4309eb040d6..3feda21aa92 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts
@@ -31,6 +31,15 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ resolveBillingAttribution: vi.fn().mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ }),
+ checkAttributedUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
+}))
+
vi.mock('@/lib/knowledge/documents/service', () => ({
createDocumentRecords: vi.fn(),
deleteDocument: vi.fn(),
diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts
index d1c3af79f73..f1d6235af6e 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts
@@ -9,8 +9,13 @@ import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ checkAttributedUsageLimits,
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createDocumentRecords,
@@ -73,12 +78,26 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- // Gate usage before any create/delete so an over-limit upsert is rejected up
- // front and never deletes the existing (already-indexed) document. Runs even
- // for legacy KBs with no workspace — the uploader's pooled/frozen status is
- // still enforced (per-member is skipped when there's no org workspace).
+ /**
+ * Gate the workspace payer and uploader before mutation so an over-limit
+ * upsert cannot delete an indexed document. Workspace-less legacy KBs
+ * retain account-only enforcement.
+ */
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId
- const usage = await checkActorUsageLimits(userId, kbWorkspaceId)
+ const billingAttribution = kbWorkspaceId
+ ? auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(req.headers, {
+ actorUserId: userId,
+ workspaceId: kbWorkspaceId,
+ })
+ : await resolveBillingAttribution({
+ actorUserId: userId,
+ workspaceId: kbWorkspaceId,
+ })
+ : undefined
+ const usage = billingAttribution
+ ? await checkAttributedUsageLimits(billingAttribution)
+ : await checkActorUsageLimits(userId)
if (usage.isExceeded) {
return NextResponse.json(
{
@@ -175,7 +194,8 @@ export const POST = withRouteHandler(
createdDocuments,
knowledgeBaseId,
validatedData.processingOptions ?? {},
- requestId
+ requestId,
+ billingAttribution
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts
index 7c4111d46d1..fcec6ace21e 100644
--- a/apps/sim/app/api/knowledge/connectors/sync/route.ts
+++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts
@@ -4,10 +4,11 @@ import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, lte } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
+import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
+import { dispatchSync } from '@/lib/knowledge/connectors/queue'
export const dynamic = 'force-dynamic'
@@ -69,6 +70,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const dueConnectors = await db
.select({
id: knowledgeConnector.id,
+ workspaceId: knowledgeBase.workspaceId,
})
.from(knowledgeConnector)
.innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id))
@@ -94,11 +96,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
}
- await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, (connector) =>
- dispatchSync(connector.id, { requestId }).catch((error) => {
+ await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => {
+ try {
+ if (!connector.workspaceId) {
+ throw new Error(`Connector ${connector.id} is missing workspace billing context`)
+ }
+ const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId)
+ await dispatchSync(connector.id, { billingAttribution, requestId })
+ } catch (error) {
logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error)
- })
- )
+ }
+ })
return NextResponse.json({
success: true,
diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts
index f7451d46c17..385fd1a3ac1 100644
--- a/apps/sim/app/api/knowledge/search/route.test.ts
+++ b/apps/sim/app/api/knowledge/search/route.test.ts
@@ -333,6 +333,74 @@ describe('Knowledge Search API Route', () => {
})
})
+ it('fails before embedding work when an internal workspace request omits attribution', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
+ success: true,
+ userId: 'user-123',
+ authType: 'internal_jwt',
+ })
+ mockCheckKnowledgeBaseAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: {
+ id: 'kb-123',
+ userId: 'user-123',
+ workspaceId: 'workspace-123',
+ embeddingModel: 'text-embedding-3-small',
+ },
+ })
+
+ const req = createMockRequest('POST', {
+ ...validSearchData,
+ skipUsageBilling: true,
+ })
+ const response = await POST(req)
+
+ expect(response.status).toBe(500)
+ expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled()
+ })
+
+ it('uses the immutable header for an internal unmetered workspace search', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
+ success: true,
+ userId: 'user-123',
+ authType: 'internal_jwt',
+ })
+ mockCheckKnowledgeBaseAccess.mockResolvedValue({
+ hasAccess: true,
+ knowledgeBase: {
+ id: 'kb-123',
+ userId: 'user-123',
+ workspaceId: 'workspace-123',
+ embeddingModel: 'text-embedding-3-small',
+ },
+ })
+ mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
+ const attribution = encodeURIComponent(
+ JSON.stringify({
+ actorUserId: 'user-123',
+ workspaceId: 'workspace-123',
+ organizationId: 'organization-123',
+ billedAccountUserId: 'owner-123',
+ billingEntity: { type: 'organization', id: 'organization-123' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ })
+ )
+
+ const req = createMockRequest(
+ 'POST',
+ { ...validSearchData, skipUsageBilling: true },
+ { 'x-sim-billing-attribution': attribution }
+ )
+ const response = await POST(req)
+
+ expect(response.status).toBe(200)
+ expect(mockGenerateSearchEmbedding).toHaveBeenCalledOnce()
+ })
+
it.concurrent('should return unauthorized for unauthenticated request', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts
index 021f3059e36..aa337524f6a 100644
--- a/apps/sim/app/api/knowledge/search/route.ts
+++ b/apps/sim/app/api/knowledge/search/route.ts
@@ -6,6 +6,16 @@ import { knowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge'
import { parseJsonBody, validationErrorResponse } from '@/lib/api/server'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ checkAttributedUsageLimits,
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+ toBillingContext,
+} from '@/lib/billing/core/billing-attribution'
+import {
+ checkAndBillOverageThreshold,
+ checkAndBillPayerOverageThreshold,
+} from '@/lib/billing/threshold-billing'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -198,12 +208,54 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const accessibleKbs = accessChecks
.filter((ac): ac is KnowledgeBaseAccessResult => Boolean(ac?.hasAccess))
.map((ac) => ac.knowledgeBase)
- const workspaceId = accessibleKbs[0]?.workspaceId
-
const useReranker = validatedData.rerankerEnabled && Boolean(validatedData.query?.trim())
const rerankerModel = useReranker ? validatedData.rerankerModel : null
const hasQuery = validatedData.query && validatedData.query.trim().length > 0
+ const workspaceIds = new Set(accessibleKbs.map((kb) => kb.workspaceId ?? null))
+ if (hasQuery && workspaceIds.size > 1) {
+ return NextResponse.json(
+ { error: 'Selected knowledge bases must belong to the same workspace' },
+ { status: 400 }
+ )
+ }
+ const workspaceId = accessibleKbs[0]?.workspaceId
+
+ if (workflowId) {
+ const authorization = await authorizeWorkflowByWorkspacePermission({
+ workflowId: workflowId as string,
+ userId,
+ action: 'read',
+ })
+ const workflowWorkspaceId = authorization.workflow?.workspaceId ?? null
+ if (
+ workflowWorkspaceId &&
+ accessChecks.some(
+ (accessCheck) =>
+ accessCheck?.hasAccess && accessCheck.knowledgeBase?.workspaceId !== workflowWorkspaceId
+ )
+ ) {
+ return NextResponse.json(
+ { error: 'Knowledge base does not belong to the workflow workspace' },
+ { status: 400 }
+ )
+ }
+ }
+
+ const billingAttribution =
+ hasQuery && workspaceId
+ ? auth.authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(request.headers, {
+ actorUserId: userId,
+ workspaceId,
+ })
+ : shouldMeter
+ ? await resolveBillingAttribution({
+ actorUserId: userId,
+ workspaceId,
+ })
+ : undefined
+ : undefined
const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel)))
if (hasQuery && embeddingModels.length > 1) {
return NextResponse.json(
@@ -225,11 +277,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- // Gate the actor before incurring hosted embedding cost, unless this is the
- // internal workflow tool (already gated at preprocessing, rolls cost up). Tag-only
- // search is free, so only the query path is gated.
+ /**
+ * Gate the workspace payer and actor before hosted embedding cost. Internal
+ * workflow tools were gated during preprocessing, and tag-only search is free.
+ */
if (shouldMeter && hasQuery) {
- const usage = await checkActorUsageLimits(userId, workspaceId)
+ const usage = billingAttribution
+ ? await checkAttributedUsageLimits(billingAttribution)
+ : await checkActorUsageLimits(userId)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
@@ -242,27 +297,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
? generateSearchEmbedding(validatedData.query!, queryEmbeddingModel, workspaceId)
: Promise.resolve(null)
- if (workflowId) {
- const authorization = await authorizeWorkflowByWorkspacePermission({
- workflowId: workflowId as string,
- userId,
- action: 'read',
- })
- const workflowWorkspaceId = authorization.workflow?.workspaceId ?? null
- if (
- workflowWorkspaceId &&
- accessChecks.some(
- (accessCheck) =>
- accessCheck?.hasAccess && accessCheck.knowledgeBase?.workspaceId !== workflowWorkspaceId
- )
- ) {
- return NextResponse.json(
- { error: 'Knowledge base does not belong to the workflow workspace' },
- { status: 400 }
- )
- }
- }
-
let results: SearchResult[]
const hasFilters = structuredFilters && structuredFilters.length > 0
@@ -420,23 +454,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// guardrail RAG). The workflow tool sets skipUsageBilling and rolls the cost
// up via the executor instead, so this never double-bills; BYOK already
// resolved to 0 above.
- if (shouldMeter && workspaceId && cost && cost.total > 0) {
+ if (shouldMeter && cost && cost.total > 0) {
const { recordUsage } = await import('@/lib/billing/core/usage-log')
- await recordUsage({
- userId,
- workspaceId,
- entries: [
- {
- category: 'model',
- source: 'knowledge-base',
- description: queryEmbeddingModel,
- cost: cost.total,
- sourceReference: `kb-search:${requestId}`,
- },
- ],
- }).catch((billingError) => {
+ try {
+ await recordUsage({
+ userId,
+ workspaceId: workspaceId ?? undefined,
+ ...(billingAttribution ? toBillingContext(billingAttribution) : {}),
+ entries: [
+ {
+ category: 'model',
+ source: 'knowledge-base',
+ description: queryEmbeddingModel,
+ cost: cost.total,
+ sourceReference: `kb-search:${requestId}`,
+ },
+ ],
+ })
+ if (billingAttribution) {
+ await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity)
+ } else {
+ await checkAndBillOverageThreshold(userId)
+ }
+ } catch (billingError) {
logger.error(`[${requestId}] Failed to record KB search usage`, { error: billingError })
- })
+ }
}
const tagDefsResults = await Promise.all(
diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts
index b8297e4cf65..3bf7f5ce352 100644
--- a/apps/sim/app/api/knowledge/utils.test.ts
+++ b/apps/sim/app/api/knowledge/utils.test.ts
@@ -26,6 +26,33 @@ vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('user1'),
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ assertBillingAttributionSnapshot: vi.fn((value) => value),
+ checkAttributedUsageLimits: vi.fn().mockResolvedValue({
+ isExceeded: false,
+ payerUsage: { currentUsage: 0, limit: 100 },
+ }),
+ resolveBillingAttribution: vi.fn().mockResolvedValue({
+ actorUserId: 'billing-user-1',
+ billedAccountUserId: 'billing-user-1',
+ billingEntity: { type: 'user', id: 'billing-user-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ organizationId: null,
+ payerSubscription: null,
+ workspaceId: 'workspace1',
+ }),
+ toBillingContext: vi.fn(() => ({
+ billingEntity: { type: 'user', id: 'billing-user-1' },
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })),
+}))
+
vi.mock('@/lib/knowledge/documents/document-processor', () => ({
processDocument: vi.fn().mockResolvedValue({
chunks: [
@@ -256,7 +283,19 @@ describe('Knowledge Utils', () => {
fileSize: 10,
mimeType: 'text/plain',
},
- {}
+ {},
+ {
+ actorUserId: 'billing-user-1',
+ billedAccountUserId: 'billing-user-1',
+ billingEntity: { type: 'user', id: 'billing-user-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ organizationId: null,
+ payerSubscription: null,
+ workspaceId: 'workspace1',
+ }
)
// Embeddings are inserted first, then the document counter update. A
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
index 36ecea6032c..f0af2021175 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
@@ -14,23 +14,53 @@ import {
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockGenerateInternalToken, fetchMock, mockIsWorkspaceApiExecutionEntitled } = vi.hoisted(
- () => ({
- mockGenerateInternalToken: vi.fn(),
- fetchMock: vi.fn(),
- mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
- })
-)
+const {
+ mockAssertBillingAttributionSnapshot,
+ mockGenerateInternalToken,
+ 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', () => ({
+ BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution',
+ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
+ resolveBillingAttribution: mockResolveBillingAttribution,
+ serializeBillingAttributionHeader: mockSerializeBillingAttributionHeader,
+}))
+
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
const MCP_BYTE_LIMIT = 10 * 1024 * 1024
const MCP_TOOLS_LIST_LIMIT = 100
+function createBillingAttribution(actorUserId: string, workspaceId: string) {
+ return {
+ actorUserId,
+ workspaceId,
+ organizationId: null,
+ billedAccountUserId: 'payer-1',
+ billingEntity: { type: 'user' as const, id: 'payer-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ }
+}
+
vi.mock('@sim/db', () => dbChainMock)
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
@@ -63,6 +93,12 @@ describe('MCP Serve Route', () => {
vi.clearAllMocks()
resetDbChainMock()
vi.stubGlobal('fetch', fetchMock)
+ mockResolveBillingAttribution.mockImplementation(
+ ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) =>
+ Promise.resolve(createBillingAttribution(actorUserId, workspaceId))
+ )
+ mockAssertBillingAttributionSnapshot.mockImplementation((value: unknown) => value)
+ mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution')
})
afterEach(() => {
@@ -242,7 +278,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
@@ -277,8 +313,13 @@ describe('MCP Serve Route', () => {
const headers = fetchOptions.headers as Record
expect(headers.Authorization).toBe('Bearer internal-token-user-1')
expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user')
+ expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution')
expect(headers['X-API-Key']).toBeUndefined()
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ })
})
it('forwards internal token for private server session auth', async () => {
@@ -293,7 +334,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
@@ -326,8 +367,127 @@ describe('MCP Serve Route', () => {
const headers = fetchOptions.headers as Record
expect(headers.Authorization).toBe('Bearer internal-token-user-1')
expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined()
+ expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution')
expect(headers['X-API-Key']).toBeUndefined()
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+ })
+
+ it('replaces caller-supplied attribution for public workflow tools', async () => {
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([
+ {
+ id: 'server-1',
+ name: 'Public Server',
+ workspaceId: 'ws-1',
+ isPublic: true,
+ createdBy: 'owner-1',
+ },
+ ])
+ .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
+ mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1')
+ fetchMock.mockResolvedValueOnce(
+ new Response(JSON.stringify({ output: { ok: true } }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ )
+
+ const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
+ method: 'POST',
+ headers: { 'x-sim-billing-attribution': 'caller-controlled-attribution' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'tool_a' },
+ }),
+ })
+ const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
+ expect(response.status).toBe(200)
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'owner-1',
+ workspaceId: 'ws-1',
+ })
+ const attribution = createBillingAttribution('owner-1', 'ws-1')
+ expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(attribution)
+ expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(attribution)
+ const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
+ const headers = fetchOptions.headers as Record
+ expect(headers.Authorization).toBe('Bearer internal-token-owner-1')
+ expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution')
+ expect(headers['x-sim-billing-attribution']).not.toBe('caller-controlled-attribution')
+ })
+
+ it.each([null, 'ws-other'])(
+ 'fails closed when a workflow tool has invalid workspace scope: %s',
+ async (workflowWorkspaceId) => {
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([
+ {
+ id: 'server-1',
+ name: 'Public Server',
+ workspaceId: 'ws-1',
+ isPublic: true,
+ createdBy: 'owner-1',
+ },
+ ])
+ .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: workflowWorkspaceId }])
+
+ const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
+ method: 'POST',
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'tool_a' },
+ }),
+ })
+ const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
+ expect(response.status).toBe(403)
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(fetchMock).not.toHaveBeenCalled()
+ }
+ )
+
+ it('fails closed when resolved attribution does not match the bridge scope', async () => {
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([
+ {
+ id: 'server-1',
+ name: 'Public Server',
+ workspaceId: 'ws-1',
+ isPublic: true,
+ createdBy: 'owner-1',
+ },
+ ])
+ .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
+ mockResolveBillingAttribution.mockResolvedValueOnce(
+ createBillingAttribution('different-actor', 'ws-1')
+ )
+
+ const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
+ method: 'POST',
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'tool_a' },
+ }),
+ })
+ const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+
+ expect(response.status).toBe(500)
+ expect(mockSerializeBillingAttributionHeader).not.toHaveBeenCalled()
+ expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects oversized MCP request bodies before parsing JSON', async () => {
@@ -432,7 +592,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockResolvedValueOnce(
new Response(
new ReadableStream({
@@ -476,7 +636,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockResolvedValueOnce(
new Response(
new ReadableStream({
@@ -523,7 +683,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
@@ -558,6 +718,50 @@ describe('MCP Serve Route', () => {
expect(headers['X-Sim-MCP-Tool-Call']).toBe('true')
})
+ it('preserves downstream attributed usage admission rejections', async () => {
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([
+ {
+ id: 'server-1',
+ name: 'Public Server',
+ workspaceId: 'ws-1',
+ isPublic: true,
+ createdBy: 'owner-1',
+ },
+ ])
+ .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
+ fetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ success: false,
+ error: 'Workspace usage limit exceeded.',
+ }),
+ {
+ status: 402,
+ headers: { 'Content-Type': 'application/json' },
+ }
+ )
+ )
+
+ const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
+ method: 'POST',
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'tool_a' },
+ }),
+ })
+
+ const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
+ const body = await response.json()
+
+ expect(response.status).toBe(402)
+ expect(body.error.message).toBe('Workspace usage limit exceeded.')
+ expect(body.error.data.httpStatus).toBe(402)
+ })
+
it('preserves upstream error status when workflow response is not JSON', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([
@@ -570,7 +774,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 }))
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
@@ -603,7 +807,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ success: true, output: false }), {
status: 200,
@@ -640,7 +844,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), {
status: 200,
@@ -677,7 +881,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
userId: 'user-1',
@@ -757,7 +961,7 @@ describe('MCP Serve Route', () => {
},
])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
- .mockResolvedValueOnce([{ isDeployed: true }])
+ .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }])
fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
const signal = init.signal as AbortSignal
return new Promise((_resolve, reject) => {
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts
index e8582336bba..5186d6c3410 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts
@@ -34,6 +34,13 @@ import {
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
isWorkspaceApiExecutionEntitled,
} from '@/lib/billing/core/api-access'
+import {
+ assertBillingAttributionSnapshot,
+ BILLING_ATTRIBUTION_HEADER,
+ type BillingAttributionSnapshot,
+ resolveBillingAttribution,
+ serializeBillingAttributionHeader,
+} from '@/lib/billing/core/billing-attribution'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
assertContentLengthWithinLimit,
@@ -242,7 +249,7 @@ function hasResponseField(value: Record, property: string): boo
}
function getWorkflowErrorStatus(status: number): number {
- return [400, 401, 403, 404, 408, 409, 413, 429, 499, 503].includes(status) ? status : 500
+ return [400, 401, 402, 403, 404, 408, 409, 413, 429, 499, 503].includes(status) ? status : 500
}
function getWorkflowErrorCode(status: number, executeResult: Record): ErrorCode {
@@ -312,6 +319,23 @@ async function getServer(serverId: string) {
type WorkflowMcpServeServer = NonNullable>>
+/**
+ * Captures attribution at the trusted MCP boundary. The workflow execute route
+ * remains the single attributed usage and reservation gate.
+ */
+async function resolveWorkflowMcpBillingAttribution(
+ actorUserId: string,
+ workspaceId: string
+): Promise {
+ const attribution = assertBillingAttributionSnapshot(
+ await resolveBillingAttribution({ actorUserId, workspaceId })
+ )
+ if (attribution.actorUserId !== actorUserId || attribution.workspaceId !== workspaceId) {
+ throw new Error('Resolved MCP billing attribution does not match the execution scope')
+ }
+ return attribution
+}
+
async function authorizeMcpServeRequest(
request: NextRequest,
server: WorkflowMcpServeServer,
@@ -511,6 +535,7 @@ export const POST = withRouteHandler(
return handleToolsCall(
id,
serverId,
+ server.workspaceId,
paramsValidation.data,
executeAuthContext,
server.isPublic ? server.createdBy : undefined,
@@ -680,6 +705,7 @@ async function handleToolsList(
async function handleToolsCall(
id: RequestId,
serverId: string,
+ serverWorkspaceId: string,
params: { name: string; arguments?: Record } | undefined,
executeAuthContext?: ExecuteAuthContext | null,
publicServerOwnerId?: string,
@@ -738,7 +764,7 @@ async function handleToolsCall(
}
const [wf] = await db
- .select({ isDeployed: workflow.isDeployed })
+ .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId })
.from(workflow)
.where(and(eq(workflow.id, tool.workflowId), isNull(workflow.archivedAt)))
.limit(1)
@@ -754,24 +780,40 @@ async function handleToolsCall(
)
}
+ if (!wf.workspaceId || wf.workspaceId !== serverWorkspaceId) {
+ logger.warn('Blocked workflow MCP tool with invalid workspace scope', {
+ serverId,
+ workflowId: tool.workflowId,
+ })
+ return NextResponse.json(
+ createError(id, ErrorCode.InternalError, 'Workflow is unavailable for this MCP server'),
+ { status: 403 }
+ )
+ }
+
+ const actorUserId = publicServerOwnerId ?? executeAuthContext?.userId
+ if (!actorUserId) {
+ throw new Error('Workflow MCP execution is missing an authenticated actor')
+ }
+ const billingAttribution = await resolveWorkflowMcpBillingAttribution(
+ actorUserId,
+ wf.workspaceId
+ )
+
const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record = {
'Content-Type': 'application/json',
+ [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution),
[MCP_TOOL_BRIDGE_HEADER]: 'true',
}
const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal)
if (abortedBeforeExecute) return abortedBeforeExecute
- if (publicServerOwnerId) {
- const internalToken = await generateInternalToken(publicServerOwnerId)
- headers.Authorization = `Bearer ${internalToken}`
- } else if (executeAuthContext) {
- const internalToken = await generateInternalToken(executeAuthContext.userId)
- headers.Authorization = `Bearer ${internalToken}`
- if (executeAuthContext.useAuthenticatedUserAsActor) {
- headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user'
- }
+ const internalToken = await generateInternalToken(actorUserId)
+ headers.Authorization = `Bearer ${internalToken}`
+ if (executeAuthContext?.useAuthenticatedUserAsActor) {
+ headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user'
}
if (simViaHeader) {
diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts
index bb4e3650fc7..f0261e111ef 100644
--- a/apps/sim/app/api/mcp/tools/execute/route.ts
+++ b/apps/sim/app/api/mcp/tools/execute/route.ts
@@ -1,9 +1,14 @@
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { mcpToolExecutionBodySchema } from '@/lib/api/contracts/mcp'
-import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
+import { AuthType } from '@/lib/auth/hybrid'
+import {
+ requireBillingAttributionHeader,
+ resolveBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -54,220 +59,233 @@ function hasType(prop: unknown): prop is SchemaProperty {
* POST - Execute a tool on an MCP server
*/
export const POST = withRouteHandler(
- withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => {
- let serverId: string | undefined
- try {
- const rawBody = await readMcpJsonBodyWithLimit(request)
- const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
-
- if (!parsedBody.success) {
- return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
- }
-
- const body = parsedBody.data
+ withMcpAuth('read')(
+ async (request: NextRequest, { userId, workspaceId, requestId, authType }) => {
+ let serverId: string | undefined
+ try {
+ const rawBody = await readMcpJsonBodyWithLimit(request)
+ const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
- logger.info(`[${requestId}] MCP tool execution request received`, {
- hasAuthHeader: !!request.headers.get('authorization'),
- bodyKeys: Object.keys(body),
- serverId: body.serverId,
- toolName: body.toolName,
- hasWorkflowId: !!body.workflowId,
- workflowId: body.workflowId,
- userId: userId,
- })
+ if (!parsedBody.success) {
+ return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
+ }
- const { toolName, arguments: rawArgs } = body
- serverId = body.serverId
- const args = rawArgs || {}
+ const body = parsedBody.data
- try {
- await assertPermissionsAllowed({
- userId,
- workspaceId,
- toolKind: 'mcp',
+ logger.info(`[${requestId}] MCP tool execution request received`, {
+ hasAuthHeader: !!request.headers.get('authorization'),
+ bodyKeys: Object.keys(body),
+ serverId: body.serverId,
+ toolName: body.toolName,
+ hasWorkflowId: !!body.workflowId,
+ workflowId: body.workflowId,
+ userId: userId,
})
- } catch (err) {
- if (err instanceof McpToolsNotAllowedError) {
- return createMcpErrorResponse(err, err.message, 403)
- }
- throw err
- }
- logger.info(
- `[${requestId}] Executing tool ${toolName} on server ${serverId} for user ${userId} in workspace ${workspaceId}`
- )
+ const { toolName, arguments: rawArgs } = body
+ serverId = body.serverId
+ const args = rawArgs || {}
- let tool: McpTool | null = null
- try {
- const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
- tool = tools.find((t) => t.name === toolName) ?? null
-
- if (!tool) {
- logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
- availableTools: tools.map((t) => t.name),
+ try {
+ await assertPermissionsAllowed({
+ userId,
+ workspaceId,
+ toolKind: 'mcp',
})
- return createMcpErrorResponse(
- new Error('Tool not found'),
- 'Tool not found on the specified server',
- 404
- )
+ } catch (err) {
+ if (err instanceof McpToolsNotAllowedError) {
+ return createMcpErrorResponse(err, err.message, 403)
+ }
+ throw err
}
- if (tool.inputSchema?.properties) {
- for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
- const schema = hasType(paramSchema) ? paramSchema : null
- if (!schema) continue
- const value = args[paramName]
+ logger.info(
+ `[${requestId}] Executing tool ${toolName} on server ${serverId} for user ${userId} in workspace ${workspaceId}`
+ )
- if (value === undefined || value === null) {
- continue
- }
+ let tool: McpTool | null = null
+ try {
+ const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
+ tool = tools.find((t) => t.name === toolName) ?? null
+
+ if (!tool) {
+ logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
+ availableTools: tools.map((t) => t.name),
+ })
+ return createMcpErrorResponse(
+ new Error('Tool not found'),
+ 'Tool not found on the specified server',
+ 404
+ )
+ }
- if (
- (schema.type === 'number' || schema.type === 'integer') &&
- typeof value === 'string'
- ) {
- const numValue =
- schema.type === 'integer' ? Number.parseInt(value) : Number.parseFloat(value)
- if (!Number.isNaN(numValue)) {
- args[paramName] = numValue
- }
- } else if (schema.type === 'boolean' && typeof value === 'string') {
- if (value.toLowerCase() === 'true') {
- args[paramName] = true
- } else if (value.toLowerCase() === 'false') {
- args[paramName] = false
+ if (tool.inputSchema?.properties) {
+ for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
+ const schema = hasType(paramSchema) ? paramSchema : null
+ if (!schema) continue
+ const value = args[paramName]
+
+ if (value === undefined || value === null) {
+ continue
}
- } else if (schema.type === 'array' && typeof value === 'string') {
- const stringValue = value.trim()
- if (stringValue) {
- try {
- const parsed = JSON.parse(stringValue)
- if (Array.isArray(parsed)) {
- args[paramName] = parsed
- } else {
- args[paramName] = [parsed]
- }
- } catch {
- if (stringValue.includes(',')) {
- args[paramName] = stringValue
- .split(',')
- .map((item) => item.trim())
- .filter((item) => item)
- } else {
- args[paramName] = [stringValue]
+
+ if (
+ (schema.type === 'number' || schema.type === 'integer') &&
+ typeof value === 'string'
+ ) {
+ const numValue =
+ schema.type === 'integer' ? Number.parseInt(value) : Number.parseFloat(value)
+ if (!Number.isNaN(numValue)) {
+ args[paramName] = numValue
+ }
+ } else if (schema.type === 'boolean' && typeof value === 'string') {
+ if (value.toLowerCase() === 'true') {
+ args[paramName] = true
+ } else if (value.toLowerCase() === 'false') {
+ args[paramName] = false
+ }
+ } else if (schema.type === 'array' && typeof value === 'string') {
+ const stringValue = value.trim()
+ if (stringValue) {
+ try {
+ const parsed = JSON.parse(stringValue)
+ if (Array.isArray(parsed)) {
+ args[paramName] = parsed
+ } else {
+ args[paramName] = [parsed]
+ }
+ } catch {
+ if (stringValue.includes(',')) {
+ args[paramName] = stringValue
+ .split(',')
+ .map((item) => item.trim())
+ .filter((item) => item)
+ } else {
+ args[paramName] = [stringValue]
+ }
}
+ } else {
+ args[paramName] = []
}
- } else {
- args[paramName] = []
}
}
}
- }
- } catch (error) {
- logger.warn(
- `[${requestId}] Failed to discover tools for validation, proceeding without schema`,
- error
- )
- }
-
- if (tool) {
- const validationError = validateToolArguments(tool, args)
- if (validationError) {
- logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
- return createMcpErrorResponse(
- new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
- 'Invalid tool arguments',
- 400
+ } catch (error) {
+ logger.warn(
+ `[${requestId}] Failed to discover tools for validation, proceeding without schema`,
+ error
)
}
- }
- const toolCall: McpToolCall = {
- name: toolName,
- arguments: args,
- }
-
- const userSubscription = await getHighestPrioritySubscription(userId)
- const executionTimeout = getExecutionTimeout(
- userSubscription?.plan as SubscriptionPlan | undefined,
- 'sync'
- )
+ if (tool) {
+ const validationError = validateToolArguments(tool, args)
+ if (validationError) {
+ logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
+ return createMcpErrorResponse(
+ new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
+ 'Invalid tool arguments',
+ 400
+ )
+ }
+ }
- const simViaHeader = request.headers.get(SIM_VIA_HEADER)
- const extraHeaders: Record = {}
- if (simViaHeader) {
- extraHeaders[SIM_VIA_HEADER] = simViaHeader
- }
+ const toolCall: McpToolCall = {
+ name: toolName,
+ arguments: args,
+ }
- let timeoutHandle: ReturnType | undefined
- const result = await Promise.race([
- mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
- new Promise((_, reject) => {
- timeoutHandle = setTimeout(
- () => reject(new Error('Tool execution timeout')),
- executionTimeout
- )
- }),
- ]).finally(() => {
- if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
- })
-
- const transformedResult = transformToolResult(result)
-
- if (result.isError) {
- logger.warn(`[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`)
- return createMcpErrorResponse(
- transformedResult,
- transformedResult.error || 'Tool execution failed',
- 400
+ const billingAttribution =
+ authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(request.headers, {
+ actorUserId: userId,
+ workspaceId,
+ })
+ : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
+ const executionTimeout = getExecutionTimeout(
+ billingAttribution.payerSubscription?.plan as SubscriptionPlan | undefined,
+ 'sync'
)
- }
- logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
- try {
- const { PlatformEvents } = await import('@/lib/core/telemetry')
- PlatformEvents.mcpToolExecuted({
- serverId,
- toolName,
- status: 'success',
- workspaceId,
- })
- } catch {
- // Telemetry failure is non-critical
- }
+ const simViaHeader = request.headers.get(SIM_VIA_HEADER)
+ const extraHeaders: Record = {}
+ if (simViaHeader) {
+ extraHeaders[SIM_VIA_HEADER] = simViaHeader
+ }
- return createMcpSuccessResponse(transformedResult)
- } catch (error) {
- const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
- if (bodyErrorResponse) return bodyErrorResponse
- if (
- error instanceof McpOauthAuthorizationRequiredError ||
- error instanceof McpOauthRedirectRequired ||
- error instanceof UnauthorizedError
- ) {
- const errorServerId =
- error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId
- logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
- serverId: errorServerId,
+ let timeoutHandle: ReturnType | undefined
+ const result = await Promise.race([
+ mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
+ new Promise((_, reject) => {
+ timeoutHandle = setTimeout(
+ () => reject(new Error('Tool execution timeout')),
+ executionTimeout
+ )
+ }),
+ ]).finally(() => {
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
})
- return NextResponse.json(
- {
- success: false,
- error: 'OAuth re-authorization required',
- code: 'reauth_required',
+
+ const transformedResult = transformToolResult(result)
+
+ if (result.isError) {
+ logger.warn(`[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`)
+ return createMcpErrorResponse(
+ transformedResult,
+ transformedResult.error || 'Tool execution failed',
+ 400
+ )
+ }
+ logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
+
+ try {
+ const { PlatformEvents } = await import('@/lib/core/telemetry')
+ PlatformEvents.mcpToolExecuted({
+ serverId,
+ toolName,
+ status: 'success',
+ workspaceId,
+ })
+ } catch (error) {
+ logger.warn('Failed to record MCP tool execution telemetry', {
+ error: getErrorMessage(error),
+ serverId,
+ toolName,
+ workspaceId,
+ })
+ }
+
+ return createMcpSuccessResponse(transformedResult)
+ } catch (error) {
+ const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
+ if (bodyErrorResponse) return bodyErrorResponse
+ if (
+ error instanceof McpOauthAuthorizationRequiredError ||
+ error instanceof McpOauthRedirectRequired ||
+ error instanceof UnauthorizedError
+ ) {
+ const errorServerId =
+ error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId
+ logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
serverId: errorServerId,
- },
- { status: 401 }
- )
- }
+ })
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'OAuth re-authorization required',
+ code: 'reauth_required',
+ serverId: errorServerId,
+ },
+ { status: 401 }
+ )
+ }
- logger.error(`[${requestId}] Error executing MCP tool:`, error)
+ logger.error(`[${requestId}] Error executing MCP tool:`, error)
- const { message, status } = categorizeError(error)
- return createMcpErrorResponse(new Error(message), message, status)
+ const { message, status } = categorizeError(error)
+ return createMcpErrorResponse(new Error(message), message, status)
+ }
}
- })
+ )
)
function validateToolArguments(tool: McpTool, args: Record): string | null {
diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index 37236aa160f..ec38527d164 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
@@ -110,11 +111,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
userMetadata,
} = validation.data.body
- // Bind the billing actor to the authenticated identity. The executor mints
- // the internal JWT with the workflow owner's userId, so a token issued for
- // one user must never be used to attribute mothership-block cost to another
- // user via a forged body.userId. When the token carries a userId we require
- // the body to match it; the JWT userId is authoritative.
+ /**
+ * Bind actor attribution to the authenticated identity. The executor mints
+ * the internal JWT with its principal, so the request body cannot forge a
+ * different actor. Workspace billing is resolved independently downstream.
+ */
if (auth.userId && auth.userId !== bodyUserId) {
logger.warn('Mothership execute userId does not match authenticated identity', {
tokenUserId: auth.userId,
@@ -128,6 +129,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const userId = auth.userId ?? bodyUserId
await assertActiveWorkspaceAccess(workspaceId, userId)
+ const billingAttribution = requireBillingAttributionHeader(req.headers, {
+ actorUserId: userId,
+ workspaceId,
+ })
const effectiveChatId = chatId || generateId()
messageId = providedMessageId || generateId()
@@ -231,6 +236,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
autoExecuteTools: true,
interactive: false,
abortSignal: lifecycleAbortController.signal,
+ billingAttribution,
onEvent,
})
diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts
new file mode 100644
index 00000000000..82a3eef2fa8
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts
@@ -0,0 +1,272 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, createSession, loggerMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockDbState, mockExpireStaleInvitations, mockGetSession } = vi.hoisted(() => ({
+ mockDbState: {
+ selectResults: [] as unknown[][],
+ },
+ mockExpireStaleInvitations: vi.fn(),
+ mockGetSession: vi.fn(),
+}))
+
+function createSelectChain() {
+ const chain = {
+ from: vi.fn(),
+ innerJoin: vi.fn(),
+ leftJoin: vi.fn(),
+ where: vi.fn(),
+ limit: vi.fn(),
+ then: vi.fn(),
+ }
+ chain.from.mockReturnValue(chain)
+ chain.innerJoin.mockReturnValue(chain)
+ chain.leftJoin.mockReturnValue(chain)
+ chain.where.mockReturnValue(chain)
+ chain.limit.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
+ chain.then.mockImplementation((resolve: (rows: unknown[]) => unknown) =>
+ Promise.resolve(resolve(mockDbState.selectResults.shift() ?? []))
+ )
+ return chain
+}
+
+vi.mock('@sim/db', () => ({
+ db: {
+ select: vi.fn(() => createSelectChain()),
+ },
+}))
+
+vi.mock('@sim/db/schema', () => ({
+ invitation: {
+ id: 'invitation.id',
+ email: 'invitation.email',
+ role: 'invitation.role',
+ kind: 'invitation.kind',
+ membershipIntent: 'invitation.membershipIntent',
+ organizationId: 'invitation.organizationId',
+ status: 'invitation.status',
+ createdAt: 'invitation.createdAt',
+ expiresAt: 'invitation.expiresAt',
+ },
+ invitationWorkspaceGrant: {
+ invitationId: 'invitationWorkspaceGrant.invitationId',
+ workspaceId: 'invitationWorkspaceGrant.workspaceId',
+ permission: 'invitationWorkspaceGrant.permission',
+ },
+ member: {
+ id: 'member.id',
+ organizationId: 'member.organizationId',
+ userId: 'member.userId',
+ role: 'member.role',
+ createdAt: 'member.createdAt',
+ },
+ permissions: {
+ userId: 'permissions.userId',
+ entityId: 'permissions.entityId',
+ entityType: 'permissions.entityType',
+ permissionType: 'permissions.permissionType',
+ createdAt: 'permissions.createdAt',
+ },
+ user: {
+ id: 'user.id',
+ name: 'user.name',
+ email: 'user.email',
+ image: 'user.image',
+ },
+ workspace: {
+ id: 'workspace.id',
+ name: 'workspace.name',
+ organizationId: 'workspace.organizationId',
+ archivedAt: 'workspace.archivedAt',
+ },
+}))
+
+vi.mock('@sim/logger', () => loggerMock)
+
+vi.mock('@sim/platform-authz/workspace', () => ({
+ isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin',
+}))
+
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
+ eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
+ inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })),
+ isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
+ sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/invitations/core', () => ({
+ expireStalePendingInvitationsForOrganization: mockExpireStaleInvitations,
+}))
+
+import { GET } from '@/app/api/organizations/[id]/roster/route'
+
+const MEMBER_ROWS = [
+ {
+ memberId: 'member-admin',
+ userId: 'user-admin',
+ role: 'admin',
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ userName: 'Admin User',
+ userEmail: 'admin@example.com',
+ userImage: null,
+ },
+ {
+ memberId: 'member-reader',
+ userId: 'user-reader',
+ role: 'member',
+ createdAt: new Date('2026-02-01T00:00:00.000Z'),
+ userName: 'Reader User',
+ userEmail: 'reader@example.com',
+ userImage: 'https://example.com/reader.png',
+ },
+]
+
+describe('GET /api/organizations/[id]/roster', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDbState.selectResults = []
+ mockExpireStaleInvitations.mockResolvedValue(undefined)
+ })
+
+ it('returns a redacted roster to a target-organization member', async () => {
+ mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' }))
+ mockDbState.selectResults = [[{ role: 'member' }], MEMBER_ROWS]
+
+ const response = await GET(
+ createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'),
+ { params: Promise.resolve({ id: 'org-1' }) }
+ )
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({
+ success: true,
+ data: {
+ members: [
+ {
+ memberId: 'member-admin',
+ userId: 'user-admin',
+ role: 'admin',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ name: 'Admin User',
+ email: 'admin@example.com',
+ image: null,
+ workspaces: [],
+ },
+ {
+ memberId: 'member-reader',
+ userId: 'user-reader',
+ role: 'member',
+ createdAt: '2026-02-01T00:00:00.000Z',
+ name: 'Reader User',
+ email: 'reader@example.com',
+ image: 'https://example.com/reader.png',
+ workspaces: [],
+ },
+ ],
+ pendingInvitations: [],
+ workspaces: [],
+ },
+ })
+ expect(mockExpireStaleInvitations).not.toHaveBeenCalled()
+ })
+
+ it('denies a workspace collaborator who is not a target-organization member', async () => {
+ mockGetSession.mockResolvedValue(createSession({ userId: 'external-user' }))
+ mockDbState.selectResults = [[]]
+
+ const response = await GET(
+ createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'),
+ { params: Promise.resolve({ id: 'org-1' }) }
+ )
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Forbidden - Not a member of this organization',
+ })
+ expect(mockExpireStaleInvitations).not.toHaveBeenCalled()
+ })
+
+ it('preserves the full management roster for organization admins', async () => {
+ mockGetSession.mockResolvedValue(createSession({ userId: 'user-admin' }))
+ mockDbState.selectResults = [
+ [{ role: 'admin' }],
+ MEMBER_ROWS,
+ [{ id: 'workspace-1', name: 'Workspace One' }],
+ [{ userId: 'user-reader', workspaceId: 'workspace-1', permission: 'write' }],
+ [
+ {
+ userId: 'external-user',
+ userName: 'External User',
+ userEmail: 'external@example.com',
+ userImage: null,
+ workspaceId: 'workspace-1',
+ permission: 'read',
+ createdAt: new Date('2026-03-01T00:00:00.000Z'),
+ },
+ ],
+ [
+ {
+ id: 'invitation-1',
+ email: 'pending@example.com',
+ role: 'member',
+ kind: 'workspace',
+ membershipIntent: 'external',
+ createdAt: new Date('2026-04-01T00:00:00.000Z'),
+ expiresAt: new Date('2026-04-08T00:00:00.000Z'),
+ inviteeName: null,
+ inviteeImage: null,
+ },
+ ],
+ [{ invitationId: 'invitation-1', workspaceId: 'workspace-1', permission: 'read' }],
+ ]
+
+ const response = await GET(
+ createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'),
+ { params: Promise.resolve({ id: 'org-1' }) }
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.data.workspaces).toEqual([{ id: 'workspace-1', name: 'Workspace One' }])
+ expect(body.data.members).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ userId: 'user-admin',
+ workspaces: [
+ {
+ workspaceId: 'workspace-1',
+ workspaceName: 'Workspace One',
+ permission: 'admin',
+ },
+ ],
+ }),
+ expect.objectContaining({
+ userId: 'external-user',
+ role: 'external',
+ email: 'external@example.com',
+ }),
+ ])
+ )
+ expect(body.data.pendingInvitations).toEqual([
+ expect.objectContaining({
+ id: 'invitation-1',
+ email: 'pending@example.com',
+ workspaces: [
+ {
+ workspaceId: 'workspace-1',
+ workspaceName: 'Workspace One',
+ permission: 'read',
+ },
+ ],
+ }),
+ ])
+ expect(mockExpireStaleInvitations).toHaveBeenCalledWith('org-1')
+ })
+})
diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts
index fe8dc0edbc2..f27d03b591a 100644
--- a/apps/sim/app/api/organizations/[id]/roster/route.ts
+++ b/apps/sim/app/api/organizations/[id]/roster/route.ts
@@ -11,37 +11,30 @@ import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { organizationParamsSchema } from '@/lib/api/contracts/organization'
-import { getValidationErrorMessage } from '@/lib/api/server'
+import {
+ getOrganizationRosterContract,
+ type OrganizationRoster,
+ type RosterMember,
+ type RosterWorkspaceAccess,
+} from '@/lib/api/contracts/organization'
+import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core'
const logger = createLogger('OrganizationRosterAPI')
-interface RosterWorkspaceAccess {
- workspaceId: string
- workspaceName: string
- permission: 'admin' | 'write' | 'read'
-}
-
export const GET = withRouteHandler(
- async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
+ async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const paramsResult = organizationParamsSchema.safeParse(await params)
- if (!paramsResult.success) {
- return NextResponse.json(
- { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
- { status: 400 }
- )
- }
-
- const { id: organizationId } = paramsResult.data
+ const parsed = await parseRequest(getOrganizationRosterContract, request, { params })
+ if (!parsed.success) return parsed.response
+ const { id: organizationId } = parsed.data.params
const [callerMembership] = await db
.select({ role: member.role })
@@ -56,23 +49,6 @@ export const GET = withRouteHandler(
)
}
- if (callerMembership.role !== 'owner' && callerMembership.role !== 'admin') {
- return NextResponse.json(
- { error: 'Forbidden - Organization admin access required' },
- { status: 403 }
- )
- }
-
- await expireStalePendingInvitationsForOrganization(organizationId)
-
- const orgWorkspaces = await db
- .select({ id: workspace.id, name: workspace.name })
- .from(workspace)
- .where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt)))
-
- const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id)
- const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name]))
-
const memberRows = await db
.select({
memberId: member.id,
@@ -87,6 +63,38 @@ export const GET = withRouteHandler(
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId))
+ const members: RosterMember[] = memberRows.map((row) => ({
+ memberId: row.memberId,
+ userId: row.userId,
+ role: row.role === 'owner' ? 'owner' : row.role === 'admin' ? 'admin' : 'member',
+ createdAt: row.createdAt.toISOString(),
+ name: row.userName,
+ email: row.userEmail,
+ image: row.userImage,
+ workspaces: [] as RosterWorkspaceAccess[],
+ }))
+
+ if (!isOrgAdminRole(callerMembership.role)) {
+ const data = {
+ members,
+ pendingInvitations: [],
+ workspaces: [],
+ } satisfies OrganizationRoster
+ return NextResponse.json({
+ success: true,
+ data,
+ })
+ }
+
+ await expireStalePendingInvitationsForOrganization(organizationId)
+
+ const orgWorkspaces = await db
+ .select({ id: workspace.id, name: workspace.name })
+ .from(workspace)
+ .where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt)))
+
+ const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id)
+ const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name]))
const memberUserIds = memberRows.map((row) => row.userId)
const memberPermissions =
@@ -118,23 +126,17 @@ export const GET = withRouteHandler(
permissionsByUser.set(row.userId, list)
}
- const members = memberRows.map((row) => {
- const isOrgAdmin = isOrgAdminRole(row.role)
+ const membersWithWorkspaceAccess = members.map((rosterMember) => {
+ const isOrgAdmin = isOrgAdminRole(rosterMember.role)
return {
- memberId: row.memberId,
- userId: row.userId,
- role: row.role,
- createdAt: row.createdAt,
- name: row.userName,
- email: row.userEmail,
- image: row.userImage,
+ ...rosterMember,
workspaces: isOrgAdmin
? orgWorkspaces.map((ws) => ({
workspaceId: ws.id,
workspaceName: ws.name,
permission: 'admin' as const,
}))
- : (permissionsByUser.get(row.userId) ?? []),
+ : (permissionsByUser.get(rosterMember.userId) ?? []),
}
})
@@ -205,7 +207,11 @@ export const GET = withRouteHandler(
})
}
- const rosterMembers = [...members, ...externalMembersByUser.values()]
+ const externalMembers = [...externalMembersByUser.values()].map((rosterMember) => ({
+ ...rosterMember,
+ createdAt: rosterMember.createdAt.toISOString(),
+ }))
+ const rosterMembers = [...membersWithWorkspaceAccess, ...externalMembers]
const pendingInvitationRows = await db
.select({
@@ -253,20 +259,21 @@ export const GET = withRouteHandler(
role: row.membershipIntent === 'external' ? 'external' : row.role,
kind: row.kind,
membershipIntent: row.membershipIntent,
- createdAt: row.createdAt,
- expiresAt: row.expiresAt,
+ createdAt: row.createdAt.toISOString(),
+ expiresAt: row.expiresAt.toISOString(),
inviteeName: row.inviteeName,
inviteeImage: row.inviteeImage,
workspaces: grantsByInvitation.get(row.id) ?? [],
}))
+ const data = {
+ members: rosterMembers,
+ pendingInvitations,
+ workspaces: orgWorkspaces,
+ } satisfies OrganizationRoster
return NextResponse.json({
success: true,
- data: {
- members: rosterMembers,
- pendingInvitations,
- workspaces: orgWorkspaces,
- },
+ data,
})
} catch (error) {
logger.error('Failed to fetch organization roster', { error })
diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts
new file mode 100644
index 00000000000..1593d5dcd12
--- /dev/null
+++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts
@@ -0,0 +1,367 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockEnqueueOrStartResume,
+ mockGetCurrentPayer,
+ mockGetPauseContextDetail,
+ mockGetPausedExecutionDetail,
+ mockPreprocessExecution,
+ mockValidateWorkflowAccess,
+} = vi.hoisted(() => ({
+ mockEnqueueOrStartResume: vi.fn(),
+ mockGetCurrentPayer: vi.fn(),
+ mockGetPauseContextDetail: vi.fn(),
+ mockGetPausedExecutionDetail: vi.fn(),
+ mockPreprocessExecution: vi.fn(),
+ mockValidateWorkflowAccess: vi.fn(),
+}))
+
+vi.mock('@/app/api/workflows/middleware', () => ({
+ validateWorkflowAccess: mockValidateWorkflowAccess,
+}))
+
+vi.mock('@/lib/execution/preprocessing', () => ({
+ preprocessExecution: mockPreprocessExecution,
+}))
+
+vi.mock('@sim/utils/id', () => ({
+ generateId: () => 'resume-preflight-1',
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceBilledAccountUserId: mockGetCurrentPayer,
+}))
+
+vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
+ PauseResumeManager: {
+ enqueueOrStartResume: mockEnqueueOrStartResume,
+ getPauseContextDetail: mockGetPauseContextDetail,
+ getPausedExecutionDetail: mockGetPausedExecutionDetail,
+ markResumeAttemptFailed: vi.fn(),
+ processQueuedResumes: vi.fn(),
+ startResumeExecution: vi.fn(),
+ },
+}))
+
+import { GET, POST } from '@/app/api/resume/[workflowId]/[executionId]/[contextId]/route'
+
+const WORKFLOW_ID = 'workflow-1'
+const EXECUTION_ID = 'execution-1'
+const CONTEXT_ID = 'context-1'
+const WORKSPACE_ID = 'workspace-1'
+const PERSISTED_ACTOR_ID = 'original-actor'
+
+const PERSISTED_ATTRIBUTION = {
+ actorUserId: PERSISTED_ACTOR_ID,
+ workspaceId: WORKSPACE_ID,
+ organizationId: 'organization-original',
+ billedAccountUserId: 'owner-original',
+ billingEntity: { type: 'organization' as const, id: 'organization-original' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: {
+ id: 'subscription-original',
+ referenceId: 'organization-original',
+ plan: 'team_25000',
+ status: 'active',
+ seats: 5,
+ periodStart: '2026-07-01T00:00:00.000Z',
+ periodEnd: '2026-08-01T00:00:00.000Z',
+ },
+}
+
+interface PausedExecutionOverrides {
+ workflowId?: string
+ executionId?: string
+ snapshotWorkflowId?: string
+ snapshotExecutionId?: string
+ snapshotWorkspaceId?: string
+ snapshotActorUserId?: string
+ billingAttribution?: unknown
+}
+
+function createPausedExecution(overrides: PausedExecutionOverrides = {}) {
+ const billingAttribution =
+ 'billingAttribution' in overrides
+ ? overrides.billingAttribution
+ : structuredClone(PERSISTED_ATTRIBUTION)
+
+ return {
+ id: 'paused-execution-1',
+ workflowId: overrides.workflowId ?? WORKFLOW_ID,
+ executionId: overrides.executionId ?? EXECUTION_ID,
+ executionSnapshot: {
+ snapshot: JSON.stringify({
+ metadata: {
+ requestId: 'request-original',
+ workflowId: overrides.snapshotWorkflowId ?? WORKFLOW_ID,
+ executionId: overrides.snapshotExecutionId ?? EXECUTION_ID,
+ workspaceId: overrides.snapshotWorkspaceId ?? WORKSPACE_ID,
+ userId: overrides.snapshotActorUserId ?? PERSISTED_ACTOR_ID,
+ billingAttribution,
+ triggerType: 'manual',
+ useDraftState: false,
+ startTime: '2026-07-10T00:00:00.000Z',
+ executionMode: 'sync',
+ },
+ workflow: { version: '1', blocks: [], connections: [] },
+ input: {},
+ workflowVariables: {},
+ selectedOutputs: [],
+ }),
+ triggerIds: [],
+ },
+ }
+}
+
+function makeRequest(
+ params: { workflowId: string; executionId: string; contextId: string } = {
+ workflowId: WORKFLOW_ID,
+ executionId: EXECUTION_ID,
+ contextId: CONTEXT_ID,
+ },
+ body = JSON.stringify({ input: { approved: true } })
+) {
+ return {
+ request: new NextRequest(
+ `http://localhost/api/resume/${params.workflowId}/${params.executionId}/${params.contextId}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ }
+ ),
+ context: { params: Promise.resolve(params) },
+ }
+}
+
+describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockValidateWorkflowAccess.mockResolvedValue({
+ workflow: {
+ id: WORKFLOW_ID,
+ workspaceId: WORKSPACE_ID,
+ },
+ auth: {
+ success: true,
+ userId: 'current-api-key-user',
+ authType: 'api_key',
+ apiKeyType: 'workspace',
+ workspaceId: WORKSPACE_ID,
+ },
+ })
+ mockGetCurrentPayer.mockResolvedValue('current-workspace-owner')
+ mockGetPausedExecutionDetail.mockResolvedValue(createPausedExecution())
+ mockPreprocessExecution.mockResolvedValue({
+ success: true,
+ actorUserId: PERSISTED_ACTOR_ID,
+ billingAttribution: PERSISTED_ATTRIBUTION,
+ executionTimeout: { sync: 30_000, async: 300_000 },
+ })
+ mockEnqueueOrStartResume.mockResolvedValue({
+ status: 'queued',
+ resumeExecutionId: EXECUTION_ID,
+ queuePosition: 1,
+ })
+ })
+
+ it('returns 401 before validating malformed route input', async () => {
+ mockValidateWorkflowAccess.mockResolvedValueOnce({
+ error: { message: 'Unauthorized', status: 401 },
+ })
+ const { request, context } = makeRequest(
+ {
+ workflowId: WORKFLOW_ID,
+ executionId: '',
+ contextId: '',
+ },
+ '{'
+ )
+
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(401)
+ expect(await response.json()).toEqual({ error: 'Unauthorized' })
+ expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, false)
+ expect(mockGetPausedExecutionDetail).not.toHaveBeenCalled()
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ })
+
+ it('reuses the persisted actor and payer snapshot for route preflight', async () => {
+ const { request, context } = makeRequest()
+
+ const response = await POST(request, context)
+
+ expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, false)
+ expect(response.status).toBe(200)
+ expect(mockGetCurrentPayer).not.toHaveBeenCalled()
+ expect(mockGetPausedExecutionDetail).toHaveBeenCalledWith({
+ workflowId: WORKFLOW_ID,
+ executionId: EXECUTION_ID,
+ })
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workflowId: WORKFLOW_ID,
+ userId: 'current-api-key-user',
+ workspaceId: WORKSPACE_ID,
+ billingAttribution: PERSISTED_ATTRIBUTION,
+ executionId: 'resume-preflight-1',
+ skipConcurrencyReservation: true,
+ logPreprocessingErrors: false,
+ })
+ )
+ expect(mockPreprocessExecution.mock.calls[0]?.[0]).not.toHaveProperty('skipUsageLimits')
+ expect(mockEnqueueOrStartResume).toHaveBeenCalledWith({
+ executionId: EXECUTION_ID,
+ workflowId: WORKFLOW_ID,
+ contextId: CONTEXT_ID,
+ resumeInput: { approved: true },
+ userId: 'current-api-key-user',
+ allowedPauseKinds: ['human'],
+ })
+ })
+
+ it.each([
+ { statusCode: 402, message: 'Member usage limit reached', retryable: false },
+ { statusCode: 429, message: 'Target concurrency full', retryable: true },
+ { statusCode: 503, message: 'Usage admission unavailable', retryable: true },
+ ])(
+ 'leaves the pause and queued input untouched when readmission returns $statusCode',
+ async ({ statusCode, message, retryable }) => {
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: false,
+ error: { statusCode, message, retryable },
+ })
+ const { request, context } = makeRequest()
+
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(statusCode)
+ expect(await response.json()).toEqual({ error: message })
+ expect(mockEnqueueOrStartResume).not.toHaveBeenCalled()
+ }
+ )
+
+ it('fails closed when the persisted snapshot has no billing attribution', async () => {
+ mockGetPausedExecutionDetail.mockResolvedValueOnce(
+ createPausedExecution({ billingAttribution: undefined })
+ )
+ const { request, context } = makeRequest()
+
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(500)
+ expect(await response.json()).toEqual({
+ error: 'Paused execution billing attribution is missing or invalid',
+ })
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockEnqueueOrStartResume).not.toHaveBeenCalled()
+ })
+
+ it('fails closed when the persisted billing attribution is malformed', async () => {
+ mockGetPausedExecutionDetail.mockResolvedValueOnce(
+ createPausedExecution({
+ billingAttribution: {
+ actorUserId: PERSISTED_ACTOR_ID,
+ workspaceId: WORKSPACE_ID,
+ },
+ })
+ )
+ const { request, context } = makeRequest()
+
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(500)
+ expect(await response.json()).toEqual({
+ error: 'Paused execution billing attribution is missing or invalid',
+ })
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockEnqueueOrStartResume).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ [
+ 'workspace',
+ createPausedExecution({
+ billingAttribution: {
+ ...structuredClone(PERSISTED_ATTRIBUTION),
+ workspaceId: 'workspace-other',
+ },
+ }),
+ ],
+ [
+ 'actor',
+ createPausedExecution({
+ billingAttribution: {
+ ...structuredClone(PERSISTED_ATTRIBUTION),
+ actorUserId: 'actor-other',
+ },
+ }),
+ ],
+ ])('rejects a persisted %s attribution mismatch', async (_field, pausedExecution) => {
+ mockGetPausedExecutionDetail.mockResolvedValueOnce(pausedExecution)
+ const { request, context } = makeRequest()
+
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(500)
+ expect(await response.json()).toEqual({
+ error: 'Paused execution billing attribution does not match its workspace or actor',
+ })
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockEnqueueOrStartResume).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['workflow', createPausedExecution({ snapshotWorkflowId: 'workflow-other' })],
+ ['execution', createPausedExecution({ snapshotExecutionId: 'execution-other' })],
+ ])('rejects a persisted %s binding mismatch', async (_field, pausedExecution) => {
+ mockGetPausedExecutionDetail.mockResolvedValueOnce(pausedExecution)
+ const { request, context } = makeRequest()
+
+ const response = await POST(request, context)
+
+ expect(response.status).toBe(500)
+ expect(await response.json()).toEqual({
+ error: 'Paused execution snapshot does not match the requested workflow or execution',
+ })
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockEnqueueOrStartResume).not.toHaveBeenCalled()
+ })
+})
+
+describe('GET /api/resume/[workflowId]/[executionId]/[contextId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns 401 before validating malformed route input', async () => {
+ mockValidateWorkflowAccess.mockResolvedValueOnce({
+ error: { message: 'Unauthorized', status: 401 },
+ })
+ const request = new NextRequest(
+ `http://localhost/api/resume/${WORKFLOW_ID}/${EXECUTION_ID}/${CONTEXT_ID}`
+ )
+ const context = {
+ params: Promise.resolve({
+ workflowId: WORKFLOW_ID,
+ executionId: '',
+ contextId: '',
+ }),
+ }
+
+ const response = await GET(request, context)
+
+ expect(response.status).toBe(401)
+ expect(await response.json()).toEqual({ error: 'Unauthorized' })
+ expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, false)
+ expect(mockGetPauseContextDetail).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
index cfc05b5a1e9..6ea631652f6 100644
--- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
+++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
+import { isRecordLike } from '@sim/utils/object'
import { type NextRequest, NextResponse } from 'next/server'
import {
getPauseContextDetailContract,
@@ -8,6 +9,10 @@ import {
} from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { AuthType } from '@/lib/auth/hybrid'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+} from '@/lib/billing/core/billing-attribution'
import { getJobQueue } from '@/lib/core/async-jobs'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
@@ -16,31 +21,81 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
-import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import type { ResumeExecutionPayload } from '@/background/resume-execution'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
-import type { SerializedSnapshot } from '@/executor/types'
const logger = createLogger('WorkflowResumeAPI')
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
-function getStoredSnapshotConfig(pausedExecution: { executionSnapshot: unknown }): {
- executionMode?: 'sync' | 'stream' | 'async'
- selectedOutputs?: string[]
-} {
+const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid'
+const INVALID_PAUSED_ATTRIBUTION_ERROR =
+ 'Paused execution billing attribution is missing or invalid'
+const PAUSED_EXECUTION_BINDING_ERROR =
+ 'Paused execution snapshot does not match the requested workflow or execution'
+const PAUSED_ATTRIBUTION_BINDING_ERROR =
+ 'Paused execution billing attribution does not match its workspace or actor'
+
+interface PausedExecutionSnapshotSource {
+ workflowId: string
+ executionId: string
+ executionSnapshot: unknown
+}
+
+interface PausedExecutionSnapshotBinding {
+ snapshot: ExecutionSnapshot
+ billingAttribution: BillingAttributionSnapshot
+}
+
+function loadPausedExecutionSnapshot(
+ pausedExecution: PausedExecutionSnapshotSource,
+ expected: { workflowId: string; executionId: string; workspaceId: string }
+): PausedExecutionSnapshotBinding {
+ if (
+ !isRecordLike(pausedExecution.executionSnapshot) ||
+ typeof pausedExecution.executionSnapshot.snapshot !== 'string'
+ ) {
+ throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR)
+ }
+
+ let snapshot: ExecutionSnapshot
try {
- const serialized = pausedExecution.executionSnapshot as SerializedSnapshot
- const snapshot = ExecutionSnapshot.fromJSON(serialized.snapshot)
- return {
- executionMode: snapshot.metadata.executionMode,
- selectedOutputs: snapshot.selectedOutputs,
- }
+ snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot)
} catch {
- return {}
+ throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR)
+ }
+
+ if (!isRecordLike(snapshot.metadata)) {
+ throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR)
+ }
+
+ let billingAttribution: BillingAttributionSnapshot
+ try {
+ billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution)
+ } catch {
+ throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR)
+ }
+
+ if (
+ pausedExecution.workflowId !== expected.workflowId ||
+ pausedExecution.executionId !== expected.executionId ||
+ snapshot.metadata.workflowId !== expected.workflowId ||
+ snapshot.metadata.executionId !== expected.executionId
+ ) {
+ throw new Error(PAUSED_EXECUTION_BINDING_ERROR)
+ }
+
+ if (
+ snapshot.metadata.workspaceId !== expected.workspaceId ||
+ billingAttribution.workspaceId !== expected.workspaceId ||
+ snapshot.metadata.userId !== billingAttribution.actorUserId
+ ) {
+ throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR)
}
+
+ return { snapshot, billingAttribution }
}
export const POST = withRouteHandler(
@@ -50,16 +105,53 @@ export const POST = withRouteHandler(
params: Promise<{ workflowId: string; executionId: string; contextId: string }>
}
) => {
+ const { workflowId: requestedWorkflowId } = await context.params
+ const access = await validateWorkflowAccess(request, requestedWorkflowId, false)
+ if (access.error) {
+ return NextResponse.json({ error: access.error.message }, { status: access.error.status })
+ }
+
const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context)
if (!parsed.success) return parsed.response
const { workflowId, executionId, contextId } = parsed.data.params
+ const requestId = generateRequestId()
- const access = await validateWorkflowAccess(request, workflowId, false)
- if (access.error) {
- return NextResponse.json({ error: access.error.message }, { status: access.error.status })
+ const workflow = access.workflow
+ if (!workflow?.workspaceId) {
+ logger.error(`[${requestId}] Authorized workflow has no workspace`, { workflowId })
+ return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
+ }
+ const userId = access.auth?.userId
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const workflow = access.workflow
+ const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({
+ workflowId,
+ executionId,
+ })
+ if (!pausedExecution) {
+ return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 })
+ }
+
+ let snapshotBinding: PausedExecutionSnapshotBinding
+ try {
+ snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, {
+ workflowId,
+ executionId,
+ workspaceId: workflow.workspaceId,
+ })
+ } catch (error) {
+ const message = toError(error).message
+ logger.error(`[${requestId}] Failed to validate paused execution snapshot`, {
+ workflowId,
+ executionId,
+ error: message,
+ })
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+
+ const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding
let payload: unknown = {}
try {
@@ -72,37 +164,21 @@ export const POST = withRouteHandler(
typeof payload === 'object' && payload !== null && 'input' in payload
? payload.input
: (payload ?? {})
- const isPersonalApiKeyCaller =
- access.auth?.authType === AuthType.API_KEY && access.auth?.apiKeyType === 'personal'
-
- let userId: string
- if (isPersonalApiKeyCaller && access.auth?.userId) {
- userId = access.auth.userId
- } else {
- const billedAccountUserId = await getWorkspaceBilledAccountUserId(workflow.workspaceId)
- if (!billedAccountUserId) {
- logger.error('Unable to resolve workspace billed account for resume execution', {
- workflowId,
- workspaceId: workflow.workspaceId,
- })
- return NextResponse.json(
- { error: 'Unable to resolve billing account for this workspace' },
- { status: 500 }
- )
- }
- userId = billedAccountUserId
- }
-
const resumeExecutionId = generateId()
- const requestId = generateRequestId()
logger.info(`[${requestId}] Preprocessing resume execution`, {
workflowId,
parentExecutionId: executionId,
resumeExecutionId,
userId,
+ actorUserId: billingAttribution.actorUserId,
})
+ /**
+ * This preflight gives synchronous callers current block/usage feedback
+ * without reserving under a throwaway id. The claimed resume reruns every
+ * gate and reserves atomically under its persisted resume execution id.
+ */
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -111,9 +187,10 @@ export const POST = withRouteHandler(
requestId,
checkRateLimit: false,
checkDeployment: false,
- skipUsageLimits: true,
- useAuthenticatedUserAsActor: isPersonalApiKeyCaller,
- workspaceId: workflow.workspaceId || undefined,
+ skipConcurrencyReservation: true,
+ logPreprocessingErrors: false,
+ workspaceId: workflow.workspaceId,
+ billingAttribution,
})
if (!preprocessResult.success) {
@@ -170,16 +247,15 @@ export const POST = withRouteHandler(
}
const isApiCaller = access.auth?.authType === AuthType.API_KEY
- const snapshotConfig = isApiCaller
- ? getStoredSnapshotConfig(enqueueResult.pausedExecution)
- : {}
- const executionMode = isApiCaller ? (snapshotConfig.executionMode ?? 'sync') : undefined
+ const executionMode = isApiCaller
+ ? (persistedSnapshot.metadata.executionMode ?? 'sync')
+ : undefined
if (isApiCaller && executionMode === 'stream') {
const stream = await createStreamingResponse({
requestId,
streamConfig: {
- selectedOutputs: snapshotConfig.selectedOutputs,
+ selectedOutputs: persistedSnapshot.selectedOutputs,
timeoutMs: preprocessResult.executionTimeout?.sync,
},
executionId: enqueueResult.resumeExecutionId,
@@ -298,9 +374,11 @@ export const POST = withRouteHandler(
contextId,
error,
})
+ const statusCode =
+ isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400
return NextResponse.json(
{ error: toError(error).message || 'Failed to queue resume request' },
- { status: 400 }
+ { status: statusCode }
)
}
}
@@ -313,15 +391,16 @@ export const GET = withRouteHandler(
params: Promise<{ workflowId: string; executionId: string; contextId: string }>
}
) => {
- const parsed = await parseRequest(getPauseContextDetailContract, request, context)
- if (!parsed.success) return parsed.response
- const { workflowId, executionId, contextId } = parsed.data.params
-
- const access = await validateWorkflowAccess(request, workflowId, false)
+ const { workflowId: requestedWorkflowId } = await context.params
+ const access = await validateWorkflowAccess(request, requestedWorkflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
+ const parsed = await parseRequest(getPauseContextDetailContract, request, context)
+ if (!parsed.success) return parsed.response
+ const { workflowId, executionId, contextId } = parsed.data.params
+
const detail = await PauseResumeManager.getPauseContextDetail({
workflowId,
executionId,
diff --git a/apps/sim/app/api/resume/poll/route.test.ts b/apps/sim/app/api/resume/poll/route.test.ts
new file mode 100644
index 00000000000..f3c66b70fad
--- /dev/null
+++ b/apps/sim/app/api/resume/poll/route.test.ts
@@ -0,0 +1,565 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ acquireLockMock,
+ assertBillingAttributionSnapshotMock,
+ dbSelectMock,
+ dueRowsLimitMock,
+ enqueueOrStartResumeMock,
+ executionSnapshotFromJsonMock,
+ fallbackRowsLimitMock,
+ inArrayMock,
+ legacySizeRowsLimitMock,
+ lteMock,
+ preprocessExecutionMock,
+ processQueuedResumesMock,
+ releaseLockMock,
+ setAutomaticResumeWaitingMock,
+ setNextResumeAtMock,
+ sqlMock,
+} = vi.hoisted(() => ({
+ acquireLockMock: vi.fn(),
+ assertBillingAttributionSnapshotMock: vi.fn((value: unknown) => value),
+ dbSelectMock: vi.fn(),
+ dueRowsLimitMock: vi.fn(),
+ enqueueOrStartResumeMock: vi.fn(),
+ executionSnapshotFromJsonMock: vi.fn(),
+ fallbackRowsLimitMock: vi.fn(),
+ inArrayMock: vi.fn(),
+ legacySizeRowsLimitMock: vi.fn(),
+ lteMock: vi.fn(),
+ preprocessExecutionMock: vi.fn(),
+ processQueuedResumesMock: vi.fn(),
+ releaseLockMock: vi.fn(),
+ setAutomaticResumeWaitingMock: vi.fn(),
+ setNextResumeAtMock: vi.fn(),
+ sqlMock: vi.fn((strings: TemplateStringsArray) =>
+ strings.join('').includes('jsonb_build_object') ? 'boundedMetadata' : 'snapshotBytes'
+ ),
+}))
+
+vi.mock('@sim/db', () => ({
+ db: {
+ select: dbSelectMock,
+ },
+}))
+
+vi.mock('@sim/db/schema', () => ({
+ pausedExecutions: {
+ id: 'id',
+ executionId: 'executionId',
+ workflowId: 'workflowId',
+ pausePoints: 'pausePoints',
+ metadata: 'metadata',
+ executionSnapshot: 'executionSnapshot',
+ status: 'status',
+ nextResumeAt: 'nextResumeAt',
+ },
+}))
+
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn(),
+ asc: vi.fn(),
+ inArray: inArrayMock,
+ isNotNull: vi.fn(),
+ lte: lteMock,
+ sql: sqlMock,
+}))
+
+vi.mock('@/lib/auth/internal', () => ({
+ verifyCronAuth: vi.fn(() => null),
+}))
+
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ assertBillingAttributionSnapshot: assertBillingAttributionSnapshotMock,
+}))
+
+vi.mock('@/lib/core/config/redis', () => ({
+ acquireLock: acquireLockMock,
+ releaseLock: releaseLockMock,
+}))
+
+vi.mock('@/lib/execution/preprocessing', () => ({
+ preprocessExecution: preprocessExecutionMock,
+}))
+
+vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
+ computeEarliestResumeAt: vi.fn(() => null),
+ PauseResumeManager: {
+ enqueueOrStartResume: enqueueOrStartResumeMock,
+ processQueuedResumes: processQueuedResumesMock,
+ setAutomaticResumeWaiting: setAutomaticResumeWaitingMock,
+ setNextResumeAt: setNextResumeAtMock,
+ },
+}))
+
+vi.mock('@/executor/execution/snapshot', () => ({
+ ExecutionSnapshot: {
+ fromJSON: executionSnapshotFromJsonMock,
+ },
+}))
+
+import {
+ LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE,
+ MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES,
+} from '@/lib/workflows/executor/paused-execution-policy'
+import { GET } from '@/app/api/resume/poll/route'
+
+function makeRequest(): NextRequest {
+ return new NextRequest('http://localhost/api/resume/poll')
+}
+
+function makeBillingAttribution(workspaceId: string, actorUserId: string) {
+ return {
+ actorUserId,
+ workspaceId,
+ organizationId: null,
+ billedAccountUserId: actorUserId,
+ billingEntity: { type: 'user', id: actorUserId },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ }
+}
+
+function makeResumeMetadata(index: number) {
+ const workspaceId = `workspace-${index}`
+ const executorUserId = `actor-${index}`
+ return {
+ executorUserId,
+ workspaceId,
+ billingAttribution: makeBillingAttribution(workspaceId, executorUserId),
+ }
+}
+
+function makeDueRow(index: number, metadata: unknown = makeResumeMetadata(index)) {
+ return {
+ id: `paused-${index}`,
+ executionId: `execution-${index}`,
+ workflowId: `workflow-${index}`,
+ metadata,
+ pausePoints: {
+ [`context-${index}`]: {
+ contextId: `context-${index}`,
+ pauseKind: 'time',
+ resumeAt: '2026-07-01T00:00:00.000Z',
+ resumeStatus: 'paused',
+ },
+ },
+ }
+}
+
+function makeSerializedSnapshot(index: number) {
+ return {
+ snapshot: JSON.stringify({
+ metadata: {
+ workspaceId: `workspace-${index}`,
+ userId: `actor-${index}`,
+ billingAttribution: makeBillingAttribution(`workspace-${index}`, `actor-${index}`),
+ },
+ }),
+ triggerIds: [],
+ }
+}
+
+describe('time-pause resume admission', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ dbSelectMock.mockImplementation((selection: Record) => {
+ if ('snapshotBytes' in selection) {
+ return {
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ limit: legacySizeRowsLimitMock,
+ })),
+ })),
+ }
+ }
+ if ('executionSnapshot' in selection) {
+ return {
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ limit: fallbackRowsLimitMock,
+ })),
+ })),
+ }
+ }
+ return {
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: vi.fn(() => ({
+ limit: dueRowsLimitMock,
+ })),
+ })),
+ })),
+ }
+ })
+ acquireLockMock.mockResolvedValue(true)
+ releaseLockMock.mockResolvedValue(undefined)
+ dueRowsLimitMock.mockResolvedValue([])
+ fallbackRowsLimitMock.mockResolvedValue([])
+ legacySizeRowsLimitMock.mockResolvedValue([])
+ setAutomaticResumeWaitingMock.mockResolvedValue(undefined)
+ setNextResumeAtMock.mockResolvedValue(undefined)
+ processQueuedResumesMock.mockResolvedValue(undefined)
+ preprocessExecutionMock.mockResolvedValue({ success: true, actorUserId: 'actor-1' })
+ enqueueOrStartResumeMock.mockResolvedValue({
+ status: 'queued',
+ resumeExecutionId: 'execution-1',
+ queuePosition: 1,
+ })
+ executionSnapshotFromJsonMock.mockImplementation((value: string) => JSON.parse(value))
+ })
+
+ it('keeps a timed pause unclaimed, records why, and schedules automatic retry', async () => {
+ dueRowsLimitMock.mockResolvedValueOnce([makeDueRow(1)])
+ preprocessExecutionMock.mockResolvedValueOnce({
+ success: false,
+ error: {
+ message: 'Usage admission unavailable',
+ statusCode: 503,
+ retryable: true,
+ logCreated: false,
+ },
+ })
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(preprocessExecutionMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ executionId: 'execution-1',
+ workspaceId: 'workspace-1',
+ skipConcurrencyReservation: true,
+ logPreprocessingErrors: false,
+ })
+ )
+ expect(enqueueOrStartResumeMock).not.toHaveBeenCalled()
+ expect(setAutomaticResumeWaitingMock).toHaveBeenCalledWith({
+ pausedExecutionId: 'paused-1',
+ contextId: 'context-1',
+ reason: 'Usage admission unavailable',
+ retryAt: expect.any(Date),
+ })
+ expect(setNextResumeAtMock).not.toHaveBeenCalled()
+ expect(legacySizeRowsLimitMock).not.toHaveBeenCalled()
+ expect(fallbackRowsLimitMock).not.toHaveBeenCalled()
+ expect(executionSnapshotFromJsonMock).not.toHaveBeenCalled()
+ })
+
+ it('preprocesses once when one paused row has multiple due time points', async () => {
+ const baseRow = makeDueRow(1)
+ dueRowsLimitMock.mockResolvedValueOnce([
+ {
+ ...baseRow,
+ pausePoints: {
+ ...baseRow.pausePoints,
+ 'context-2': {
+ contextId: 'context-2',
+ pauseKind: 'time',
+ resumeAt: '2026-07-01T00:00:00.000Z',
+ resumeStatus: 'paused',
+ },
+ },
+ },
+ ])
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(preprocessExecutionMock).toHaveBeenCalledTimes(1)
+ expect(enqueueOrStartResumeMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('retries a preserved queued input without creating a replacement input', async () => {
+ const row = makeDueRow(1)
+ row.pausePoints['context-1'] = {
+ contextId: 'context-1',
+ pauseKind: 'human',
+ resumeAt: '2026-07-01T00:00:00.000Z',
+ resumeStatus: 'queued',
+ }
+ dueRowsLimitMock.mockResolvedValueOnce([row])
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(processQueuedResumesMock).toHaveBeenCalledWith('execution-1', 'workflow-1')
+ expect(preprocessExecutionMock).not.toHaveBeenCalled()
+ expect(enqueueOrStartResumeMock).not.toHaveBeenCalled()
+ })
+
+ it('does not clear the retry deadline while a queued resume is already running', async () => {
+ const row = makeDueRow(1)
+ row.pausePoints['context-1'].resumeStatus = 'resuming'
+ dueRowsLimitMock.mockResolvedValueOnce([row])
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(processQueuedResumesMock).toHaveBeenCalledWith('execution-1', 'workflow-1')
+ expect(setNextResumeAtMock).not.toHaveBeenCalled()
+ expect(enqueueOrStartResumeMock).not.toHaveBeenCalled()
+ })
+
+ it('loads and parses snapshots only for legacy rows with missing or invalid metadata', async () => {
+ dueRowsLimitMock.mockResolvedValueOnce([
+ makeDueRow(1),
+ makeDueRow(2, { executorUserId: 'actor-2' }),
+ makeDueRow(3, {
+ ...makeResumeMetadata(3),
+ billingAttribution: makeBillingAttribution('different-workspace', 'actor-3'),
+ }),
+ ])
+ legacySizeRowsLimitMock.mockResolvedValueOnce([
+ { id: 'paused-2', snapshotBytes: 1024 },
+ { id: 'paused-3', snapshotBytes: 2048 },
+ ])
+ fallbackRowsLimitMock.mockResolvedValueOnce([
+ { id: 'paused-2', executionSnapshot: makeSerializedSnapshot(2) },
+ { id: 'paused-3', executionSnapshot: makeSerializedSnapshot(3) },
+ ])
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(dueRowsLimitMock).toHaveBeenCalledWith(200)
+ expect(legacySizeRowsLimitMock).toHaveBeenCalledWith(200)
+ expect(fallbackRowsLimitMock).toHaveBeenCalledWith(LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE)
+ expect(dbSelectMock).toHaveBeenCalledTimes(3)
+ expect(dbSelectMock.mock.calls[0]?.[0]).not.toHaveProperty('executionSnapshot')
+ expect(dbSelectMock.mock.calls[0]?.[0]).toEqual(
+ expect.objectContaining({ metadata: 'boundedMetadata' })
+ )
+ expect(dbSelectMock.mock.calls[1]?.[0]).toHaveProperty('snapshotBytes')
+ expect(dbSelectMock.mock.calls[1]?.[0]).not.toHaveProperty('executionSnapshot')
+ expect(dbSelectMock.mock.calls[2]?.[0]).toHaveProperty('executionSnapshot')
+ expect(
+ sqlMock.mock.calls.some(([strings]) =>
+ (strings as TemplateStringsArray).join('').includes('octet_length(')
+ )
+ ).toBe(true)
+ expect(
+ inArrayMock.mock.calls.some(
+ ([column, values]) =>
+ column === 'id' && Array.isArray(values) && values.join(',') === 'paused-2,paused-3'
+ )
+ ).toBe(true)
+ expect(executionSnapshotFromJsonMock).toHaveBeenCalledTimes(2)
+ expect(preprocessExecutionMock).toHaveBeenCalledTimes(3)
+ })
+
+ it('keeps an unreadable legacy row paused with a visible retry reason', async () => {
+ dueRowsLimitMock.mockResolvedValueOnce([makeDueRow(1, { executorUserId: 'actor-1' })])
+ legacySizeRowsLimitMock.mockResolvedValueOnce([{ id: 'paused-1', snapshotBytes: 1024 }])
+ fallbackRowsLimitMock.mockResolvedValueOnce([
+ {
+ id: 'paused-1',
+ executionSnapshot: { snapshot: '{invalid', triggerIds: [] },
+ },
+ ])
+ executionSnapshotFromJsonMock.mockImplementationOnce(() => {
+ throw new Error('Snapshot JSON is invalid')
+ })
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(preprocessExecutionMock).not.toHaveBeenCalled()
+ expect(enqueueOrStartResumeMock).not.toHaveBeenCalled()
+ expect(setAutomaticResumeWaitingMock).toHaveBeenCalledWith({
+ pausedExecutionId: 'paused-1',
+ contextId: 'context-1',
+ reason: 'Snapshot JSON is invalid',
+ retryAt: expect.any(Date),
+ })
+ })
+
+ it('never selects or deserializes an oversized legacy snapshot', async () => {
+ const oversizedRow = makeDueRow(1, { executorUserId: 'actor-1' })
+ oversizedRow.pausePoints['context-1'].resumeStatus = 'queued'
+ dueRowsLimitMock.mockResolvedValue([oversizedRow])
+ legacySizeRowsLimitMock.mockResolvedValue([
+ {
+ id: 'paused-1',
+ snapshotBytes: MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES + 1,
+ },
+ ])
+
+ const firstResponse = await GET(makeRequest())
+ const secondResponse = await GET(makeRequest())
+
+ expect(firstResponse.status).toBe(200)
+ expect(secondResponse.status).toBe(200)
+ expect(legacySizeRowsLimitMock).toHaveBeenCalledTimes(2)
+ expect(legacySizeRowsLimitMock).toHaveBeenNthCalledWith(1, 200)
+ expect(legacySizeRowsLimitMock).toHaveBeenNthCalledWith(2, 200)
+ expect(fallbackRowsLimitMock).not.toHaveBeenCalled()
+ expect(dbSelectMock.mock.calls.some(([selection]) => 'executionSnapshot' in selection)).toBe(
+ false
+ )
+ expect(executionSnapshotFromJsonMock).not.toHaveBeenCalled()
+ expect(preprocessExecutionMock).not.toHaveBeenCalled()
+ expect(enqueueOrStartResumeMock).not.toHaveBeenCalled()
+ expect(processQueuedResumesMock).not.toHaveBeenCalled()
+ expect(setAutomaticResumeWaitingMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ pausedExecutionId: 'paused-1',
+ contextId: 'context-1',
+ reason: expect.stringContaining('16 MiB automatic-resume safety limit'),
+ retryAt: expect.any(Date),
+ })
+ )
+ })
+
+ it('preserves due-row order when concurrent admission checks finish out of order', async () => {
+ dueRowsLimitMock.mockResolvedValueOnce([makeDueRow(1), makeDueRow(2)])
+ let releaseFirst: (() => void) | undefined
+ const firstGate = new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ preprocessExecutionMock.mockImplementation(async ({ executionId }: { executionId: string }) => {
+ if (executionId === 'execution-1') {
+ await firstGate
+ }
+ return {
+ success: false,
+ error: {
+ message: `Blocked ${executionId}`,
+ statusCode: 503,
+ retryable: true,
+ logCreated: false,
+ },
+ }
+ })
+
+ const responsePromise = GET(makeRequest())
+ await vi.waitFor(() => {
+ expect(preprocessExecutionMock).toHaveBeenCalledTimes(2)
+ })
+ releaseFirst?.()
+
+ const response = await responsePromise
+ const payload = (await response.json()) as {
+ failures: Array<{ executionId: string }>
+ }
+ expect(payload.failures.map((failure) => failure.executionId)).toEqual([
+ 'execution-1',
+ 'execution-2',
+ ])
+ })
+
+ it('loads qualifying legacy snapshots in sequential four-row chunks', async () => {
+ const rows = Array.from({ length: 10 }, (_, index) =>
+ makeDueRow(index + 1, { executorUserId: `actor-${index + 1}` })
+ )
+ dueRowsLimitMock.mockResolvedValueOnce(rows)
+ legacySizeRowsLimitMock.mockResolvedValueOnce(
+ rows.map((row) => ({ id: row.id, snapshotBytes: 1024 }))
+ )
+ const snapshotChunks = [
+ rows.slice(0, 4).map((row, index) => ({
+ id: row.id,
+ executionSnapshot: makeSerializedSnapshot(index + 1),
+ })),
+ rows.slice(4, 8).map((row, index) => ({
+ id: row.id,
+ executionSnapshot: makeSerializedSnapshot(index + 5),
+ })),
+ rows.slice(8).map((row, index) => ({
+ id: row.id,
+ executionSnapshot: makeSerializedSnapshot(index + 9),
+ })),
+ ]
+ let activeSnapshotLoads = 0
+ let maxActiveSnapshotLoads = 0
+ fallbackRowsLimitMock.mockImplementation(async () => {
+ const chunk = snapshotChunks[fallbackRowsLimitMock.mock.calls.length - 1] ?? []
+ activeSnapshotLoads++
+ maxActiveSnapshotLoads = Math.max(maxActiveSnapshotLoads, activeSnapshotLoads)
+ await Promise.resolve()
+ activeSnapshotLoads--
+ return chunk
+ })
+
+ const response = await GET(makeRequest())
+
+ expect(response.status).toBe(200)
+ expect(fallbackRowsLimitMock).toHaveBeenCalledTimes(3)
+ expect(fallbackRowsLimitMock.mock.calls).toEqual([
+ [LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE],
+ [LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE],
+ [LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE],
+ ])
+ const snapshotIdBatches = inArrayMock.mock.calls
+ .filter(([column]) => column === 'id')
+ .map(([, ids]) => ids as string[])
+ expect(snapshotIdBatches.map((ids) => ids.length)).toEqual([10, 4, 4, 2])
+ expect(
+ lteMock.mock.calls.filter(
+ ([expression, limit]) =>
+ expression === 'snapshotBytes' && limit === MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES
+ )
+ ).toHaveLength(3)
+ expect(maxActiveSnapshotLoads).toBe(1)
+ expect(executionSnapshotFromJsonMock).toHaveBeenCalledTimes(10)
+ expect(preprocessExecutionMock).toHaveBeenCalledTimes(10)
+ })
+
+ it('caps preprocessing at ten pipelines while preserving the 200-row batch bound', async () => {
+ dueRowsLimitMock.mockResolvedValueOnce(
+ Array.from({ length: 200 }, (_, index) => makeDueRow(index + 1))
+ )
+
+ let active = 0
+ let maxActive = 0
+ let releaseGate: (() => void) | undefined
+ const gate = new Promise((resolve) => {
+ releaseGate = resolve
+ })
+ preprocessExecutionMock.mockImplementation(async () => {
+ active++
+ maxActive = Math.max(maxActive, active)
+ await gate
+ active--
+ return { success: true, actorUserId: 'actor-1' }
+ })
+
+ const responsePromise = GET(makeRequest())
+ await vi.waitFor(() => {
+ expect(preprocessExecutionMock).toHaveBeenCalledTimes(10)
+ })
+
+ expect(active).toBe(10)
+ expect(maxActive).toBe(10)
+ releaseGate?.()
+
+ const response = await responsePromise
+ const payload = (await response.json()) as {
+ claimedRows: number
+ dispatched: number
+ failures: unknown[]
+ }
+
+ expect(response.status).toBe(200)
+ expect(payload).toEqual(
+ expect.objectContaining({
+ claimedRows: 200,
+ dispatched: 200,
+ failures: [],
+ })
+ )
+ expect(dueRowsLimitMock).toHaveBeenCalledWith(200)
+ expect(preprocessExecutionMock).toHaveBeenCalledTimes(200)
+ expect(maxActive).toBe(10)
+ expect(legacySizeRowsLimitMock).not.toHaveBeenCalled()
+ expect(fallbackRowsLimitMock).not.toHaveBeenCalled()
+ expect(executionSnapshotFromJsonMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/resume/poll/route.ts b/apps/sim/app/api/resume/poll/route.ts
index 86edf2f218c..afd22196044 100644
--- a/apps/sim/app/api/resume/poll/route.ts
+++ b/apps/sim/app/api/resume/poll/route.ts
@@ -3,15 +3,32 @@ import { pausedExecutions } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
-import { and, asc, inArray, isNotNull, lte } from 'drizzle-orm'
+import { isRecordLike } from '@sim/utils/object'
+import { and, asc, inArray, isNotNull, lte, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
+import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { preprocessExecution } from '@/lib/execution/preprocessing'
import {
computeEarliestResumeAt,
PauseResumeManager,
} from '@/lib/workflows/executor/human-in-the-loop-manager'
+import {
+ createPausedExecutionResumeMetadata,
+ type PausedExecutionResumeMetadata,
+ parsePausedExecutionResumeMetadata,
+} from '@/lib/workflows/executor/paused-execution-metadata'
+import {
+ LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE,
+ MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES,
+} from '@/lib/workflows/executor/paused-execution-policy'
+import {
+ getResumeAdmissionRetryAt,
+ normalizeAutomaticResumeWaitingReason,
+} from '@/lib/workflows/executor/resume-policy'
+import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { PausePoint } from '@/executor/types'
const logger = createLogger('TimePauseResumePoll')
@@ -22,6 +39,10 @@ export const maxDuration = 120
const LOCK_KEY = 'time-pause-resume-poll-lock'
const LOCK_TTL_SECONDS = 180
const POLL_BATCH_LIMIT = 200
+const POLL_PREPROCESSING_CONCURRENCY = 10
+const OVERSIZED_LEGACY_SNAPSHOT_REASON = `Legacy paused execution snapshot exceeds the ${
+ MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES / (1024 * 1024)
+} MiB automatic-resume safety limit and requires repair`
interface DispatchFailure {
executionId: string
@@ -57,7 +78,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
executionId: pausedExecutions.executionId,
workflowId: pausedExecutions.workflowId,
pausePoints: pausedExecutions.pausePoints,
- metadata: pausedExecutions.metadata,
+ metadata: sql`jsonb_build_object(
+ 'executorUserId', ${pausedExecutions.metadata}->'executorUserId',
+ 'workspaceId', ${pausedExecutions.metadata}->'workspaceId',
+ 'billingAttribution', ${pausedExecutions.metadata}->'billingAttribution'
+ )`,
})
.from(pausedExecutions)
.where(
@@ -73,7 +98,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.orderBy(asc(pausedExecutions.nextResumeAt))
.limit(POLL_BATCH_LIMIT)
- const results = await Promise.all(dueRows.map((row) => dispatchRow(row, now)))
+ const preparedRows = await prepareDueRows(dueRows)
+ const results = await mapWithConcurrency(preparedRows, POLL_PREPROCESSING_CONCURRENCY, (row) =>
+ dispatchRowSafely(row, now)
+ )
const dispatched = results.reduce((sum, r) => sum + r.dispatched, 0)
const failures = results.flatMap((r) => r.failures)
@@ -108,12 +136,223 @@ interface DueRow {
metadata: unknown
}
-async function dispatchRow(row: DueRow, now: Date): Promise {
- const points = (row.pausePoints ?? {}) as Record
- const metadata = (row.metadata ?? {}) as Record
- const userId = typeof metadata.executorUserId === 'string' ? metadata.executorUserId : ''
+interface PreparedDueRow extends DueRow {
+ resumeMetadata: PausedExecutionResumeMetadata | null
+ resumeMetadataError?: string
+}
+
+interface LegacySnapshotRow {
+ id: string
+ executionSnapshot: unknown
+}
+
+interface LegacySnapshotSizeRow {
+ id: string
+ snapshotBytes: number
+}
+
+interface LegacyResumeMetadataResult {
+ resumeMetadata: PausedExecutionResumeMetadata | null
+ resumeMetadataError?: string
+}
+
+/**
+ * Measures the serialized JSON text sent by the database driver. This matches
+ * the cutover migration's byte accounting and avoids compressed TOAST size
+ * undercounting.
+ */
+function pausedExecutionSnapshotBytesSql() {
+ return sql`octet_length(${pausedExecutions.executionSnapshot}::text)`
+}
+
+function isPausePoint(value: unknown): value is PausePoint {
+ return isRecordLike(value) && typeof value.contextId === 'string'
+}
+
+function getPausePoints(value: unknown): PausePoint[] {
+ return isRecordLike(value) ? Object.values(value).filter(isPausePoint) : []
+}
+
+function getLegacyExecutorUserId(metadata: unknown): string | undefined {
+ if (!isRecordLike(metadata)) return undefined
+ return typeof metadata.executorUserId === 'string' ? metadata.executorUserId : undefined
+}
+
+async function loadLegacyResumeMetadata(
+ rows: DueRow[]
+): Promise> {
+ const results = new Map()
+ const snapshotSizeRows = (await db
+ .select({
+ id: pausedExecutions.id,
+ snapshotBytes: pausedExecutionSnapshotBytesSql(),
+ })
+ .from(pausedExecutions)
+ .where(
+ inArray(
+ pausedExecutions.id,
+ rows.map((row) => row.id)
+ )
+ )
+ .limit(POLL_BATCH_LIMIT)) as LegacySnapshotSizeRow[]
+
+ const snapshotBytesById = new Map(
+ snapshotSizeRows.map((row) => [row.id, Number(row.snapshotBytes)])
+ )
+ const eligibleIds: string[] = []
+ for (const row of rows) {
+ const snapshotBytes = snapshotBytesById.get(row.id)
+ if (snapshotBytes === undefined || !Number.isFinite(snapshotBytes) || snapshotBytes < 0) {
+ results.set(row.id, {
+ resumeMetadata: null,
+ resumeMetadataError: 'Legacy paused execution snapshot size is unavailable',
+ })
+ } else if (snapshotBytes > MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES) {
+ results.set(row.id, {
+ resumeMetadata: null,
+ resumeMetadataError: OVERSIZED_LEGACY_SNAPSHOT_REASON,
+ })
+ } else {
+ eligibleIds.push(row.id)
+ }
+ }
+
+ const rowsById = new Map(rows.map((row) => [row.id, row]))
+ for (
+ let offset = 0;
+ offset < eligibleIds.length;
+ offset += LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE
+ ) {
+ const chunkIds = eligibleIds.slice(offset, offset + LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE)
+ const snapshotRows = (await db
+ .select({
+ id: pausedExecutions.id,
+ executionSnapshot: pausedExecutions.executionSnapshot,
+ })
+ .from(pausedExecutions)
+ .where(
+ and(
+ inArray(pausedExecutions.id, chunkIds),
+ lte(pausedExecutionSnapshotBytesSql(), MAX_PAUSED_EXECUTION_SNAPSHOT_BYTES)
+ )
+ )
+ .limit(LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE)) as LegacySnapshotRow[]
+
+ const loadedIds = new Set()
+ for (const snapshotRow of snapshotRows) {
+ loadedIds.add(snapshotRow.id)
+ const sourceRow = rowsById.get(snapshotRow.id)
+ try {
+ if (
+ !sourceRow ||
+ !isRecordLike(snapshotRow.executionSnapshot) ||
+ typeof snapshotRow.executionSnapshot.snapshot !== 'string'
+ ) {
+ throw new Error('Legacy paused execution snapshot is missing')
+ }
+ const snapshot = ExecutionSnapshot.fromJSON(snapshotRow.executionSnapshot.snapshot)
+ results.set(snapshotRow.id, {
+ resumeMetadata: createPausedExecutionResumeMetadata(
+ snapshot,
+ getLegacyExecutorUserId(sourceRow.metadata)
+ ),
+ })
+ } catch (error) {
+ results.set(snapshotRow.id, {
+ resumeMetadata: null,
+ resumeMetadataError: toError(error).message,
+ })
+ }
+ }
+
+ for (const id of chunkIds) {
+ if (!loadedIds.has(id)) {
+ results.set(id, {
+ resumeMetadata: null,
+ resumeMetadataError:
+ 'Legacy paused execution snapshot is missing or exceeds the automatic-resume safety limit',
+ })
+ }
+ }
+ }
+
+ return results
+}
+
+async function prepareDueRows(rows: DueRow[]): Promise {
+ const parsedMetadata = rows.map((row) => parsePausedExecutionResumeMetadata(row.metadata))
+ const legacyRows = rows.filter((_, index) => parsedMetadata[index] === null)
+ if (legacyRows.length === 0) {
+ return rows.map((row, index) => ({
+ ...row,
+ resumeMetadata: parsedMetadata[index],
+ }))
+ }
+
+ const legacyMetadataById = await loadLegacyResumeMetadata(legacyRows)
- const eligiblePoints = Object.values(points).filter(
+ return rows.map((row, index) => {
+ const currentMetadata = parsedMetadata[index]
+ if (currentMetadata) {
+ return { ...row, resumeMetadata: currentMetadata }
+ }
+
+ const legacyMetadata = legacyMetadataById.get(row.id)
+ return {
+ ...row,
+ resumeMetadata: legacyMetadata?.resumeMetadata ?? null,
+ resumeMetadataError:
+ legacyMetadata?.resumeMetadataError ?? 'Legacy paused execution snapshot is unavailable',
+ }
+ })
+}
+
+async function dispatchRowSafely(row: PreparedDueRow, now: Date): Promise {
+ try {
+ return await dispatchRow(row, now)
+ } catch (error) {
+ const message = toError(error).message
+ const contextId = getPausePoints(row.pausePoints)[0]?.contextId ?? 'automatic-resume'
+ logger.warn('Failed to process time-pause resume row', {
+ executionId: row.executionId,
+ contextId,
+ error: message,
+ })
+ return {
+ dispatched: 0,
+ failures: [{ executionId: row.executionId, contextId, error: message }],
+ }
+ }
+}
+
+async function recordAutomaticAdmissionWait(
+ row: DueRow,
+ contextId: string,
+ reason: string,
+ now: Date
+): Promise {
+ const boundedReason = normalizeAutomaticResumeWaitingReason(reason)
+ try {
+ await PauseResumeManager.setAutomaticResumeWaiting({
+ pausedExecutionId: row.id,
+ contextId,
+ reason: boundedReason,
+ retryAt: getResumeAdmissionRetryAt(now),
+ })
+ } catch (error) {
+ logger.warn('Failed to persist automatic resume waiting state', {
+ executionId: row.executionId,
+ contextId,
+ error: toError(error).message,
+ })
+ }
+ return boundedReason
+}
+
+async function dispatchRow(row: PreparedDueRow, now: Date): Promise {
+ const points = getPausePoints(row.pausePoints)
+
+ const eligiblePoints = points.filter(
(point) =>
point.pauseKind === 'time' && (!point.resumeStatus || point.resumeStatus === 'paused')
)
@@ -126,6 +365,106 @@ async function dispatchRow(row: DueRow, now: Date): Promise {
const failures: DispatchFailure[] = []
let dispatched = 0
+ if (!row.resumeMetadata) {
+ const metadataError =
+ row.resumeMetadataError ?? 'Paused execution resume metadata is unavailable'
+ const blockedPoints =
+ duePoints.length > 0
+ ? duePoints
+ : points.filter(
+ (point) => point.resumeStatus === 'queued' || point.resumeStatus === 'resuming'
+ )
+ const contextIds =
+ blockedPoints.length > 0
+ ? blockedPoints.map((point) => point.contextId)
+ : ['automatic-resume']
+ for (const contextId of contextIds) {
+ const waitingReason = await recordAutomaticAdmissionWait(row, contextId, metadataError, now)
+ failures.push({
+ executionId: row.executionId,
+ contextId,
+ error: waitingReason,
+ })
+ }
+ return { dispatched, failures }
+ }
+
+ if (duePoints.length === 0) {
+ const queuedPoint = points.find((point) => point.resumeStatus === 'queued')
+ const resumingPoint = points.find((point) => point.resumeStatus === 'resuming')
+ try {
+ await PauseResumeManager.processQueuedResumes(row.executionId, row.workflowId)
+ if (!queuedPoint && !resumingPoint) {
+ await PauseResumeManager.setNextResumeAt({
+ pausedExecutionId: row.id,
+ nextResumeAt: null,
+ })
+ }
+ } catch (error) {
+ const message = toError(error).message
+ const contextId = queuedPoint?.contextId ?? 'queued-resume'
+ const waitingReason = await recordAutomaticAdmissionWait(row, contextId, message, now)
+ failures.push({
+ executionId: row.executionId,
+ contextId,
+ error: waitingReason,
+ })
+ }
+ return { dispatched, failures }
+ }
+
+ const { executorUserId, workspaceId, billingAttribution } = row.resumeMetadata
+ let preprocessing: Awaited>
+ try {
+ preprocessing = await preprocessExecution({
+ workflowId: row.workflowId,
+ userId: executorUserId,
+ triggerType: 'manual',
+ executionId: row.executionId,
+ requestId: `time-resume:${row.id}`,
+ checkRateLimit: false,
+ checkDeployment: false,
+ skipConcurrencyReservation: true,
+ logPreprocessingErrors: false,
+ workspaceId,
+ billingAttribution,
+ })
+ } catch (error) {
+ for (const point of duePoints) {
+ if (!point.contextId) continue
+ const waitingReason = await recordAutomaticAdmissionWait(
+ row,
+ point.contextId,
+ toError(error).message,
+ now
+ )
+ failures.push({
+ executionId: row.executionId,
+ contextId: point.contextId,
+ error: waitingReason,
+ })
+ }
+ return { dispatched, failures }
+ }
+
+ if (!preprocessing.success) {
+ for (const point of duePoints) {
+ if (!point.contextId) continue
+ const waitingReason = await recordAutomaticAdmissionWait(
+ row,
+ point.contextId,
+ preprocessing.error?.message ?? 'Resume admission failed',
+ now
+ )
+ failures.push({
+ executionId: row.executionId,
+ contextId: point.contextId,
+ error: waitingReason,
+ })
+ }
+ return { dispatched, failures }
+ }
+
for (const point of duePoints) {
if (!point.contextId) continue
try {
@@ -134,16 +473,15 @@ async function dispatchRow(row: DueRow, now: Date): Promise {
workflowId: row.workflowId,
contextId: point.contextId,
resumeInput: {},
- userId,
+ userId: executorUserId,
allowedPauseKinds: ['time'],
})
if (enqueueResult.status === 'starting') {
- // Route through `executeResumeJob` (not `PauseResumeManager.startResumeExecution`
- // directly) so cell-context restoration + cascade-loop continuation
- // fires. This is the same primitive the trigger.dev `resumeExecutionTask`
- // wraps — calling it directly handles both trigger.dev-disabled local
- // dev and trigger.dev-enabled prod identically.
+ /**
+ * Route through `executeResumeJob` so cell-context restoration and
+ * cascade continuation use the same primitive as the background task.
+ */
const { executeResumeJob } = await import('@/background/resume-execution')
void executeResumeJob({
resumeEntryId: enqueueResult.resumeEntryId,
@@ -174,10 +512,13 @@ async function dispatchRow(row: DueRow, now: Date): Promise {
}
}
- // We never auto-retry a failed dispatch: workflow blocks aren't idempotent, and
- // an operator must investigate stranded rows by hand. The status='paused' guard
- // also prevents clobbering when a concurrent manual resume has already advanced
- // the row's state since we read it.
+ /**
+ * Read-only admission failures happen before input/state claims. The claimed
+ * resume acquires its fresh atomic reservation inside PauseResumeManager;
+ * reservation failures restore that same queued input for a bounded retry.
+ * Execution/dispatch failures keep the existing manual investigation
+ * behavior because workflow blocks are not idempotent.
+ */
await PauseResumeManager.setNextResumeAt({
pausedExecutionId: row.id,
nextResumeAt: computeEarliestResumeAt(eligiblePoints, { after: now }),
diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts
index b7057d4fe4f..bcf0eaf5d3f 100644
--- a/apps/sim/app/api/schedules/execute/route.test.ts
+++ b/apps/sim/app/api/schedules/execute/route.test.ts
@@ -22,6 +22,8 @@ const {
mockMarkJobFailed,
mockCancelJob,
mockShouldExecuteInline,
+ mockResolveSystemBillingAttribution,
+ mockAssertBillingAttributionSnapshot,
} = vi.hoisted(() => ({
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
@@ -40,12 +42,19 @@ const {
mockMarkJobFailed: vi.fn().mockResolvedValue(undefined),
mockCancelJob: vi.fn().mockResolvedValue(undefined),
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
+ mockResolveSystemBillingAttribution: vi.fn(),
+ mockAssertBillingAttributionSnapshot: vi.fn(),
}))
vi.mock('@/lib/auth/internal', () => ({
verifyCronAuth: mockVerifyCronAuth,
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
+ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
+}))
+
vi.mock('@/background/schedule-execution', () => ({
executeScheduleJob: mockExecuteScheduleJob,
executeJobInline: mockExecuteJobInline,
@@ -181,6 +190,21 @@ const MULTIPLE_SCHEDULES = [
const SINGLE_CLAIMED_SCHEDULE_ROWS = [{ id: 'schedule-1', workspaceId: 'workspace-1' }]
+function createBillingAttribution(workspaceId: string, actorUserId = `owner-${workspaceId}`) {
+ return {
+ actorUserId,
+ workspaceId,
+ organizationId: 'org-1',
+ billedAccountUserId: `owner-${workspaceId}`,
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ }
+}
+
const SINGLE_JOB = [
{
id: 'job-1',
@@ -288,6 +312,16 @@ describe('Scheduled Workflow Execution API Route', () => {
mockExecuteJobInline.mockResolvedValue(undefined)
mockReleaseScheduleLock.mockReset()
mockReleaseScheduleLock.mockResolvedValue(undefined)
+ mockAssertBillingAttributionSnapshot.mockReset()
+ mockAssertBillingAttributionSnapshot.mockImplementation((value: unknown) => {
+ if (!value || typeof value !== 'object') {
+ throw new Error('Billing attribution snapshot must be an object')
+ }
+ return value
+ })
+ mockResolveSystemBillingAttribution.mockImplementation((workspaceId: string) =>
+ Promise.resolve(createBillingAttribution(workspaceId))
+ )
dbChainMockFns.returning.mockReturnValue([])
})
@@ -351,7 +385,7 @@ describe('Scheduled Workflow Execution API Route', () => {
)
})
- it('should enqueue schedule with correlation metadata via job queue', async () => {
+ it('should enqueue schedule with one atomic system actor and payer snapshot', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
.mockResolvedValueOnce([])
@@ -365,6 +399,11 @@ describe('Scheduled Workflow Execution API Route', () => {
workflowId: 'workflow-1',
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
+ billingAttribution: expect.objectContaining({
+ actorUserId: 'owner-workspace-1',
+ workspaceId: 'workspace-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ }),
}),
expect.objectContaining({
jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
@@ -381,6 +420,8 @@ describe('Scheduled Workflow Execution API Route', () => {
}),
})
)
+ expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1')
+ expect(mockResolveSystemBillingAttribution).toHaveBeenCalledTimes(1)
expect(mockEnqueue.mock.calls[0][2].concurrencyKey).toBeUndefined()
})
@@ -443,6 +484,13 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockResolvedValueOnce({
id: 'job-id-1',
status: 'pending',
+ payload: {
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution: createBillingAttribution('workspace-1'),
+ now: '2025-01-01T00:00:00.000Z',
+ },
})
orderByLimitMock
.mockResolvedValueOnce([])
@@ -493,6 +541,8 @@ describe('Scheduled Workflow Execution API Route', () => {
payload: {
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution: createBillingAttribution('workspace-1'),
now: claimedAt.toISOString(),
},
},
@@ -517,6 +567,113 @@ describe('Scheduled Workflow Execution API Route', () => {
expect(mockCompleteJob).toHaveBeenCalledWith('pending-job-id', null)
})
+ it.each([
+ {
+ name: 'a missing workspace',
+ payload: {
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ billingAttribution: createBillingAttribution('workspace-1'),
+ },
+ expectedError: 'Invalid pending schedule execution payload: workspaceId is required',
+ },
+ {
+ name: 'missing billing attribution',
+ payload: {
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ },
+ expectedError: 'Invalid pending schedule execution payload: billingAttribution is required',
+ },
+ {
+ name: 'billing attribution for another workspace',
+ payload: {
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution: createBillingAttribution('workspace-2'),
+ },
+ expectedError:
+ 'Invalid pending schedule execution payload: billing attribution workspace does not match payload workspace',
+ },
+ {
+ name: 'billing attribution for a different system actor',
+ payload: {
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution: createBillingAttribution('workspace-1', 'member-1'),
+ },
+ expectedError:
+ 'Invalid pending schedule execution payload: billing attribution actor does not match billed account',
+ },
+ ])('rejects pending database fallback jobs with $name', async ({ payload, expectedError }) => {
+ mockShouldExecuteInline.mockReturnValue(true)
+ const claimedAt = new Date('2025-01-01T00:00:00.000Z')
+ mockProcessingCounts(0, 0)
+ orderByLimitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
+ {
+ id: 'pending-job-id',
+ payload: {
+ ...payload,
+ now: claimedAt.toISOString(),
+ },
+ },
+ ])
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([{ lastQueuedAt: claimedAt }])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([])
+
+ const result = await runScheduleTick('test-request-id')
+
+ expect(result.processedCount).toBe(1)
+ expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
+ expect(mockMarkJobFailed).toHaveBeenCalledWith('pending-job-id', expectedError)
+ expect(mockReleaseScheduleLock).toHaveBeenCalledWith(
+ 'schedule-1',
+ 'test-request-id',
+ expect.any(Date),
+ expect.stringContaining('invalid pending schedule execution payload'),
+ undefined,
+ { expectedLastQueuedAt: claimedAt }
+ )
+ })
+
+ it('rejects pending database fallback jobs with malformed billing attribution', async () => {
+ mockShouldExecuteInline.mockReturnValue(true)
+ const claimedAt = new Date('2025-01-01T00:00:00.000Z')
+ mockProcessingCounts(0, 0)
+ mockAssertBillingAttributionSnapshot.mockImplementationOnce(() => {
+ throw new Error('Billing attribution snapshot is missing its billing entity')
+ })
+ orderByLimitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([
+ {
+ id: 'pending-job-id',
+ payload: {
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution: {},
+ now: claimedAt.toISOString(),
+ },
+ },
+ ])
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([{ lastQueuedAt: claimedAt }])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([])
+
+ await runScheduleTick('test-request-id')
+
+ expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
+ expect(mockMarkJobFailed).toHaveBeenCalledWith(
+ 'pending-job-id',
+ 'Invalid pending schedule execution payload: Billing attribution snapshot is missing its billing entity'
+ )
+ })
+
it('completes stale pending database fallback jobs whose schedule claim was already released', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts
index 3214dca2f9b..63a9258211d 100644
--- a/apps/sim/app/api/schedules/execute/route.ts
+++ b/apps/sim/app/api/schedules/execute/route.ts
@@ -5,12 +5,16 @@ import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { randomInt } from '@sim/utils/random'
-import { backoffWithJitter } from '@sim/utils/retry'
import { Cron } from 'croner'
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import type { ExecuteSchedulesResponse } from '@/lib/api/contracts/schedules'
import { verifyCronAuth } from '@/lib/auth/internal'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+ resolveSystemBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
@@ -21,12 +25,11 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
SCHEDULE_EXECUTION_QUEUE_NAME,
- SCHEDULE_INFRA_RETRY_BASE_MS,
SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS,
- SCHEDULE_INFRA_RETRY_MAX_MS,
SCHEDULE_JITTER_MAX_MS,
SCHEDULE_WORKFLOW_ENQUEUE_LIMIT,
} from '@/lib/workflows/schedules/execution-limits'
+import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry'
import {
buildScheduleFailureUpdate,
executeJobInline,
@@ -215,10 +218,17 @@ type DatabaseScheduleExecutionTarget = Pick<
ClaimedSchedule,
'id' | 'workflowId' | 'cronExpression' | 'timezone'
>
+type ScheduleRecoveryMetadata = Pick<
+ ScheduleExecutionPayload,
+ 'scheduleId' | 'workflowId' | 'now' | 'cronExpression' | 'timezone'
+>
+type SchedulePayloadValidation =
+ | { success: true; payload: ScheduleExecutionPayload }
+ | { success: false; error: string }
-function getSchedulePayloadFromValue(payload: unknown): ScheduleExecutionPayload | null {
+function getScheduleRecoveryMetadataFromValue(payload: unknown): ScheduleRecoveryMetadata | null {
if (!payload || typeof payload !== 'object') return null
- const candidate = payload as Partial
+ const candidate = payload as Record
if (
typeof candidate.scheduleId !== 'string' ||
typeof candidate.workflowId !== 'string' ||
@@ -227,14 +237,77 @@ function getSchedulePayloadFromValue(payload: unknown): ScheduleExecutionPayload
return null
}
- return candidate as ScheduleExecutionPayload
+ return {
+ scheduleId: candidate.scheduleId,
+ workflowId: candidate.workflowId,
+ now: candidate.now,
+ cronExpression:
+ typeof candidate.cronExpression === 'string' ? candidate.cronExpression : undefined,
+ timezone: typeof candidate.timezone === 'string' ? candidate.timezone : undefined,
+ }
+}
+
+function getScheduleRecoveryMetadataFromJob(job: Job): ScheduleRecoveryMetadata | null {
+ return getScheduleRecoveryMetadataFromValue(job.payload)
}
-function getSchedulePayloadFromJob(job: Job): ScheduleExecutionPayload | null {
- return getSchedulePayloadFromValue(job.payload)
+function getSchedulePayloadValidation(payload: unknown): SchedulePayloadValidation {
+ const metadata = getScheduleRecoveryMetadataFromValue(payload)
+ if (!metadata || !payload || typeof payload !== 'object') {
+ return { success: false, error: 'recovery metadata is invalid' }
+ }
+
+ const candidate = payload as Record
+ if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) {
+ return { success: false, error: 'workspaceId is required' }
+ }
+ if (candidate.billingAttribution === undefined || candidate.billingAttribution === null) {
+ return { success: false, error: 'billingAttribution is required' }
+ }
+
+ let billingAttribution: BillingAttributionSnapshot
+ try {
+ billingAttribution = assertBillingAttributionSnapshot(candidate.billingAttribution)
+ } catch (error) {
+ return { success: false, error: toError(error).message }
+ }
+
+ if (billingAttribution.workspaceId !== candidate.workspaceId) {
+ return {
+ success: false,
+ error: 'billing attribution workspace does not match payload workspace',
+ }
+ }
+ if (billingAttribution.actorUserId !== billingAttribution.billedAccountUserId) {
+ return {
+ success: false,
+ error: 'billing attribution actor does not match billed account',
+ }
+ }
+
+ return {
+ success: true,
+ payload: {
+ ...metadata,
+ workspaceId: candidate.workspaceId,
+ billingAttribution,
+ executionId: typeof candidate.executionId === 'string' ? candidate.executionId : undefined,
+ requestId: typeof candidate.requestId === 'string' ? candidate.requestId : undefined,
+ blockId: typeof candidate.blockId === 'string' ? candidate.blockId : undefined,
+ deploymentVersionId:
+ typeof candidate.deploymentVersionId === 'string'
+ ? candidate.deploymentVersionId
+ : undefined,
+ lastRanAt: typeof candidate.lastRanAt === 'string' ? candidate.lastRanAt : undefined,
+ failedCount: typeof candidate.failedCount === 'number' ? candidate.failedCount : undefined,
+ infraRetryCount:
+ typeof candidate.infraRetryCount === 'number' ? candidate.infraRetryCount : undefined,
+ scheduledFor: typeof candidate.scheduledFor === 'string' ? candidate.scheduledFor : undefined,
+ },
+ }
}
-function getSchedulePayloadClaimedAt(payload: ScheduleExecutionPayload | null): Date | null {
+function getSchedulePayloadClaimedAt(payload: ScheduleRecoveryMetadata | null): Date | null {
if (!payload) return null
const claimedAt = new Date(payload.now)
return Number.isNaN(claimedAt.getTime()) ? null : claimedAt
@@ -355,15 +428,7 @@ async function deferClaimedScheduleAfterQueueFailure(
return
}
- const retryDelayMs = Math.min(
- SCHEDULE_INFRA_RETRY_MAX_MS,
- Math.round(
- backoffWithJitter(retryAttempt, null, {
- baseMs: SCHEDULE_INFRA_RETRY_BASE_MS,
- maxMs: SCHEDULE_INFRA_RETRY_MAX_MS,
- })
- )
- )
+ const retryDelayMs = calculateScheduleInfraRetryDelayMs(retryAttempt)
const nextRetryAt = new Date(now.getTime() + retryDelayMs)
logger.warn(`[${requestId}] Deferring schedule after queue infrastructure failure`, {
@@ -469,7 +534,7 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise {
}
for (const row of exhaustedRows) {
- const payload = getSchedulePayloadFromValue(row.payload)
+ const payload = getScheduleRecoveryMetadataFromValue(row.payload)
const claimedAt = getSchedulePayloadClaimedAt(payload)
if (!payload || !claimedAt) continue
@@ -640,7 +705,7 @@ function getScheduleTargetFromPayload(
}
async function getScheduleClaimState(
- payload: ScheduleExecutionPayload,
+ payload: ScheduleRecoveryMetadata,
claimedAt: Date
): Promise<'matches' | 'released' | 'claimed_by_other'> {
const [schedule] = await db
@@ -665,18 +730,18 @@ async function resumePendingDatabaseScheduleJobs(
const results = await Promise.allSettled(
pendingJobs.map(async (job) => {
- const payload = getSchedulePayloadFromValue(job.payload)
- const claimedAt = getSchedulePayloadClaimedAt(payload)
- if (!payload || !claimedAt) {
- await jobQueue.markJobFailed(job.id, 'Invalid pending schedule execution payload')
+ const recoveryMetadata = getScheduleRecoveryMetadataFromValue(job.payload)
+ const claimedAt = getSchedulePayloadClaimedAt(recoveryMetadata)
+ if (!recoveryMetadata || !claimedAt) {
+ await jobQueue.markJobFailed(job.id, 'Invalid pending schedule recovery metadata')
return true
}
- const claimState = await getScheduleClaimState(payload, claimedAt)
+ const claimState = await getScheduleClaimState(recoveryMetadata, claimedAt)
if (claimState === 'released') {
logger.info(`[${requestId}] Completing stale pending schedule execution job`, {
- scheduleId: payload.scheduleId,
- workflowId: payload.workflowId,
+ scheduleId: recoveryMetadata.scheduleId,
+ workflowId: recoveryMetadata.workflowId,
jobId: job.id,
})
await jobQueue.completeJob(job.id, {
@@ -687,24 +752,45 @@ async function resumePendingDatabaseScheduleJobs(
}
if (claimState === 'claimed_by_other') {
logger.info(`[${requestId}] Leaving pending schedule execution job for active claimant`, {
- scheduleId: payload.scheduleId,
- workflowId: payload.workflowId,
+ scheduleId: recoveryMetadata.scheduleId,
+ workflowId: recoveryMetadata.workflowId,
jobId: job.id,
})
return false
}
+ const payloadValidation = getSchedulePayloadValidation(job.payload)
+ if (!payloadValidation.success) {
+ const error = `Invalid pending schedule execution payload: ${payloadValidation.error}`
+ logger.warn(`[${requestId}] Rejecting invalid pending schedule execution payload`, {
+ scheduleId: recoveryMetadata.scheduleId,
+ workflowId: recoveryMetadata.workflowId,
+ jobId: job.id,
+ error,
+ })
+ await jobQueue.markJobFailed(job.id, error)
+ await releaseScheduleLock(
+ recoveryMetadata.scheduleId,
+ requestId,
+ new Date(),
+ `Released schedule ${recoveryMetadata.scheduleId} after rejecting invalid pending schedule execution payload`,
+ undefined,
+ { expectedLastQueuedAt: claimedAt }
+ )
+ return true
+ }
+
logger.info(`[${requestId}] Resuming pending database schedule execution job`, {
- scheduleId: payload.scheduleId,
- workflowId: payload.workflowId,
+ scheduleId: recoveryMetadata.scheduleId,
+ workflowId: recoveryMetadata.workflowId,
jobId: job.id,
})
await executeDatabaseScheduleJob(
jobQueue,
job.id,
- payload,
- getScheduleTargetFromPayload(payload),
+ payloadValidation.payload,
+ getScheduleTargetFromPayload(payloadValidation.payload),
claimedAt,
requestId,
0
@@ -740,6 +826,24 @@ async function processScheduleItem(
) {
const queueTime = schedule.lastQueuedAt ?? queuedAt
const executionId = generateId()
+ const workspaceId = schedule.workspaceId ?? undefined
+ let billingAttribution: BillingAttributionSnapshot
+ try {
+ if (!workspaceId) {
+ throw new Error(`Unable to resolve workspace for schedule ${schedule.id}`)
+ }
+ billingAttribution = await resolveSystemBillingAttribution(workspaceId)
+ } catch (error) {
+ await handleClaimedScheduleSetupFailure(
+ schedule,
+ requestId,
+ queueTime,
+ error,
+ `Failed to defer schedule ${schedule.id} after billing attribution failure`,
+ `Failed to mark schedule ${schedule.id} failed after billing attribution failure`
+ )
+ return
+ }
const correlation = {
executionId,
requestId,
@@ -757,7 +861,8 @@ async function processScheduleItem(
requestId,
correlation,
blockId: schedule.blockId || undefined,
- workspaceId: schedule.workspaceId || undefined,
+ workspaceId,
+ billingAttribution,
deploymentVersionId: schedule.deploymentVersionId || undefined,
cronExpression: schedule.cronExpression || undefined,
timezone: schedule.timezone || undefined,
@@ -766,7 +871,7 @@ async function processScheduleItem(
infraRetryCount: schedule.infraRetryCount || 0,
now: queueTime.toISOString(),
scheduledFor: schedule.nextRunAt?.toISOString(),
- }
+ } satisfies ScheduleExecutionPayload
let enqueuedJobId: string | null = null
@@ -776,7 +881,7 @@ async function processScheduleItem(
const scheduleJobId = buildScheduleExecutionJobId(schedule)
const existingJob = await jobQueue.getJob(scheduleJobId)
if (existingJob && ['pending', 'processing'].includes(existingJob.status)) {
- const activeJobPayload = getSchedulePayloadFromJob(existingJob)
+ const activeJobPayload = getScheduleRecoveryMetadataFromJob(existingJob)
const activeJobClaim = getSchedulePayloadClaimedAt(activeJobPayload)
if (useDatabaseFallback && isStaleDatabaseScheduleJob(existingJob)) {
@@ -788,7 +893,9 @@ async function processScheduleItem(
}
const databaseJob = useDatabaseFallback ? await jobQueue.getJob(scheduleJobId) : existingJob
- const databaseJobPayload = databaseJob ? getSchedulePayloadFromJob(databaseJob) : null
+ const databaseJobPayload = databaseJob
+ ? getScheduleRecoveryMetadataFromJob(databaseJob)
+ : null
const databaseJobClaim = getSchedulePayloadClaimedAt(databaseJobPayload) ?? activeJobClaim
if (!useDatabaseFallback && activeJobClaim && isStaleScheduleClaim(activeJobClaim)) {
logger.warn(`[${requestId}] Cancelling stale schedule execution job`, {
@@ -809,6 +916,28 @@ async function processScheduleItem(
}
if (useDatabaseFallback && databaseJob?.status === JOB_STATUS.PENDING) {
+ const payloadValidation = getSchedulePayloadValidation(databaseJob.payload)
+ if (!payloadValidation.success) {
+ const error = `Invalid pending schedule execution payload: ${payloadValidation.error}`
+ logger.warn(`[${requestId}] Rejecting invalid pending schedule execution payload`, {
+ scheduleId: schedule.id,
+ workflowId: schedule.workflowId,
+ jobId: scheduleJobId,
+ error,
+ })
+ enqueuedJobId = scheduleJobId
+ await jobQueue.markJobFailed(scheduleJobId, error)
+ await releaseScheduleLock(
+ schedule.id,
+ requestId,
+ queuedAt,
+ `Released schedule ${schedule.id} after rejecting invalid pending schedule execution payload`,
+ undefined,
+ { expectedLastQueuedAt: queueTime }
+ )
+ return
+ }
+
logger.info(`[${requestId}] Resuming pending database schedule execution job`, {
scheduleId: schedule.id,
jobId: scheduleJobId,
@@ -826,7 +955,7 @@ async function processScheduleItem(
await executeDatabaseScheduleJob(
jobQueue,
scheduleJobId,
- databaseJobPayload ?? payload,
+ payloadValidation.payload,
schedule,
databaseJobClaim ?? queueTime,
requestId,
@@ -975,7 +1104,9 @@ async function processScheduleItem(
return
}
if (queuedJob) {
- const queuedJobClaim = getSchedulePayloadClaimedAt(getSchedulePayloadFromJob(queuedJob))
+ const queuedJobClaim = getSchedulePayloadClaimedAt(
+ getScheduleRecoveryMetadataFromJob(queuedJob)
+ )
if (queuedJobClaim) {
if (isStaleScheduleClaim(queuedJobClaim)) {
logger.warn(`[${requestId}] Cancelling stale queued schedule execution job`, {
diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts
index 8c00a1c7787..bbf052af066 100644
--- a/apps/sim/app/api/speech/token/route.test.ts
+++ b/apps/sim/app/api/speech/token/route.test.ts
@@ -8,18 +8,39 @@ const {
mockGetSession,
mockRecordUsage,
mockCheckActorUsageLimits,
- mockGetWorkspaceBilledAccountUserId,
mockVerifyWorkspaceMembership,
mockChatRows,
+ mockResolveBillingAttribution,
+ mockResolveSystemBillingAttribution,
+ mockCheckAttributedUsageLimits,
+ mockToBillingContext,
+ mockCheckAndBillPayerOverageThreshold,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockRecordUsage: vi.fn(),
mockCheckActorUsageLimits: vi.fn(),
- mockGetWorkspaceBilledAccountUserId: vi.fn(),
mockVerifyWorkspaceMembership: vi.fn(),
mockChatRows: { value: [] as Array> },
+ mockResolveBillingAttribution: vi.fn(),
+ mockResolveSystemBillingAttribution: vi.fn(),
+ mockCheckAttributedUsageLimits: vi.fn(),
+ mockToBillingContext: vi.fn(),
+ mockCheckAndBillPayerOverageThreshold: vi.fn(),
}))
+const SYSTEM_BILLING_ATTRIBUTION = {
+ actorUserId: 'owner-after-transfer',
+ workspaceId: 'ws-1',
+ organizationId: 'org-after-transfer',
+ billedAccountUserId: 'owner-after-transfer',
+ billingEntity: { type: 'organization' as const, id: 'org-after-transfer' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
vi.mock('@sim/db', () => ({
db: {
select: () => {
@@ -37,12 +58,19 @@ vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage }))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ resolveBillingAttribution: mockResolveBillingAttribution,
+ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
+ checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
+ toBillingContext: mockToBillingContext,
+}))
+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: mockCheckActorUsageLimits,
}))
-vi.mock('@/lib/workspaces/utils', () => ({
- getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
+vi.mock('@/lib/billing/threshold-billing', () => ({
+ checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
}))
vi.mock('@/app/api/workflows/utils', () => ({
@@ -81,7 +109,24 @@ beforeEach(() => {
mockGetSession.mockResolvedValue({ user: { id: 'member-1' } })
mockRecordUsage.mockResolvedValue(undefined)
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
- mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-acct')
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockResolveBillingAttribution.mockImplementation(
+ ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => ({
+ actorUserId,
+ workspaceId,
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ )
+ mockResolveSystemBillingAttribution.mockResolvedValue(SYSTEM_BILLING_ATTRIBUTION)
+ mockToBillingContext.mockImplementation(
+ (attribution: { billingEntity: { type: 'organization' | 'user'; id: string } }) => ({
+ billingEntity: attribution.billingEntity,
+ billingPeriod: {
+ start: new Date('2026-07-01T00:00:00.000Z'),
+ end: new Date('2026-08-01T00:00:00.000Z'),
+ },
+ })
+ )
mockVerifyWorkspaceMembership.mockResolvedValue('admin')
global.fetch = vi.fn().mockResolvedValue({
ok: true,
@@ -101,6 +146,15 @@ describe('POST /api/speech/token — usage attribution', () => {
userId: 'member-1',
workspaceId: 'ws-1',
})
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'member-1',
+ workspaceId: 'ws-1',
+ })
+ expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
+ expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
+ type: 'organization',
+ id: 'org-1',
+ })
})
it('editor voice: rejects an unverified workspace id (requires an attributable workspace)', async () => {
@@ -112,30 +166,39 @@ describe('POST /api/speech/token — usage attribution', () => {
expect(mockRecordUsage).not.toHaveBeenCalled()
})
- it('deployed chat: bills the workspace billed account and stamps the chat workspace', async () => {
+ it('deployed chat: uses one atomic system actor and payer snapshot', async () => {
mockChatRows.value = [publicChatRow]
const res = await POST(createMockRequest('POST', { chatId: 'chat-1' }))
expect(res.status).toBe(200)
expect(mockGetSession).not.toHaveBeenCalled()
- expect(mockGetWorkspaceBilledAccountUserId).toHaveBeenCalledWith('ws-1')
+ expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('ws-1')
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(SYSTEM_BILLING_ATTRIBUTION)
+ expect(mockToBillingContext).toHaveBeenCalledWith(SYSTEM_BILLING_ATTRIBUTION)
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
- userId: 'billed-acct',
+ userId: 'owner-after-transfer',
workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-after-transfer' },
+ })
+ expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
+ type: 'organization',
+ id: 'org-after-transfer',
})
})
- it('deployed chat: falls back to the chat owner when no billed account resolves', async () => {
- mockChatRows.value = [publicChatRow]
- mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
+ it('deployed chat: uses the chat owner only when no workspace exists', async () => {
+ mockChatRows.value = [{ ...publicChatRow, workspaceId: null }]
const res = await POST(createMockRequest('POST', { chatId: 'chat-1' }))
expect(res.status).toBe(200)
+ expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
userId: 'owner-1',
- workspaceId: 'ws-1',
})
+ expect(mockRecordUsage.mock.calls[0][0].workspaceId).toBeUndefined()
})
})
diff --git a/apps/sim/app/api/speech/token/route.ts b/apps/sim/app/api/speech/token/route.ts
index 3ccb721366f..a94f5a8dd59 100644
--- a/apps/sim/app/api/speech/token/route.ts
+++ b/apps/sim/app/api/speech/token/route.ts
@@ -8,14 +8,21 @@ import { type NextRequest, NextResponse } from 'next/server'
import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech'
import { getSession } from '@/lib/auth'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ type BillingAttributionSnapshot,
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ resolveSystemBillingAttribution,
+ toBillingContext,
+} from '@/lib/billing/core/billing-attribution'
import { recordUsage } from '@/lib/billing/core/usage-log'
+import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { validateAuthToken } from '@/lib/core/security/deployment'
import { getClientIp } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('SpeechTokenAPI')
@@ -92,31 +99,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const chatId =
body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined
- let billingUserId: string | undefined
+ let actorUserId: string | undefined
let workspaceId: string | undefined
+ let billingAttribution: BillingAttributionSnapshot | undefined
if (chatId) {
const chatAuth = await validateChatAuth(request, chatId)
if (!chatAuth.valid) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- // A deployed chat is used by anonymous end-users, so the cost belongs to the
- // workspace's billed account (the deployment's payer) — matching how the
- // chat's workflow execution bills. Fall back to the chat owner only when no
- // billed account resolves.
+ /**
+ * Anonymous deployed chats have no human request actor, so resolve the
+ * system actor and immutable workspace payer together.
+ */
workspaceId = chatAuth.workspaceId ?? undefined
- const billedAccountUserId = workspaceId
- ? await getWorkspaceBilledAccountUserId(workspaceId)
- : null
- billingUserId = billedAccountUserId ?? chatAuth.ownerId
+ if (workspaceId) {
+ billingAttribution = await resolveSystemBillingAttribution(workspaceId)
+ actorUserId = billingAttribution.actorUserId
+ } else {
+ actorUserId = chatAuth.ownerId
+ }
} else {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- billingUserId = session.user.id
- // Editor voice: only attribute to a workspace the caller actually belongs to,
- // so a client-supplied id can't misattribute (or dodge) per-member usage.
+ actorUserId = session.user.id
+ /**
+ * Editor voice accepts only a workspace the caller belongs to, preventing
+ * client-supplied IDs from misattributing or bypassing member usage.
+ */
const requestedWorkspaceId =
body.success && typeof body.data.workspaceId === 'string'
? body.data.workspaceId
@@ -125,17 +137,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const permission = await verifyWorkspaceMembership(session.user.id, requestedWorkspaceId)
if (permission) workspaceId = requestedWorkspaceId
}
- // Editor voice is always workspace-scoped; require an attributable workspace
- // so per-member usage can't be skipped and the cost stamped workspace-less.
+ /**
+ * Editor voice is workspace-scoped so every charge has a payer and member
+ * cap attribution.
+ */
if (!workspaceId) {
return NextResponse.json({ error: 'Workspace context is required.' }, { status: 400 })
}
}
+ if (!billingAttribution && actorUserId && workspaceId) {
+ billingAttribution = await resolveBillingAttribution({
+ actorUserId,
+ workspaceId,
+ })
+ }
+
if (isBillingEnabled) {
const rateLimitKey = chatId
? `stt-token:chat:${chatId}:${getClientIp(request)}`
- : `stt-token:user:${billingUserId}`
+ : `stt-token:user:${actorUserId}`
const rateCheck = await rateLimiter.checkRateLimitDirect(rateLimitKey, STT_TOKEN_RATE_LIMIT)
if (!rateCheck.allowed) {
@@ -151,8 +172,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}
- if (billingUserId) {
- const usageCheck = await checkActorUsageLimits(billingUserId, workspaceId)
+ if (actorUserId) {
+ const usageCheck = billingAttribution
+ ? await checkAttributedUsageLimits(billingAttribution)
+ : await checkActorUsageLimits(actorUserId)
if (usageCheck.isExceeded) {
return NextResponse.json(
{
@@ -188,25 +211,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const data = await response.json()
- if (billingUserId) {
+ if (actorUserId) {
const maxMinutes = chatId ? CHAT_SESSION_MAX_MINUTES : WORKSPACE_SESSION_MAX_MINUTES
const sessionCost = VOICE_SESSION_COST_PER_MIN * maxMinutes
- await recordUsage({
- userId: billingUserId,
- workspaceId,
- entries: [
- {
- category: 'fixed',
- source: 'voice-input',
- description: `Voice input session (${maxMinutes} min)`,
- cost: sessionCost * getCostMultiplier(),
- sourceReference: `voice-input:${hashVoiceToken(data.token)}`,
- },
- ],
- }).catch((err) => {
+ try {
+ await recordUsage({
+ userId: actorUserId,
+ workspaceId,
+ ...(billingAttribution ? toBillingContext(billingAttribution) : {}),
+ entries: [
+ {
+ category: 'fixed',
+ source: 'voice-input',
+ description: `Voice input session (${maxMinutes} min)`,
+ cost: sessionCost * getCostMultiplier(),
+ sourceReference: `voice-input:${hashVoiceToken(data.token)}`,
+ },
+ ],
+ })
+ if (billingAttribution) {
+ await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity)
+ }
+ } catch (err) {
logger.warn('Failed to record voice input usage, continuing:', err)
- })
+ }
}
return NextResponse.json({ token: data.token })
diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts
index 3c12cb21bbf..1c5cf6d803b 100644
--- a/apps/sim/app/api/tools/jupyter/upload/route.ts
+++ b/apps/sim/app/api/tools/jupyter/upload/route.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { isRecordLike } from '@sim/utils/object'
import { type NextRequest, NextResponse } from 'next/server'
import { jupyterUploadContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
@@ -128,17 +129,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const uploaded = await response.json()
+ const uploadedValue: unknown = await response.json()
+ const uploaded = isRecordLike(uploadedValue) ? uploadedValue : {}
+ const uploadedPath = typeof uploaded.path === 'string' ? uploaded.path : destinationPath
- logger.info(`[${requestId}] File uploaded to Jupyter: ${uploaded.path}`)
+ logger.info(`[${requestId}] File uploaded to Jupyter: ${uploadedPath}`)
return NextResponse.json({
success: true,
output: {
- name: uploaded.name ?? fileName,
- path: uploaded.path ?? destinationPath,
- size: uploaded.size ?? fileBuffer.length,
- lastModified: uploaded.last_modified ?? null,
+ name: typeof uploaded.name === 'string' ? uploaded.name : fileName,
+ path: uploadedPath,
+ size: typeof uploaded.size === 'number' ? uploaded.size : fileBuffer.length,
+ lastModified: typeof uploaded.last_modified === 'string' ? uploaded.last_modified : null,
},
})
} catch (error) {
diff --git a/apps/sim/app/api/usage/route.test.ts b/apps/sim/app/api/usage/route.test.ts
new file mode 100644
index 00000000000..ab03f1a5982
--- /dev/null
+++ b/apps/sim/app/api/usage/route.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetOrganizationBillingData, mockGetSession, mockIsOrganizationOwnerOrAdmin } =
+ vi.hoisted(() => ({
+ mockGetOrganizationBillingData: vi.fn(),
+ mockGetSession: vi.fn(),
+ mockIsOrganizationOwnerOrAdmin: vi.fn(),
+ }))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/billing', () => ({
+ getUserUsageLimitInfo: vi.fn(),
+ updateUserUsageLimit: vi.fn(),
+}))
+
+vi.mock('@/lib/billing/core/organization', () => ({
+ getOrganizationBillingData: mockGetOrganizationBillingData,
+ isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin,
+}))
+
+import { GET } from '@/app/api/usage/route'
+
+describe('GET /api/usage organization context', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'member-1' } })
+ })
+
+ it('rejects ordinary members before loading organization usage data', async () => {
+ mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false)
+
+ const response = await GET(
+ createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/usage?context=organization&organizationId=org-1'
+ )
+ )
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({ error: 'Permission denied' })
+ expect(mockGetOrganizationBillingData).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/usage/route.ts b/apps/sim/app/api/usage/route.ts
index c8271ace599..251e51bde5e 100644
--- a/apps/sim/app/api/usage/route.ts
+++ b/apps/sim/app/api/usage/route.ts
@@ -9,7 +9,6 @@ import {
getOrganizationBillingData,
isOrganizationOwnerOrAdmin,
} from '@/lib/billing/core/organization'
-import { isUserMemberOfOrganization } from '@/lib/billing/organizations/membership'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('UnifiedUsageAPI')
@@ -58,8 +57,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
}
- const membership = await isUserMemberOfOrganization(session.user.id, organizationId)
- if (!membership.isMember) {
+ const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
+ if (!hasPermission) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
}
diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts
index fb2411ace55..cd5f2eb83ca 100644
--- a/apps/sim/app/api/users/me/api-keys/route.ts
+++ b/apps/sim/app/api/users/me/api-keys/route.ts
@@ -42,8 +42,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
keys.map(async (key) => {
const displayFormat = await getApiKeyDisplayFormat(key.key)
return {
- ...key,
- key: key.key,
+ id: key.id,
+ name: key.name,
+ createdAt: key.createdAt,
+ lastUsed: key.lastUsed,
+ expiresAt: key.expiresAt,
displayKey: displayFormat,
}
})
diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts
index cee604950e5..4743b35041a 100644
--- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts
+++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts
@@ -12,9 +12,27 @@ import {
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+const { mockAcquireOrganizationMutationLock, mockAssertNoUnresolvedEnterpriseIssuance } =
+ vi.hoisted(() => ({
+ mockAcquireOrganizationMutationLock: vi.fn(),
+ mockAssertNoUnresolvedEnterpriseIssuance: vi.fn(),
+ }))
+
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/auth', () => authMock)
+vi.mock('@/lib/billing/enterprise-outbox', () => {
+ class EnterpriseIssuanceInProgressError extends Error {}
+ return {
+ EnterpriseIssuanceInProgressError,
+ assertNoUnresolvedEnterpriseIssuance: mockAssertNoUnresolvedEnterpriseIssuance,
+ }
+})
+
+vi.mock('@/lib/billing/organizations/membership', () => ({
+ acquireOrganizationMutationLock: mockAcquireOrganizationMutationLock,
+}))
+
vi.mock('@/lib/billing/plan-helpers', () => ({
isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise',
}))
@@ -82,6 +100,32 @@ describe('POST /api/users/me/subscription/[id]/transfer', () => {
})
expect(dbChainMockFns.update).toHaveBeenCalled()
expect(dbChainMockFns.set).toHaveBeenCalledWith({ referenceId: 'org-1' })
+ expect(mockAcquireOrganizationMutationLock).toHaveBeenCalledWith(expect.anything(), 'org-1')
+ expect(mockAssertNoUnresolvedEnterpriseIssuance).toHaveBeenCalledWith(
+ expect.anything(),
+ 'org-1'
+ )
+ })
+
+ it('rejects an entitlement transfer while Enterprise issuance is unresolved', async () => {
+ const { EnterpriseIssuanceInProgressError } = await import('@/lib/billing/enterprise-outbox')
+ dbChainMockFns.for
+ .mockResolvedValueOnce([
+ { id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
+ ])
+ .mockResolvedValueOnce([{ id: 'org-1' }])
+ dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }])
+ mockAssertNoUnresolvedEnterpriseIssuance.mockRejectedValueOnce(
+ new EnterpriseIssuanceInProgressError()
+ )
+
+ const response = await makeRequest({ organizationId: 'org-1' })
+
+ expect(response.status).toBe(409)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Organization has an unfinished Enterprise issuance',
+ })
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('treats an already-transferred organization subscription as a successful no-op', async () => {
diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
index adce3c771ef..161349f2ffb 100644
--- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
+++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
@@ -8,6 +8,11 @@ import { type NextRequest, NextResponse } from 'next/server'
import { subscriptionTransferContract } from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import {
+ assertNoUnresolvedEnterpriseIssuance,
+ EnterpriseIssuanceInProgressError,
+} from '@/lib/billing/enterprise-outbox'
+import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
@@ -42,6 +47,10 @@ export const POST = withRouteHandler(
logger.info('Processing subscription transfer', { subscriptionId, organizationId })
const outcome = await db.transaction(async (tx): Promise => {
+ // Organization-first lock ordering serializes this entitlement move
+ // with Enterprise issuance and membership mutations.
+ await acquireOrganizationMutationLock(tx, organizationId)
+
const [sub] = await tx
.select()
.from(subscription)
@@ -97,6 +106,17 @@ export const POST = withRouteHandler(
}
}
+ try {
+ await assertNoUnresolvedEnterpriseIssuance(tx, organizationId)
+ } catch (error) {
+ if (!(error instanceof EnterpriseIssuanceInProgressError)) throw error
+ return {
+ kind: 'error',
+ status: 409,
+ error: 'Organization has an unfinished Enterprise issuance',
+ }
+ }
+
const [existingOrgSub] = await tx
.select({ id: subscription.id })
.from(subscription)
diff --git a/apps/sim/app/api/v1/admin/dashboard/actor.ts b/apps/sim/app/api/v1/admin/dashboard/actor.ts
new file mode 100644
index 00000000000..c3237cd200a
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/actor.ts
@@ -0,0 +1,16 @@
+import { db } from '@sim/db'
+import { user } from '@sim/db/schema'
+import { eq, or } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import type { AdminMutationActor } from '@/lib/admin/dashboard'
+
+export async function getAdminAuditActor(request: NextRequest): Promise {
+ const email = request.headers.get('x-admin-email')?.trim().toLowerCase()
+ if (!email) return { id: null, name: 'Admin API', email: null }
+ const [admin] = await db
+ .select({ id: user.id, name: user.name, email: user.email })
+ .from(user)
+ .where(or(eq(user.email, email), eq(user.normalizedEmail, email)))
+ .limit(1)
+ return admin ?? { id: null, name: 'Admin Panel', email }
+}
diff --git a/apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/[id]/retry/route.ts b/apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/[id]/retry/route.ts
new file mode 100644
index 00000000000..6fe8a61365e
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/[id]/retry/route.ts
@@ -0,0 +1,36 @@
+import { createLogger } from '@sim/logger'
+import { adminDashboardRetryEnterpriseContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import {
+ EnterpriseProvisioningError,
+ retryEnterpriseProvisioning,
+} from '@/lib/billing/enterprise-provisioning'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminValidationErrorResponse,
+ badRequestResponse,
+ internalErrorResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminEnterpriseProvisioningRetryAPI')
+
+export const POST = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardRetryEnterpriseContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ return singleResponse(
+ await retryEnterpriseProvisioning(parsed.data.params.id, await getAdminAuditActor(request))
+ )
+ } catch (error) {
+ if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
+ logger.error('Failed to retry Enterprise provisioning', { error })
+ return internalErrorResponse('Failed to retry Enterprise provisioning')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/route.ts b/apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/route.ts
new file mode 100644
index 00000000000..19ce6ab4346
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/enterprise-provisioning/route.ts
@@ -0,0 +1,49 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { adminDashboardIssueEnterpriseContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import {
+ EnterpriseProvisioningError,
+ issueEnterpriseProvisioning,
+} from '@/lib/billing/enterprise-provisioning'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ internalErrorResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminEnterpriseProvisioningAPI')
+
+export const POST = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(
+ adminDashboardIssueEnterpriseContract,
+ request,
+ {},
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ try {
+ const actor = await getAdminAuditActor(request)
+ return singleResponse(
+ await issueEnterpriseProvisioning({
+ ...parsed.data.body,
+ requestedByEmail: actor.email ?? 'admin-api',
+ requestedByUserId: actor.id,
+ })
+ )
+ } catch (error) {
+ if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
+ logger.error('Failed to enqueue Enterprise provisioning', { error })
+ return internalErrorResponse(getErrorMessage(error, 'Failed to issue Enterprise plan'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/global-work/route.ts b/apps/sim/app/api/v1/admin/dashboard/global-work/route.ts
new file mode 100644
index 00000000000..2105915eb45
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/global-work/route.ts
@@ -0,0 +1,32 @@
+import { createLogger } from '@sim/logger'
+import { NextResponse } from 'next/server'
+import { adminV1GetGlobalWorkContract } from '@/lib/api/contracts/v1/admin/global-work'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getGlobalWorkSummary } from '@/lib/global-work/summary'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import { adminValidationErrorResponse, internalErrorResponse } from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminGlobalWorkAPI')
+
+export const GET = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(
+ adminV1GetGlobalWorkContract,
+ request,
+ {},
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ try {
+ const data = await getGlobalWorkSummary(parsed.data.query.month)
+ return NextResponse.json({ data })
+ } catch (error) {
+ logger.error('Failed to build Global Work summary', { error })
+ return internalErrorResponse('Failed to build Global Work summary')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/credits/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/credits/route.ts
new file mode 100644
index 00000000000..863f00b14e4
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/credits/route.ts
@@ -0,0 +1,35 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { grantDashboardOrganizationCredits } from '@/lib/admin/dashboard'
+import { adminDashboardGrantCreditsContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+export const POST = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardGrantCreditsContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ const result = await grantDashboardOrganizationCredits(
+ parsed.data.params.id,
+ parsed.data.body.credits,
+ parsed.data.body.reason,
+ parsed.data.body.operationId,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const, ...result })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to grant credits'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/external-collaborators/[userId]/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/external-collaborators/[userId]/route.ts
new file mode 100644
index 00000000000..936399587d9
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/external-collaborators/[userId]/route.ts
@@ -0,0 +1,47 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { updateDashboardExternalCollaboratorUsageLimit } from '@/lib/admin/external-collaborators'
+import { adminDashboardUpdateExternalCollaboratorLimitContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+interface RouteParams {
+ id: string
+ userId: string
+}
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(
+ adminDashboardUpdateExternalCollaboratorLimitContract,
+ request,
+ context,
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ try {
+ await updateDashboardExternalCollaboratorUsageLimit(
+ parsed.data.params.id,
+ parsed.data.params.userId,
+ parsed.data.body.usageLimitCredits,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const })
+ } catch (error) {
+ return badRequestResponse(
+ getErrorMessage(error, 'Failed to update external collaborator usage cap')
+ )
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/limits/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/limits/route.ts
new file mode 100644
index 00000000000..cab50eb3749
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/limits/route.ts
@@ -0,0 +1,33 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { updateDashboardOrganizationLimits } from '@/lib/admin/dashboard'
+import { adminDashboardUpdateLimitsContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardUpdateLimitsContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ await updateDashboardOrganizationLimits(
+ parsed.data.params.id,
+ parsed.data.body,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to update limits'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/[memberId]/route.ts
new file mode 100644
index 00000000000..314d1181c82
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/[memberId]/route.ts
@@ -0,0 +1,64 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import {
+ removeDashboardOrganizationMember,
+ updateDashboardOrganizationMember,
+} from '@/lib/admin/dashboard'
+import {
+ adminDashboardRemoveMemberContract,
+ adminDashboardUpdateMemberContract,
+} from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+interface RouteParams {
+ id: string
+ memberId: string
+}
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardUpdateMemberContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ await updateDashboardOrganizationMember(
+ parsed.data.params.id,
+ parsed.data.params.memberId,
+ parsed.data.body,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to update member'))
+ }
+ })
+)
+
+export const DELETE = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardRemoveMemberContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ await removeDashboardOrganizationMember(
+ parsed.data.params.id,
+ parsed.data.params.memberId,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to remove member'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/route.ts
new file mode 100644
index 00000000000..ee044d4664e
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/route.ts
@@ -0,0 +1,33 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { addDashboardOrganizationMember } from '@/lib/admin/dashboard'
+import { adminDashboardAddMemberContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+export const POST = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardAddMemberContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ const memberId = await addDashboardOrganizationMember(
+ parsed.data.params.id,
+ parsed.data.body,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const, memberId })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to add member'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/route.ts
new file mode 100644
index 00000000000..727cb6a0fbc
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/route.ts
@@ -0,0 +1,30 @@
+import { createLogger } from '@sim/logger'
+import { getDashboardOrganization } from '@/lib/admin/dashboard'
+import { adminDashboardGetOrganizationContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminValidationErrorResponse,
+ internalErrorResponse,
+ notFoundResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminDashboardOrganizationAPI')
+
+export const GET = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardGetOrganizationContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ const organization = await getDashboardOrganization(parsed.data.params.id)
+ return organization ? singleResponse(organization) : notFoundResponse('Organization')
+ } catch (error) {
+ logger.error('Failed to get dashboard organization', { error })
+ return internalErrorResponse('Failed to get organization')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/seats/route.ts
new file mode 100644
index 00000000000..c763013e88f
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/seats/route.ts
@@ -0,0 +1,33 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { updateDashboardEnterpriseSeats } from '@/lib/admin/dashboard'
+import { adminDashboardUpdateSeatsContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+export const PATCH = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardUpdateSeatsContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ await updateDashboardEnterpriseSeats(
+ parsed.data.params.id,
+ parsed.data.body.seats,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to update seats'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/transfer-ownership/route.ts
new file mode 100644
index 00000000000..2a563ba36bd
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/[id]/transfer-ownership/route.ts
@@ -0,0 +1,33 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { transferDashboardOrganizationOwnership } from '@/lib/admin/dashboard'
+import { adminDashboardTransferOwnershipContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+export const POST = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardTransferOwnershipContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ await transferDashboardOrganizationOwnership(
+ parsed.data.params.id,
+ parsed.data.body.newOwnerUserId,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to transfer ownership'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/organizations/route.ts b/apps/sim/app/api/v1/admin/dashboard/organizations/route.ts
new file mode 100644
index 00000000000..56ca1945f39
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/organizations/route.ts
@@ -0,0 +1,30 @@
+import { createLogger } from '@sim/logger'
+import { NextResponse } from 'next/server'
+import { listDashboardOrganizations } from '@/lib/admin/dashboard'
+import { adminDashboardListOrganizationsContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import { adminValidationErrorResponse, internalErrorResponse } from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminDashboardOrganizationsAPI')
+
+export const GET = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(
+ adminDashboardListOrganizationsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ try {
+ return NextResponse.json(await listDashboardOrganizations(parsed.data.query))
+ } catch (error) {
+ logger.error('Failed to list dashboard organizations', { error })
+ return internalErrorResponse('Failed to list organizations')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/users/[id]/credits/route.ts b/apps/sim/app/api/v1/admin/dashboard/users/[id]/credits/route.ts
new file mode 100644
index 00000000000..88ee5c8c4d6
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/users/[id]/credits/route.ts
@@ -0,0 +1,35 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { grantDashboardUserCredits } from '@/lib/admin/dashboard'
+import { adminDashboardGrantUserCreditsContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ badRequestResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+export const POST = withRouteHandler(
+ withAdminAuthParams<{ id: string }>(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardGrantUserCreditsContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+ try {
+ const result = await grantDashboardUserCredits(
+ parsed.data.params.id,
+ parsed.data.body.credits,
+ parsed.data.body.reason,
+ parsed.data.body.operationId,
+ await getAdminAuditActor(request)
+ )
+ return singleResponse({ success: true as const, ...result })
+ } catch (error) {
+ return badRequestResponse(getErrorMessage(error, 'Failed to grant credits'))
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/users/route.ts b/apps/sim/app/api/v1/admin/dashboard/users/route.ts
new file mode 100644
index 00000000000..b8a4d27d52f
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/users/route.ts
@@ -0,0 +1,30 @@
+import { createLogger } from '@sim/logger'
+import { NextResponse } from 'next/server'
+import { listDashboardUsers } from '@/lib/admin/dashboard'
+import { adminDashboardListUsersContract } from '@/lib/api/contracts/v1/admin/dashboard'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import { adminValidationErrorResponse, internalErrorResponse } from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminDashboardUsersAPI')
+
+export const GET = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(
+ adminDashboardListUsersContract,
+ request,
+ {},
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+ try {
+ return NextResponse.json(await listDashboardUsers(parsed.data.query))
+ } catch (error) {
+ logger.error('Failed to list dashboard users', { error })
+ return internalErrorResponse('Failed to list users')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/move/route.ts b/apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/move/route.ts
new file mode 100644
index 00000000000..8199fb3d78f
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/move/route.ts
@@ -0,0 +1,49 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { NextResponse } from 'next/server'
+import { adminDashboardWorkspaceMoveContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { moveWorkspaceToOrganization, WorkspaceMoveError } from '@/lib/workspaces/admin-move'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ badRequestResponse,
+ internalErrorResponse,
+ notFoundResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminDashboardWorkspaceMoveAPI')
+
+interface RouteParams {
+ id: string
+}
+
+export const POST = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardWorkspaceMoveContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ try {
+ const data = await moveWorkspaceToOrganization({
+ workspaceId: parsed.data.params.id,
+ destinationOrganizationId: parsed.data.body.destinationOrganizationId,
+ adminEmail: request.headers.get('x-admin-email') ?? 'admin-api@sim.ai',
+ })
+ return NextResponse.json({ data })
+ } catch (error) {
+ if (error instanceof WorkspaceMoveError) {
+ if (error.code === 'workspace-not-found' || error.code === 'organization-not-found') {
+ return notFoundResponse(
+ error.code === 'workspace-not-found' ? 'Workspace' : 'Organization'
+ )
+ }
+ return badRequestResponse(error.message)
+ }
+ logger.error('Failed to move workspace into organization', {
+ error: getErrorMessage(error),
+ workspaceId: parsed.data.params.id,
+ })
+ return internalErrorResponse('Failed to move workspace')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/preflight/route.ts b/apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/preflight/route.ts
new file mode 100644
index 00000000000..729a765008a
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/preflight/route.ts
@@ -0,0 +1,48 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { NextResponse } from 'next/server'
+import { adminDashboardWorkspacePreflightContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkspaceMovePreflight, WorkspaceMoveError } from '@/lib/workspaces/admin-move'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ badRequestResponse,
+ internalErrorResponse,
+ notFoundResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminDashboardWorkspacePreflightAPI')
+
+interface RouteParams {
+ id: string
+}
+
+export const GET = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminDashboardWorkspacePreflightContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ try {
+ const data = await getWorkspaceMovePreflight(
+ parsed.data.params.id,
+ parsed.data.query.destinationOrganizationId
+ )
+ return NextResponse.json({ data })
+ } catch (error) {
+ if (error instanceof WorkspaceMoveError) {
+ if (error.code === 'workspace-not-found' || error.code === 'organization-not-found') {
+ return notFoundResponse(
+ error.code === 'workspace-not-found' ? 'Workspace' : 'Organization'
+ )
+ }
+ return badRequestResponse(error.message)
+ }
+ logger.error('Failed to build workspace move preflight', {
+ error: getErrorMessage(error),
+ workspaceId: parsed.data.params.id,
+ })
+ return internalErrorResponse('Failed to build workspace move preflight')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/dashboard/workspaces/route.ts b/apps/sim/app/api/v1/admin/dashboard/workspaces/route.ts
new file mode 100644
index 00000000000..b28005230a8
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/dashboard/workspaces/route.ts
@@ -0,0 +1,31 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { NextResponse } from 'next/server'
+import { adminDashboardWorkspaceSearchContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { searchWorkspaceMoveCandidates } from '@/lib/workspaces/admin-move'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import { internalErrorResponse } from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminDashboardWorkspacesAPI')
+
+export const GET = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(adminDashboardWorkspaceSearchContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ try {
+ const data = await searchWorkspaceMoveCandidates(
+ parsed.data.query.search,
+ parsed.data.query.limit
+ )
+ return NextResponse.json({ data })
+ } catch (error) {
+ logger.error('Failed to search workspace move candidates', {
+ error: getErrorMessage(error),
+ })
+ return internalErrorResponse('Failed to search workspaces')
+ }
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts
index 2b00537059a..5c9525ca7ff 100644
--- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts
+++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts
@@ -2,10 +2,15 @@ import { db } from '@sim/db'
import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { adminV1RequeueOutboxEventContract } from '@/lib/api/contracts/v1/admin'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import {
+ ENTERPRISE_METADATA_SYNC_EVENT_TYPE,
+ ENTERPRISE_PROVISION_EVENT_TYPE,
+ enterpriseMetadataSyncPayloadSchema,
+} from '@/lib/billing/enterprise-outbox'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
@@ -36,6 +41,26 @@ export const POST = withRouteHandler(
const { id } = parsed.data.params
try {
+ const [existing] = await db
+ .select({ eventType: outboxEvent.eventType, payload: outboxEvent.payload })
+ .from(outboxEvent)
+ .where(eq(outboxEvent.id, id))
+ .limit(1)
+ if (existing?.eventType === ENTERPRISE_PROVISION_EVENT_TYPE) {
+ return invalidOutboxEventResponse(
+ 'Enterprise issuance must be retried through its dedicated admin action'
+ )
+ }
+ const metadataIntent =
+ existing?.eventType === ENTERPRISE_METADATA_SYNC_EVENT_TYPE
+ ? enterpriseMetadataSyncPayloadSchema.safeParse(existing.payload)
+ : null
+ if (metadataIntent && !metadataIntent.success) {
+ return invalidOutboxEventResponse('Enterprise metadata intent payload is invalid')
+ }
+ const deliveryRevision = metadataIntent?.success
+ ? metadataIntent.data.deliveryRevision + 1
+ : null
const result = await db
.update(outboxEvent)
.set({
@@ -45,6 +70,11 @@ export const POST = withRouteHandler(
availableAt: new Date(),
lockedAt: null,
processedAt: null,
+ ...(deliveryRevision === null
+ ? {}
+ : {
+ payload: sql`(${outboxEvent.payload}::jsonb || ${JSON.stringify({ deliveryRevision })}::jsonb)::json`,
+ }),
})
.where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter')))
.returning({ id: outboxEvent.id, eventType: outboxEvent.eventType })
diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts
new file mode 100644
index 00000000000..119c7e9c643
--- /dev/null
+++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts
@@ -0,0 +1,84 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockIsOrganizationBillingBlocked,
+ mockMemberWhere,
+ mockMembersWhere,
+ mockSelect,
+ mockSubscriptionWhere,
+} = vi.hoisted(() => ({
+ mockIsOrganizationBillingBlocked: vi.fn(),
+ mockMemberWhere: vi.fn(),
+ mockMembersWhere: vi.fn(),
+ mockSelect: vi.fn(),
+ mockSubscriptionWhere: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => ({
+ db: { select: mockSelect },
+}))
+
+vi.mock('@sim/db/schema', () => ({
+ member: {
+ organizationId: 'member.organizationId',
+ role: 'member.role',
+ userId: 'member.userId',
+ },
+ subscription: {
+ id: 'subscription.id',
+ plan: 'subscription.plan',
+ referenceId: 'subscription.referenceId',
+ status: 'subscription.status',
+ },
+}))
+
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
+ eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })),
+ inArray: vi.fn((left: unknown, right: unknown) => ({ type: 'inArray', left, right })),
+}))
+
+vi.mock('@/lib/billing/core/access', () => ({
+ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked,
+}))
+
+import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
+
+describe('enterprise audit access', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsOrganizationBillingBlocked.mockResolvedValue(false)
+ mockMemberWhere.mockReturnValue({
+ limit: vi.fn().mockResolvedValue([{ organizationId: 'organization-route', role: 'admin' }]),
+ })
+ mockSubscriptionWhere.mockReturnValue({
+ limit: vi.fn().mockResolvedValue([{ id: 'subscription-1' }]),
+ })
+ mockMembersWhere.mockResolvedValue([{ userId: 'viewer' }, { userId: 'member-2' }])
+ mockSelect
+ .mockReturnValueOnce({ from: () => ({ where: mockMemberWhere }) })
+ .mockReturnValueOnce({ from: () => ({ where: mockSubscriptionWhere }) })
+ .mockReturnValueOnce({ from: () => ({ where: mockMembersWhere }) })
+ })
+
+ it('authorizes and bills against the organization named by the route', async () => {
+ await expect(validateEnterpriseAuditAccess('viewer', 'organization-route')).resolves.toEqual({
+ success: true,
+ context: {
+ organizationId: 'organization-route',
+ orgMemberIds: ['viewer', 'member-2'],
+ },
+ })
+ expect(mockMemberWhere).toHaveBeenCalledWith({
+ type: 'and',
+ conditions: [
+ { type: 'eq', left: 'member.userId', right: 'viewer' },
+ { type: 'eq', left: 'member.organizationId', right: 'organization-route' },
+ ],
+ })
+ expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route')
+ })
+})
diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts
index b01f6af9736..323d1b82bdd 100644
--- a/apps/sim/app/api/v1/audit-logs/auth.ts
+++ b/apps/sim/app/api/v1/audit-logs/auth.ts
@@ -10,7 +10,7 @@ import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
-import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
+import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('V1AuditLogsAuth')
@@ -35,11 +35,18 @@ type AuthResult =
* Returns the organization ID and all member user IDs on success,
* or an error response on failure.
*/
-export async function validateEnterpriseAuditAccess(userId: string): Promise {
+export async function validateEnterpriseAuditAccess(
+ userId: string,
+ targetOrganizationId?: string
+): Promise {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
- .where(eq(member.userId, userId))
+ .where(
+ targetOrganizationId
+ ? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId))
+ : eq(member.userId, userId)
+ )
.limit(1)
if (!membership) {
@@ -59,8 +66,8 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise ({
mockAuthenticateRequest: vi.fn(),
mockResolveKnowledgeBase: vi.fn(),
@@ -23,8 +26,24 @@ const {
mockCreateSingleDocument: vi.fn(),
mockProcessDocumentsWithQueue: vi.fn(),
mockValidateFileType: vi.fn(),
+ mockResolveBillingAttribution: vi.fn(),
+ mockResolveSystemBillingAttribution: vi.fn(),
+ mockCheckAttributedUsageLimits: vi.fn(),
}))
+const SYSTEM_BILLING_ATTRIBUTION = {
+ actorUserId: 'owner-after-transfer',
+ workspaceId: 'ws-1',
+ organizationId: 'org-after-transfer',
+ billedAccountUserId: 'owner-after-transfer',
+ billingEntity: { type: 'organization' as const, id: 'org-after-transfer' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
vi.mock('@/app/api/v1/middleware', () => ({
authenticateRequest: mockAuthenticateRequest,
}))
@@ -42,6 +61,12 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: mockCheckActorUsageLimits,
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ resolveBillingAttribution: mockResolveBillingAttribution,
+ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
+ checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
+}))
+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
uploadWorkspaceFile: mockUploadWorkspaceFile,
}))
@@ -99,6 +124,13 @@ describe('v1 knowledge document upload route', () => {
})
mockResolveKnowledgeBase.mockResolvedValue({ kb: { id: 'kb-1', workspaceId: 'ws-1' } })
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
+ mockResolveBillingAttribution.mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ mockResolveSystemBillingAttribution.mockResolvedValue(SYSTEM_BILLING_ATTRIBUTION)
+ mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
mockValidateFileType.mockReturnValue(null)
mockUploadWorkspaceFile.mockResolvedValue({
url: 'https://example.com/file.txt',
@@ -164,5 +196,44 @@ describe('v1 knowledge document upload route', () => {
expect(data.success).toBe(true)
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
expect(mockCreateSingleDocument).toHaveBeenCalledTimes(1)
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+ expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
+ })
+
+ it('uses one atomic system actor and payer snapshot for a workspace API key', async () => {
+ mockAuthenticateRequest.mockResolvedValue({
+ requestId: 'req-1',
+ userId: 'key-creator',
+ rateLimit: { keyType: 'workspace' },
+ })
+ const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
+ const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
+ method: 'POST',
+ headers: { 'content-length': '1024' },
+ body: buildFormData(file),
+ })
+
+ const response = await POST(req, routeContext)
+
+ expect(response.status).toBe(200)
+ expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('ws-1')
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(SYSTEM_BILLING_ATTRIBUTION)
+ expect(mockCreateSingleDocument).toHaveBeenCalledWith(
+ expect.any(Object),
+ 'kb-1',
+ 'req-1',
+ 'owner-after-transfer'
+ )
+ expect(mockProcessDocumentsWithQueue).toHaveBeenCalledWith(
+ expect.any(Array),
+ 'kb-1',
+ {},
+ 'req-1',
+ SYSTEM_BILLING_ATTRIBUTION
+ )
})
})
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
index d0d80070dcd..0b1c096ad96 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts
@@ -5,7 +5,11 @@ import {
v1UploadKnowledgeDocumentContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
-import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ resolveSystemBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import {
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
@@ -151,9 +155,16 @@ export const POST = withRouteHandler(
)
if (result instanceof NextResponse) return result
- // Fast usage gate before the storage write + indexing (the async backstop
- // in processDocumentAsync still covers non-HTTP paths).
- const usage = await checkActorUsageLimits(userId, workspaceId)
+ /**
+ * Gate before storage and indexing. Workspace keys use the billed account
+ * and immutable payer from one read; personal keys preserve their human actor.
+ */
+ const billingAttribution =
+ rateLimit.keyType === 'workspace'
+ ? await resolveSystemBillingAttribution(workspaceId)
+ : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
+ const billingActorUserId = billingAttribution.actorUserId
+ const usage = await checkAttributedUsageLimits(billingAttribution)
if (usage.isExceeded) {
return NextResponse.json(
{
@@ -183,7 +194,7 @@ export const POST = withRouteHandler(
},
knowledgeBaseId,
requestId,
- userId
+ billingActorUserId
)
const documentData: DocumentData = {
@@ -194,7 +205,13 @@ export const POST = withRouteHandler(
mimeType: contentType,
}
- processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => {
+ processDocumentsWithQueue(
+ [documentData],
+ knowledgeBaseId,
+ {},
+ requestId,
+ billingAttribution
+ ).catch(() => {
// Processing errors are logged internally
})
diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts
index b95bd329784..9c423d1b2d9 100644
--- a/apps/sim/app/api/v1/knowledge/search/route.test.ts
+++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts
@@ -20,6 +20,9 @@ const {
mockGetDocumentMetadataByIds,
mockAuthenticateRequest,
mockValidateWorkspaceAccess,
+ mockResolveBillingAttribution,
+ mockResolveSystemBillingAttribution,
+ mockRecordSearchEmbeddingUsage,
} = vi.hoisted(() => ({
mockHandleVectorOnlySearch: vi.fn(),
mockHandleTagOnlySearch: vi.fn(),
@@ -29,8 +32,24 @@ const {
mockGetDocumentMetadataByIds: vi.fn(),
mockAuthenticateRequest: vi.fn(),
mockValidateWorkspaceAccess: vi.fn(),
+ mockResolveBillingAttribution: vi.fn(),
+ mockResolveSystemBillingAttribution: vi.fn(),
+ mockRecordSearchEmbeddingUsage: vi.fn(),
}))
+const SYSTEM_BILLING_ATTRIBUTION = {
+ actorUserId: 'owner-after-transfer',
+ workspaceId: 'ws-1',
+ organizationId: 'org-after-transfer',
+ billedAccountUserId: 'owner-after-transfer',
+ billingEntity: { type: 'organization' as const, id: 'org-after-transfer' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
vi.mock('@/app/api/knowledge/search/utils', () => ({
handleVectorOnlySearch: mockHandleVectorOnlySearch,
handleTagOnlySearch: mockHandleTagOnlySearch,
@@ -46,6 +65,16 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ resolveBillingAttribution: mockResolveBillingAttribution,
+ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
+ checkAttributedUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
+}))
+
+vi.mock('@/lib/knowledge/embeddings', () => ({
+ recordSearchEmbeddingUsage: mockRecordSearchEmbeddingUsage,
+}))
+
vi.mock('@/app/api/v1/middleware', () => ({
authenticateRequest: mockAuthenticateRequest,
validateWorkspaceAccess: mockValidateWorkspaceAccess,
@@ -85,9 +114,22 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
})
mockValidateWorkspaceAccess.mockResolvedValue(null)
mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 })
- mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3])
+ mockGenerateSearchEmbedding.mockResolvedValue({
+ embedding: [0.1, 0.2, 0.3],
+ isBYOK: false,
+ })
mockHandleVectorOnlySearch.mockResolvedValue([])
mockGetDocumentMetadataByIds.mockResolvedValue({})
+ mockResolveBillingAttribution.mockImplementation(
+ ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) =>
+ Promise.resolve({
+ actorUserId,
+ workspaceId,
+ billingEntity: { type: 'organization', id: 'org-1' },
+ })
+ )
+ mockResolveSystemBillingAttribution.mockResolvedValue(SYSTEM_BILLING_ATTRIBUTION)
+ mockRecordSearchEmbeddingUsage.mockResolvedValue(undefined)
})
it('passes the KB embedding model into generateSearchEmbedding', async () => {
@@ -109,6 +151,42 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
'gemini-embedding-001',
'ws-1'
)
+ expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
+ actorUserId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+ expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
+ })
+
+ it('uses one atomic system actor and payer snapshot for a workspace API key', async () => {
+ mockAuthenticateRequest.mockResolvedValue({
+ requestId: 'req-1',
+ userId: 'key-creator',
+ rateLimit: { keyType: 'workspace' },
+ })
+ mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
+ hasAccess: true,
+ knowledgeBase: baseKb('kb-workspace-key', 'text-embedding-3-small'),
+ })
+
+ const res = await POST(
+ createMockRequest('POST', {
+ workspaceId: 'ws-1',
+ knowledgeBaseIds: 'kb-workspace-key',
+ query: 'hello',
+ })
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('ws-1')
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockRecordSearchEmbeddingUsage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'owner-after-transfer',
+ workspaceId: 'ws-1',
+ billingAttribution: SYSTEM_BILLING_ATTRIBUTION,
+ })
+ )
})
it('rejects cross-KB queries with mixed embedding models', async () => {
diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts
index 542fe5606d7..36dc7558214 100644
--- a/apps/sim/app/api/v1/knowledge/search/route.ts
+++ b/apps/sim/app/api/v1/knowledge/search/route.ts
@@ -1,7 +1,11 @@
import { type NextRequest, NextResponse } from 'next/server'
import { v1KnowledgeSearchContract } from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
-import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ resolveSystemBillingAttribution,
+} from '@/lib/billing/core/billing-attribution'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings'
@@ -39,10 +43,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
if (accessError) return accessError
- // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's
- // usage and frozen status before spending. Tag-only search is free, so skip it.
- if (query && query.trim().length > 0) {
- const usage = await checkActorUsageLimits(userId, workspaceId)
+ const hasBillableQuery = Boolean(query?.trim())
+ const billingAttribution = hasBillableQuery
+ ? rateLimit.keyType === 'workspace'
+ ? await resolveSystemBillingAttribution(workspaceId)
+ : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
+ : undefined
+ const billingActorUserId = billingAttribution?.actorUserId ?? userId
+
+ /**
+ * Query embeddings incur hosted cost; tag-only searches do not. Workspace
+ * keys resolve their system actor and immutable payer from one workspace read.
+ */
+ if (billingAttribution) {
+ const usage = await checkAttributedUsageLimits(billingAttribution)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
@@ -210,12 +224,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (queryEmbeddingIsBYOK !== null) {
await recordSearchEmbeddingUsage({
- userId,
+ userId: billingActorUserId,
workspaceId,
embeddingModel: queryEmbeddingModel,
query: query!,
isBYOK: queryEmbeddingIsBYOK,
sourceReference: `v1-kb-search:${requestId}`,
+ billingAttribution,
})
}
diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts
index 51d69070f32..61ec99a5abd 100644
--- a/apps/sim/app/api/v1/middleware.ts
+++ b/apps/sim/app/api/v1/middleware.ts
@@ -6,7 +6,10 @@ import type { SubscriptionPlan } from '@/lib/core/rate-limiter'
import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
+import {
+ getWorkspaceBilledAccountUserId,
+ getWorkspaceBillingSettings,
+} from '@/lib/workspaces/utils'
import { authenticateV1Request } from '@/app/api/v1/auth'
const logger = createLogger('V1Middleware')
@@ -193,6 +196,21 @@ export async function checkWorkspaceScope(
return null
}
+/**
+ * Resolves the usage actor for a workspace-scoped v1 request. Personal keys
+ * identify their human owner; shared workspace keys use the billed account as
+ * the explicit system actor because the credential does not identify a human.
+ */
+export async function resolveWorkspaceRequestActor(
+ rateLimit: RateLimitResult,
+ workspaceId: string
+): Promise {
+ if (rateLimit.keyType === 'workspace') {
+ return getWorkspaceBilledAccountUserId(workspaceId)
+ }
+ return rateLimit.userId ?? null
+}
+
/**
* Validates workspace-scoped API key bounds and the user's workspace permission.
* Returns null on success, NextResponse on failure.
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
index 4aa1d85f93f..419da80ad35 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
@@ -25,6 +25,7 @@ import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
+ resolveWorkspaceRequestActor,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableRowAPI')
@@ -126,6 +127,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
+ const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
+ if (!actorUserId) {
+ throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
+ }
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
@@ -144,7 +149,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
rowId,
data: rowDataNameToId(validated.data as RowData, idByName),
workspaceId: validated.workspaceId,
- actorUserId: userId,
+ actorUserId,
},
table,
requestId
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
index 5f637c3a26a..c1ffacf2218 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
@@ -38,6 +38,7 @@ import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
+ resolveWorkspaceRequestActor,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableRowsAPI')
@@ -53,7 +54,8 @@ async function handleBatchInsert(
requestId: string,
tableId: string,
validated: V1BatchInsertTableRowsBody,
- userId: string
+ userId: string,
+ actorUserId: string
): Promise {
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
@@ -82,7 +84,7 @@ async function handleBatchInsert(
tableId,
rows,
workspaceId: validated.workspaceId,
- userId,
+ userId: actorUserId,
},
table,
requestId
@@ -220,13 +222,26 @@ export const POST = withRouteHandler(
const batchValidated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId)
if (scopeError) return scopeError
- return handleBatchInsert(requestId, tableId, batchValidated, userId)
+ const actorUserId = await resolveWorkspaceRequestActor(
+ rateLimit,
+ batchValidated.workspaceId
+ )
+ if (!actorUserId) {
+ throw new Error(
+ `Unable to resolve system actor for workspace ${batchValidated.workspaceId}`
+ )
+ }
+ return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId)
}
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
+ const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
+ if (!actorUserId) {
+ throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
+ }
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
@@ -253,7 +268,7 @@ export const POST = withRouteHandler(
tableId,
data: rowData,
workspaceId: validated.workspaceId,
- userId,
+ userId: actorUserId,
},
table,
requestId
@@ -303,6 +318,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
+ const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
+ if (!actorUserId) {
+ throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
+ }
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
@@ -330,7 +349,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
filter: filterNamesToIds(validated.filter as Filter, idByName),
data: patchData,
limit: validated.limit,
- actorUserId: userId,
+ actorUserId,
},
requestId
)
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
index 285cd6d1e31..caf0e0a6df2 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
@@ -18,6 +18,7 @@ import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
+ resolveWorkspaceRequestActor,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableUpsertAPI')
@@ -47,6 +48,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
+ const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
+ if (!actorUserId) {
+ throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
+ }
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
@@ -64,7 +69,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
tableId,
workspaceId: validated.workspaceId,
data: rowDataNameToId(validated.data as RowData, idByName),
- userId,
+ userId: actorUserId,
conflictTarget: validated.conflictTarget,
},
table,
diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts
index fae011a480d..6222c75ad45 100644
--- a/apps/sim/app/api/wand/route.ts
+++ b/apps/sim/app/api/wand/route.ts
@@ -8,11 +8,14 @@ import { parseRequest } from '@/lib/api/server'
import { getBYOKKey } from '@/lib/api-key/byok'
import { getSession } from '@/lib/auth'
import {
- checkActorUsageLimits,
- checkBillingBlocked,
-} from '@/lib/billing/calculations/usage-monitor'
+ type BillingAttributionSnapshot,
+ checkAttributedBillingBlocks,
+ checkAttributedUsageLimits,
+ resolveBillingAttribution,
+ toBillingContext,
+} from '@/lib/billing/core/billing-attribution'
import { recordUsage } from '@/lib/billing/core/usage-log'
-import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
+import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -90,8 +93,7 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg
}
async function updateUserStatsForWand(
- billingUserId: string,
- workspaceId: string | null,
+ billingAttribution: BillingAttributionSnapshot,
usage: {
prompt_tokens?: number
completion_tokens?: number
@@ -132,8 +134,9 @@ async function updateUserStatsForWand(
}
await recordUsage({
- userId: billingUserId,
- workspaceId: workspaceId ?? undefined,
+ userId: billingAttribution.actorUserId,
+ workspaceId: billingAttribution.workspaceId,
+ ...toBillingContext(billingAttribution),
entries: [
{
category: 'model',
@@ -146,7 +149,7 @@ async function updateUserStatsForWand(
],
})
- await checkAndBillOverageThreshold(billingUserId)
+ await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity)
} catch (error) {
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
}
@@ -245,12 +248,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}
- // Wand is always an interactive, session-authenticated editor action, so the
- // person using it is the billing actor — matching client-side executions and
- // editor voice rather than the workspace billed account. deriveBillingContext
- // still routes payment to the org for org-scoped members; per-member usage is
- // attributed to the member who actually used the wand.
- const billingUserId = session.user.id
+ /** Wand is an interactive action attributed to the authenticated human. */
+ const actorUserId = session.user.id
+ const billingAttribution = await resolveBillingAttribution({
+ actorUserId,
+ workspaceId,
+ })
let isBYOK = false
let activeOpenAIKey = openaiApiKey
@@ -272,12 +275,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}
- // BYOK incurs no Sim-metered cost, so it skips usage gating — but a frozen /
- // billing-blocked account is locked out of everything, so still check that.
- // Non-BYOK runs the full actor gate, which already includes the billing-blocked
- // check, so it isn't repeated here.
+ /**
+ * BYOK skips metered caps but still checks the actor and exact workspace
+ * payer. Non-BYOK applies the complete attributed gate.
+ */
if (isBYOK) {
- const blocked = await checkBillingBlocked(billingUserId)
+ const blocked = await checkAttributedBillingBlocks(billingAttribution)
if (blocked.blocked) {
return NextResponse.json(
{
@@ -288,7 +291,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}
} else {
- const usage = await checkActorUsageLimits(billingUserId, workspaceId)
+ const usage = await checkAttributedUsageLimits(billingAttribution)
if (usage.isExceeded) {
return NextResponse.json(
{
@@ -397,13 +400,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
usageRecorded = true
- await updateUserStatsForWand(
- billingUserId,
- workspaceId,
- finalUsage,
- requestId,
- isBYOK
- )
+ await updateUserStatsForWand(billingAttribution, finalUsage, requestId, isBYOK)
}
try {
@@ -620,8 +617,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const usage = parseResponsesUsage(completion.usage)
if (usage) {
await updateUserStatsForWand(
- billingUserId,
- workspaceId,
+ billingAttribution,
{
prompt_tokens: usage.promptTokens,
completion_tokens: usage.completionTokens,
diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts
index 73728d6b06f..381ecb5a8b8 100644
--- a/apps/sim/app/api/webhooks/outbox/process/route.ts
+++ b/apps/sim/app/api/webhooks/outbox/process/route.ts
@@ -3,11 +3,13 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
+import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning'
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
+import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move'
import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
const logger = createLogger('OutboxProcessorAPI')
@@ -17,6 +19,8 @@ export const maxDuration = 120
const handlers = {
...billingOutboxHandlers,
+ ...enterpriseIssuanceOutboxHandlers,
+ ...invitationMigrationOutboxHandlers,
...workflowDeploymentOutboxHandlers,
} as const
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
index df0e11e35dd..b7266029fe7 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
@@ -13,7 +13,13 @@ import {
workflowsPersistenceUtilsMockFns,
workflowsUtilsMock,
} from '@sim/testing'
+import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ ADMISSION_ERROR_CODE,
+ ADMISSION_ERROR_DESCRIPTOR,
+ ADMISSION_RETRY_AFTER_SECONDS,
+} from '@/lib/core/admission/transient-failure'
/** Mock execution dependencies for webhook tests */
function mockExecutionDependencies() {
@@ -85,6 +91,7 @@ const testData = {
isActive: boolean
providerConfig?: Record
workflowId: string
+ blockId?: string
rateLimitCount?: number
rateLimitPeriod?: number
}>,
@@ -105,8 +112,13 @@ const {
processWebhookMock,
executeMock,
getWorkspaceBilledAccountUserIdMock,
+ checkWebhookPreprocessingMock,
+ handleWebhookEventFilterMock,
queueWebhookExecutionMock,
isWorkspaceApiExecutionEntitledMock,
+ shouldSkipWebhookEventMock,
+ admissionRejectedResponseMock,
+ tryAdmitMock,
} = vi.hoisted(() => ({
generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'),
validateSlackSignatureMock: vi.fn().mockResolvedValue(true),
@@ -130,11 +142,42 @@ const {
.mockImplementation(async (workspaceId: string | null | undefined) =>
workspaceId ? 'test-user-id' : null
),
+ checkWebhookPreprocessingMock: vi.fn().mockResolvedValue({
+ error: null,
+ actorUserId: 'test-user-id',
+ billingAttribution: {
+ actorUserId: 'test-user-id',
+ workspaceId: 'test-workspace-id',
+ organizationId: null,
+ billedAccountUserId: 'test-user-id',
+ billingEntity: { type: 'user', id: 'test-user-id' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ },
+ executionId: 'preprocess-execution-id',
+ correlation: {
+ executionId: 'preprocess-execution-id',
+ requestId: 'mock-request-id',
+ source: 'webhook',
+ workflowId: 'test-workflow-id',
+ webhookId: 'generic-webhook-id',
+ path: 'test-path',
+ provider: 'generic',
+ triggerType: 'webhook',
+ },
+ }),
+ handleWebhookEventFilterMock: vi.fn().mockResolvedValue(null),
queueWebhookExecutionMock: vi.fn().mockImplementation(async () => {
const { NextResponse } = await import('next/server')
return NextResponse.json({ message: 'Webhook processed' })
}),
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', () => ({
@@ -142,6 +185,11 @@ vi.mock('@/lib/billing/core/api-access', () => ({
isWorkspaceApiExecutionEntitled: isWorkspaceApiExecutionEntitledMock,
}))
+vi.mock('@/lib/core/admission/gate', () => ({
+ admissionRejectedResponse: admissionRejectedResponseMock,
+ tryAdmit: tryAdmitMock,
+}))
+
vi.mock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
@@ -274,6 +322,7 @@ vi.mock('@/lib/webhooks/processor', () => ({
}
),
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
+ handleWebhookEventFilter: handleWebhookEventFilterMock,
verifyProviderAuth: vi
.fn()
.mockImplementation(
@@ -329,26 +378,12 @@ vi.mock('@/lib/webhooks/processor', () => ({
return null
}
),
- checkWebhookPreprocessing: vi.fn().mockResolvedValue({
- error: null,
- actorUserId: 'test-user-id',
- executionId: 'preprocess-execution-id',
- correlation: {
- executionId: 'preprocess-execution-id',
- requestId: 'mock-request-id',
- source: 'webhook',
- workflowId: 'test-workflow-id',
- webhookId: 'generic-webhook-id',
- path: 'test-path',
- provider: 'generic',
- triggerType: 'webhook',
- },
- }),
+ checkWebhookPreprocessing: checkWebhookPreprocessingMock,
formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
const { NextResponse } = require('next/server')
return NextResponse.json({ error }, { status })
}),
- shouldSkipWebhookEvent: vi.fn().mockReturnValue(false),
+ shouldSkipWebhookEvent: shouldSkipWebhookEventMock,
handlePreDeploymentVerification: vi.fn().mockReturnValue(null),
queueWebhookExecution: queueWebhookExecutionMock,
}))
@@ -366,6 +401,22 @@ import { GET, POST } from '@/app/api/webhooks/trigger/[path]/route'
describe('Webhook Trigger API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
+ const gateDescriptor = ADMISSION_ERROR_DESCRIPTOR.GATE_CAPACITY
+ admissionRejectedResponseMock.mockImplementation(() =>
+ NextResponse.json(
+ {
+ error: 'Too many requests',
+ message: 'Server is at capacity. Please retry shortly.',
+ code: gateDescriptor.code,
+ retryable: gateDescriptor.retryable,
+ retryAfterSeconds: gateDescriptor.retryAfterSeconds,
+ },
+ {
+ status: gateDescriptor.statusCode,
+ headers: { 'Retry-After': String(gateDescriptor.retryAfterSeconds) },
+ }
+ )
+ )
// Reset test data arrays
testData.webhooks.length = 0
@@ -380,15 +431,6 @@ describe('Webhook Trigger API Route', () => {
isDeployed: true,
workspaceId: 'test-workspace-id',
},
- userSubscription: {
- plan: 'pro',
- status: 'active',
- },
- rateLimitInfo: {
- allowed: true,
- remaining: 100,
- resetAt: new Date(),
- },
})
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue({
@@ -400,6 +442,8 @@ describe('Webhook Trigger API Route', () => {
})
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true)
+ handleWebhookEventFilterMock.mockResolvedValue(null)
+ shouldSkipWebhookEventMock.mockReturnValue(false)
mockExecutionDependencies()
mockTriggerDevSdk()
@@ -429,6 +473,25 @@ describe('Webhook Trigger API Route', () => {
expect(text).toMatch(/not found/i)
})
+ it('returns a stable retryable response when the webhook admission gate is full', async () => {
+ tryAdmitMock.mockReturnValueOnce(null)
+
+ const response = await POST(createMockRequest('POST', { event: 'test' }) as any, {
+ params: Promise.resolve({ path: 'test-path' }),
+ })
+
+ expect(response.status).toBe(429)
+ expect(response.headers.get('Retry-After')).toBe(String(ADMISSION_RETRY_AFTER_SECONDS))
+ await expect(response.json()).resolves.toMatchObject({
+ code: ADMISSION_ERROR_CODE.GATE_CAPACITY,
+ retryable: true,
+ retryAfterSeconds: ADMISSION_RETRY_AFTER_SECONDS,
+ })
+ expect(admissionRejectedResponseMock).toHaveBeenCalledOnce()
+ expect(checkWebhookPreprocessingMock).not.toHaveBeenCalled()
+ expect(queueWebhookExecutionMock).not.toHaveBeenCalled()
+ })
+
it('should return 405 for GET requests on unknown webhook paths', async () => {
const req = createMockRequest(
'GET',
@@ -538,6 +601,70 @@ describe('Webhook Trigger API Route', () => {
})
})
+ describe('Reservation-free filtering', () => {
+ it('skips filtered webhook events before preprocessing reserves a slot', async () => {
+ testData.webhooks.push({
+ id: 'generic-webhook-id',
+ provider: 'generic',
+ path: 'filtered-path',
+ isActive: true,
+ providerConfig: { requireAuth: false },
+ workflowId: 'test-workflow-id',
+ })
+ shouldSkipWebhookEventMock.mockReturnValueOnce(true)
+
+ await POST(createMockRequest('POST', { event: 'ignored' }) as any, {
+ params: Promise.resolve({ path: 'filtered-path' }),
+ })
+
+ expect(checkWebhookPreprocessingMock).not.toHaveBeenCalled()
+ expect(queueWebhookExecutionMock).not.toHaveBeenCalled()
+ })
+
+ it('runs asynchronous event matching before preprocessing reserves a slot', async () => {
+ testData.webhooks.push({
+ id: 'generic-webhook-id',
+ provider: 'generic',
+ path: 'event-filter-path',
+ isActive: true,
+ providerConfig: { requireAuth: false },
+ workflowId: 'test-workflow-id',
+ })
+ handleWebhookEventFilterMock.mockResolvedValueOnce(
+ NextResponse.json({ message: 'Event ignored' })
+ )
+
+ const response = await POST(createMockRequest('POST', { event: 'ignored' }) as any, {
+ params: Promise.resolve({ path: 'event-filter-path' }),
+ })
+
+ expect(response.status).toBe(200)
+ expect(checkWebhookPreprocessingMock).not.toHaveBeenCalled()
+ expect(queueWebhookExecutionMock).not.toHaveBeenCalled()
+ })
+
+ it('checks for a missing trigger block before preprocessing reserves a slot', async () => {
+ testData.webhooks.push({
+ id: 'generic-webhook-id',
+ provider: 'generic',
+ path: 'missing-block-path',
+ isActive: true,
+ providerConfig: { requireAuth: false },
+ workflowId: 'test-workflow-id',
+ blockId: 'missing-block',
+ })
+ workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValueOnce(false)
+
+ const response = await POST(createMockRequest('POST', { event: 'test' }) as any, {
+ params: Promise.resolve({ path: 'missing-block-path' }),
+ })
+
+ expect(response.status).toBe(404)
+ expect(checkWebhookPreprocessingMock).not.toHaveBeenCalled()
+ expect(queueWebhookExecutionMock).not.toHaveBeenCalled()
+ })
+ })
+
describe('Generic Webhook Authentication', () => {
it('passes correlation-bearing request context into webhook queueing', async () => {
testData.webhooks.push({
@@ -580,6 +707,55 @@ describe('Webhook Trigger API Route', () => {
)
})
+ it.each([
+ {
+ statusCode: 429,
+ code: ADMISSION_ERROR_CODE.RESERVATION_CONCURRENCY,
+ },
+ {
+ statusCode: 503,
+ code: ADMISSION_ERROR_CODE.RESERVATION_INFRASTRUCTURE,
+ },
+ ])(
+ 'preserves retryable admission $statusCode status, code, and Retry-After',
+ async ({ statusCode, code }) => {
+ testData.webhooks.push({
+ id: 'generic-webhook-id',
+ provider: 'generic',
+ path: 'test-path',
+ isActive: true,
+ providerConfig: { requireAuth: false },
+ workflowId: 'test-workflow-id',
+ })
+ checkWebhookPreprocessingMock.mockResolvedValueOnce({
+ error: NextResponse.json(
+ {
+ error: 'Admission temporarily unavailable',
+ code,
+ retryable: true,
+ retryAfterSeconds: ADMISSION_RETRY_AFTER_SECONDS,
+ },
+ {
+ status: statusCode,
+ headers: { 'Retry-After': String(ADMISSION_RETRY_AFTER_SECONDS) },
+ }
+ ),
+ })
+
+ const response = await POST(createMockRequest('POST', { event: 'test' }) as any, {
+ params: Promise.resolve({ path: 'test-path' }),
+ })
+
+ expect(response.status).toBe(statusCode)
+ expect(response.headers.get('Retry-After')).toBe(String(ADMISSION_RETRY_AFTER_SECONDS))
+ await expect(response.json()).resolves.toMatchObject({
+ code,
+ retryable: true,
+ })
+ expect(queueWebhookExecutionMock).not.toHaveBeenCalled()
+ }
+ )
+
it('should process generic webhook without authentication', async () => {
testData.webhooks.push({
id: 'generic-webhook-id',
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index 1c103ddad72..21940c9c9f9 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -16,6 +16,7 @@ import {
handlePreLookupWebhookVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
+ handleWebhookEventFilter,
parseWebhookBody,
queueWebhookExecution,
shouldSkipWebhookEvent,
@@ -172,15 +173,20 @@ async function handleWebhookPost(
return reachabilityResponse
}
- const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
- if (preprocessResult.error) {
- if (webhooksForPath.length > 1) {
- logger.warn(
- `[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
- )
- continue
- }
- return preprocessResult.error
+ if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) {
+ continue
+ }
+
+ const eventFilterResponse = await handleWebhookEventFilter(
+ foundWebhook,
+ foundWorkflow,
+ body,
+ request,
+ requestId
+ )
+ if (eventFilterResponse) {
+ responses.push(eventFilterResponse)
+ continue
}
if (foundWebhook.blockId) {
@@ -201,13 +207,22 @@ async function handleWebhookPost(
}
}
- if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) {
- continue
+ const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
+ if (preprocessResult.error) {
+ if (webhooksForPath.length > 1) {
+ logger.warn(
+ `[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
+ )
+ continue
+ }
+ return preprocessResult.error
}
+
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
+ billingAttribution: preprocessResult.billingAttribution,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
receivedAt,
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
index 17f90047c77..cd23fcd05e9 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
@@ -4,11 +4,14 @@
import {
createMockRequest,
+ dbChainMock,
+ dbChainMockFns,
executionPreprocessingMock,
executionPreprocessingMockFns,
hybridAuthMockFns,
loggingSessionMock,
requestUtilsMockFns,
+ resetDbChainMock,
workflowAuthzMockFns,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
@@ -19,22 +22,65 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
+ mockAssertBillingAttributionSnapshot,
+ mockClaimExecutionId,
mockEnqueue,
mockExecuteWorkflowCore,
+ mockGenerateId,
+ mockGetWorkspaceBillingSettings,
mockHandlePostExecutionPauseState,
+ mockHasDurableExecutionOwner,
mockIsWorkspaceApiExecutionEntitled,
+ mockReleaseExecutionIdClaim,
+ mockReleaseExecutionSlot,
+ mockRequireBillingAttributionHeader,
+ mockValidatePublicApiAllowed,
} = vi.hoisted(() => ({
+ mockAssertBillingAttributionSnapshot: vi.fn((value: unknown) => {
+ if (!value || typeof value !== 'object') {
+ throw new Error('Billing attribution snapshot must be an object')
+ }
+ return value
+ }),
+ mockClaimExecutionId: vi.fn(),
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
mockExecuteWorkflowCore: vi.fn(),
+ mockGenerateId: vi.fn(() => 'execution-123'),
+ mockGetWorkspaceBillingSettings: vi.fn(),
mockHandlePostExecutionPauseState: vi.fn(),
+ mockHasDurableExecutionOwner: vi.fn(),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
+ mockReleaseExecutionIdClaim: vi.fn(),
+ mockReleaseExecutionSlot: vi.fn(),
+ mockRequireBillingAttributionHeader: vi.fn(),
+ mockValidatePublicApiAllowed: vi.fn(),
}))
+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,
+}))
+
+vi.mock('@/lib/billing/calculations/usage-reservation', () => ({
+ releaseExecutionSlot: mockReleaseExecutionSlot,
+}))
+
+vi.mock('@/lib/workspaces/utils', () => ({
+ getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
+}))
+
+vi.mock('@/ee/access-control/utils/permission-check', () => ({
+ PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {},
+ validatePublicApiAllowed: mockValidatePublicApiAllowed,
+}))
+
const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
@@ -55,6 +101,12 @@ vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
handlePostExecutionPauseState: mockHandlePostExecutionPauseState,
}))
+vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({
+ claimExecutionId: mockClaimExecutionId,
+ hasDurableExecutionOwner: mockHasDurableExecutionOwner,
+ releaseExecutionIdClaim: mockReleaseExecutionIdClaim,
+}))
+
vi.mock('@/lib/execution/payloads/store', () => ({
storeLargeValue: vi.fn(async (_value, _json, size: number) => ({
__simLargeValueRef: true,
@@ -94,7 +146,7 @@ vi.mock('@/background/workflow-execution', () => ({
}))
vi.mock('@sim/utils/id', () => ({
- generateId: vi.fn(() => 'execution-123'),
+ generateId: mockGenerateId,
generateShortId: vi.fn(() => 'mock-short-id'),
isValidUuid: vi.fn((v: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
@@ -104,13 +156,155 @@ vi.mock('@sim/utils/id', () => ({
import { storeLargeValue } from '@/lib/execution/payloads/store'
import { POST } from './route'
+const billingAttribution = {
+ actorUserId: 'actor-1',
+ workspaceId: 'workspace-1',
+ organizationId: null,
+ billedAccountUserId: 'actor-1',
+ billingEntity: { type: 'user' as const, id: 'actor-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
+function createSessionReplayRequest(executionId: string): NextRequest {
+ return createMockRequest(
+ 'POST',
+ {
+ input: { hello: 'world' },
+ executionId,
+ isClientSession: true,
+ },
+ {
+ 'Content-Type': 'application/json',
+ 'X-Execution-Mode': 'async',
+ }
+ )
+}
+
+interface ExecutionCallerCase {
+ caseName: string
+ authResult: Record
+ headers: Record
+ usesExternalInput: boolean
+ isPublic?: boolean
+}
+
+const EXECUTION_CALLERS: ExecutionCallerCase[] = [
+ {
+ caseName: 'session',
+ authResult: {
+ success: true,
+ userId: 'session-user-1',
+ authType: 'session',
+ },
+ headers: { Cookie: 'session=value' },
+ usesExternalInput: false,
+ },
+ {
+ caseName: 'personal API key',
+ authResult: {
+ success: true,
+ userId: 'personal-key-user-1',
+ authType: 'api_key',
+ apiKeyType: 'personal',
+ },
+ headers: { 'X-API-Key': 'personal-key' },
+ usesExternalInput: true,
+ },
+ {
+ caseName: 'workspace API key',
+ authResult: {
+ success: true,
+ userId: 'workspace-key-user-1',
+ workspaceId: 'workspace-1',
+ authType: 'api_key',
+ apiKeyType: 'workspace',
+ },
+ headers: { 'X-API-Key': 'workspace-key' },
+ usesExternalInput: true,
+ },
+ {
+ caseName: 'public API',
+ authResult: {
+ success: false,
+ error: 'Unauthorized',
+ },
+ headers: {},
+ usesExternalInput: true,
+ isPublic: true,
+ },
+ {
+ caseName: 'internal JWT',
+ authResult: {
+ success: true,
+ userId: 'internal-user-1',
+ authType: 'internal_jwt',
+ },
+ headers: { Authorization: 'Bearer internal-token' },
+ usesExternalInput: true,
+ },
+]
+
+const EXTERNAL_EXECUTION_CALLERS = EXECUTION_CALLERS.filter(
+ ({ usesExternalInput }) => usesExternalInput
+)
+
+function configureExecutionCaller(caller: ExecutionCallerCase, requestCount = 1): void {
+ mockCheckHybridAuth.mockResolvedValue(caller.authResult)
+ if (!caller.isPublic) return
+
+ for (let request = 0; request < requestCount; request++) {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ isPublicApi: true,
+ isDeployed: true,
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ },
+ ])
+ }
+}
+
+function createCallerExecutionRequest(
+ caller: ExecutionCallerCase,
+ executionId?: string,
+ executionMode: 'async' | 'sync' = 'async'
+): NextRequest {
+ const input = { hello: 'world' }
+ const body = caller.usesExternalInput
+ ? { ...input, ...(executionId ? { executionId } : {}) }
+ : { input, ...(executionId ? { executionId } : {}) }
+
+ return createMockRequest('POST', body, {
+ 'Content-Type': 'application/json',
+ ...(executionMode === 'async' ? { 'X-Execution-Mode': 'async' } : {}),
+ ...caller.headers,
+ })
+}
+
describe('workflow execute async route', () => {
beforeEach(() => {
vi.clearAllMocks()
+ resetDbChainMock()
+ mockGenerateId.mockReset().mockReturnValue('execution-123')
+ mockClaimExecutionId.mockImplementation(async (executionId: string) => ({
+ key: `workflow-execution-id:${executionId}`,
+ token: `token-${executionId}`,
+ }))
+ mockHasDurableExecutionOwner.mockResolvedValue(false)
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678')
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false)
hybridAuthMockFns.mockHasExternalApiCredentials.mockReturnValue(true)
+ mockGetWorkspaceBillingSettings.mockResolvedValue({
+ billedAccountUserId: 'owner-1',
+ allowPersonalApiKeys: true,
+ })
+ mockRequireBillingAttributionHeader.mockReturnValue(undefined)
+ mockValidatePublicApiAllowed.mockResolvedValue(undefined)
mockCheckHybridAuth.mockResolvedValue({
success: true,
@@ -135,6 +329,7 @@ describe('workflow execute async route', () => {
userId: 'owner-1',
workspaceId: 'workspace-1',
},
+ billingAttribution,
})
workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null)
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
@@ -168,6 +363,7 @@ describe('workflow execute async route', () => {
expect(response.status).toBe(202)
expect(body.executionId).toBe('execution-123')
expect(body.jobId).toBe('job-123')
+ expect(mockClaimExecutionId).toHaveBeenCalledWith('execution-123')
expect(mockEnqueue).toHaveBeenCalledWith(
'workflow-execution',
expect.objectContaining({
@@ -176,6 +372,8 @@ describe('workflow execute async route', () => {
workspaceId: 'workspace-1',
executionId: 'execution-123',
executionMode: 'async',
+ admissionCompleted: true,
+ billingAttribution,
}),
expect.objectContaining({
metadata: expect.objectContaining({
@@ -194,6 +392,430 @@ describe('workflow execute async route', () => {
)
})
+ it('preserves a first-use execution ID supplied by an authenticated session', async () => {
+ const requestedExecutionId = '11111111-1111-4111-8111-111111111111'
+ const response = await POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(202)
+ await expect(response.json()).resolves.toMatchObject({ executionId: requestedExecutionId })
+ expect(mockClaimExecutionId).toHaveBeenCalledWith(requestedExecutionId)
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({ executionId: requestedExecutionId })
+ )
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'workflow-execution',
+ expect.objectContaining({ executionId: requestedExecutionId }),
+ expect.any(Object)
+ )
+ })
+
+ it('rejects sequential replay of a claimed session execution ID before preprocessing', async () => {
+ const requestedExecutionId = '22222222-2222-4222-8222-222222222222'
+ mockClaimExecutionId
+ .mockResolvedValueOnce({
+ key: `workflow-execution-id:${requestedExecutionId}`,
+ token: 'claim-token',
+ })
+ .mockResolvedValueOnce(null)
+
+ const firstResponse = await POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+ const replayResponse = await POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(firstResponse.status).toBe(202)
+ expect(replayResponse.status).toBe(409)
+ await expect(replayResponse.json()).resolves.toMatchObject({
+ code: 'EXECUTION_ID_CONFLICT',
+ executionId: requestedExecutionId,
+ })
+ expect(mockPreprocessExecution).toHaveBeenCalledTimes(1)
+ expect(mockEnqueue).toHaveBeenCalledTimes(1)
+ })
+
+ it('allows only one concurrent request to use the same session execution ID', async () => {
+ const requestedExecutionId = '33333333-3333-4333-8333-333333333333'
+ mockClaimExecutionId
+ .mockResolvedValueOnce({
+ key: `workflow-execution-id:${requestedExecutionId}`,
+ token: 'claim-token',
+ })
+ .mockResolvedValueOnce(null)
+
+ const responses = await Promise.all([
+ POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ }),
+ POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ }),
+ ])
+
+ expect(responses.map((response) => response.status).sort()).toEqual([202, 409])
+ expect(mockPreprocessExecution).toHaveBeenCalledTimes(1)
+ expect(mockEnqueue).toHaveBeenCalledTimes(1)
+ })
+
+ it('releases a claimed session execution ID when preprocessing rejects the run', async () => {
+ const requestedExecutionId = '44444444-4444-4444-8444-444444444444'
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: false,
+ error: { message: 'Not admitted', statusCode: 402 },
+ })
+
+ const response = await POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(402)
+ expect(mockReleaseExecutionIdClaim).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: `workflow-execution-id:${requestedExecutionId}`,
+ })
+ )
+ expect(mockEnqueue).not.toHaveBeenCalled()
+ })
+
+ it('fails closed before preprocessing when the durable claim store is unavailable', async () => {
+ const requestedExecutionId = '55555555-5555-4555-8555-555555555555'
+ mockClaimExecutionId.mockRejectedValueOnce(new Error('database unavailable'))
+
+ const response = await POST(createSessionReplayRequest(requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(503)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Workflow execution identity is temporarily unavailable',
+ })
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockEnqueue).not.toHaveBeenCalled()
+ })
+
+ it.each(EXECUTION_CALLERS)(
+ 'honors a first-use execution ID supplied by a $caseName caller',
+ async (caller) => {
+ const requestedExecutionId = '66666666-6666-4666-8666-666666666666'
+ configureExecutionCaller(caller)
+
+ const response = await POST(createCallerExecutionRequest(caller, requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(202)
+ await expect(response.json()).resolves.toMatchObject({ executionId: requestedExecutionId })
+ expect(mockClaimExecutionId).toHaveBeenCalledWith(requestedExecutionId)
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({ executionId: requestedExecutionId })
+ )
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'workflow-execution',
+ expect.objectContaining({ executionId: requestedExecutionId }),
+ expect.any(Object)
+ )
+ }
+ )
+
+ it.each(EXECUTION_CALLERS)(
+ 'returns 409 for a duplicate execution ID from a $caseName caller',
+ async (caller) => {
+ const requestedExecutionId = '77777777-7777-4777-8777-777777777777'
+ configureExecutionCaller(caller, 2)
+ mockClaimExecutionId
+ .mockResolvedValueOnce({
+ key: `workflow-execution-id:${requestedExecutionId}`,
+ token: 'claim-token',
+ })
+ .mockResolvedValueOnce(null)
+
+ const firstResponse = await POST(createCallerExecutionRequest(caller, requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+ const duplicateResponse = await POST(
+ createCallerExecutionRequest(caller, requestedExecutionId),
+ {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ }
+ )
+
+ expect(firstResponse.status).toBe(202)
+ expect(duplicateResponse.status).toBe(409)
+ await expect(duplicateResponse.json()).resolves.toMatchObject({
+ code: 'EXECUTION_ID_CONFLICT',
+ executionId: requestedExecutionId,
+ })
+ expect(mockPreprocessExecution).toHaveBeenCalledTimes(1)
+ expect(mockEnqueue).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ it.each(EXTERNAL_EXECUTION_CALLERS)(
+ 'strips executionId from $caseName workflow input',
+ async (caller) => {
+ const requestedExecutionId = '88888888-8888-4888-8888-888888888888'
+ configureExecutionCaller(caller)
+
+ const response = await POST(createCallerExecutionRequest(caller, requestedExecutionId), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(202)
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'workflow-execution',
+ expect.objectContaining({
+ executionId: requestedExecutionId,
+ input: { hello: 'world' },
+ }),
+ expect.any(Object)
+ )
+ }
+ )
+
+ it('retries a generated execution ID collision with a fresh server ID', async () => {
+ mockGenerateId
+ .mockReturnValueOnce('generated-collision')
+ .mockReturnValueOnce('generated-success')
+ mockClaimExecutionId.mockResolvedValueOnce(null).mockResolvedValueOnce({
+ key: 'workflow-execution-id:generated-success',
+ token: 'claim-token',
+ })
+
+ const response = await POST(createCallerExecutionRequest(EXECUTION_CALLERS[0]), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(202)
+ await expect(response.json()).resolves.toMatchObject({ executionId: 'generated-success' })
+ expect(mockClaimExecutionId.mock.calls.map(([executionId]) => executionId)).toEqual([
+ 'generated-collision',
+ 'generated-success',
+ ])
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({ executionId: 'generated-success' })
+ )
+ })
+
+ it('rejects a workspace API key for another workspace before preprocessing', async () => {
+ const caller = EXECUTION_CALLERS[2]
+ configureExecutionCaller({
+ ...caller,
+ authResult: { ...caller.authResult, workspaceId: 'workspace-2' },
+ })
+
+ const response = await POST(createCallerExecutionRequest(caller), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ error: 'API key is not authorized for this workspace',
+ })
+ expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalled()
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockClaimExecutionId).not.toHaveBeenCalled()
+ })
+
+ it('rejects a personal API key disabled by workspace policy before preprocessing', async () => {
+ const caller = EXECUTION_CALLERS[1]
+ configureExecutionCaller(caller)
+ mockGetWorkspaceBillingSettings.mockResolvedValueOnce({
+ billedAccountUserId: 'owner-1',
+ allowPersonalApiKeys: false,
+ })
+
+ const response = await POST(createCallerExecutionRequest(caller), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Personal API keys are not allowed for this workspace',
+ })
+ expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalled()
+ expect(mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockClaimExecutionId).not.toHaveBeenCalled()
+ })
+
+ it('releases a transient execution ID claim when synchronous startup fails', async () => {
+ const caller = EXECUTION_CALLERS[0]
+ configureExecutionCaller(caller)
+ mockExecuteWorkflowCore.mockRejectedValueOnce(new Error('startup failed'))
+
+ const response = await POST(createCallerExecutionRequest(caller, undefined, 'sync'), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(500)
+ expect(mockReleaseExecutionIdClaim).toHaveBeenCalledWith(
+ expect.objectContaining({ key: 'workflow-execution-id:execution-123' })
+ )
+ })
+
+ it('retains the execution ID claim after a durable log owner is established', async () => {
+ const caller = EXECUTION_CALLERS[0]
+ configureExecutionCaller(caller)
+ mockHasDurableExecutionOwner.mockResolvedValueOnce(true)
+ mockExecuteWorkflowCore.mockRejectedValueOnce(
+ new Error('execution failed after logging started')
+ )
+
+ const response = await POST(createCallerExecutionRequest(caller, undefined, 'sync'), {
+ params: Promise.resolve({ id: 'workflow-1' }),
+ })
+
+ expect(response.status).toBe(500)
+ expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled()
+ })
+
+ it('releases the admission reservation when enqueue fails', async () => {
+ mockEnqueue.mockRejectedValueOnce(new Error('queue unavailable'))
+ const req = createMockRequest(
+ 'POST',
+ { input: { hello: 'world' } },
+ {
+ 'Content-Type': 'application/json',
+ 'X-Execution-Mode': 'async',
+ }
+ )
+
+ const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(500)
+ expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-123')
+ expect(mockReleaseExecutionIdClaim).toHaveBeenCalledWith(
+ expect.objectContaining({ key: 'workflow-execution-id:execution-123' })
+ )
+ })
+
+ it.each([
+ {
+ caseName: 'missing actor',
+ preprocessResult: {
+ success: true,
+ workflowRecord: {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ },
+ billingAttribution,
+ },
+ },
+ {
+ caseName: 'missing workflow record',
+ preprocessResult: {
+ success: true,
+ actorUserId: 'actor-1',
+ billingAttribution,
+ },
+ },
+ {
+ caseName: 'missing billing attribution',
+ preprocessResult: {
+ success: true,
+ actorUserId: 'actor-1',
+ workflowRecord: {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ },
+ },
+ },
+ {
+ caseName: 'mismatched billing actor',
+ preprocessResult: {
+ success: true,
+ actorUserId: 'actor-1',
+ workflowRecord: {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ },
+ billingAttribution: { ...billingAttribution, actorUserId: 'actor-2' },
+ },
+ },
+ {
+ caseName: 'mismatched billing workspace',
+ preprocessResult: {
+ success: true,
+ actorUserId: 'actor-1',
+ workflowRecord: {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ },
+ billingAttribution: { ...billingAttribution, workspaceId: 'workspace-2' },
+ },
+ },
+ ])(
+ 'rejects successful preprocessing with $caseName before enqueue',
+ async ({ preprocessResult }) => {
+ mockPreprocessExecution.mockResolvedValueOnce(preprocessResult)
+ const req = createMockRequest(
+ 'POST',
+ { input: { hello: 'world' } },
+ {
+ 'Content-Type': 'application/json',
+ 'X-Execution-Mode': 'async',
+ }
+ )
+
+ const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(500)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Invalid execution context returned by preprocessing',
+ })
+ expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-123')
+ expect(mockEnqueue).not.toHaveBeenCalled()
+ }
+ )
+
+ it('reuses internal child-workflow billing attribution during preprocessing', async () => {
+ const billingAttribution = {
+ actorUserId: 'actor-1',
+ workspaceId: 'workspace-1',
+ organizationId: 'org-1',
+ billedAccountUserId: 'owner-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ }
+ mockCheckHybridAuth.mockResolvedValue({
+ success: true,
+ userId: 'actor-1',
+ authType: 'internal_jwt',
+ })
+ mockRequireBillingAttributionHeader.mockReturnValue(billingAttribution)
+
+ const req = createMockRequest(
+ 'POST',
+ { input: { hello: 'world' } },
+ {
+ 'Content-Type': 'application/json',
+ 'X-Execution-Mode': 'async',
+ 'X-Sim-Billing-Attribution': 'snapshot',
+ }
+ )
+
+ const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(202)
+ expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(req.headers, {
+ actorUserId: 'actor-1',
+ workspaceId: 'workspace-1',
+ })
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({ billingAttribution })
+ )
+ })
+
it('rejects cross-site session requests before authorization work', async () => {
const req = createMockRequest(
'POST',
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 16bc63964a5..098d5112ef6 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -13,6 +13,11 @@ import {
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
isWorkspaceApiExecutionEntitled,
} from '@/lib/billing/core/api-access'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+ requireBillingAttributionHeader,
+} from '@/lib/billing/core/billing-attribution'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import {
@@ -50,7 +55,7 @@ import {
} from '@/lib/execution/manual-cancellation'
import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer'
-import { preprocessExecution } from '@/lib/execution/preprocessing'
+import { type PreprocessExecutionSuccess, preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
MAX_MCP_WORKFLOW_RESPONSE_BYTES,
@@ -65,6 +70,12 @@ import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/op
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
+import {
+ claimExecutionId,
+ type ExecutionIdClaim,
+ hasDurableExecutionOwner,
+ releaseExecutionIdClaim,
+} from '@/lib/workflows/executor/execution-id-claim'
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
import {
loadDeployedWorkflowState,
@@ -72,6 +83,7 @@ import {
} from '@/lib/workflows/persistence/utils'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
+import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import {
@@ -93,6 +105,7 @@ import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/
const logger = createLogger('WorkflowExecuteAPI')
const MAX_WORKFLOW_EXECUTE_BODY_BYTES = 10 * 1024 * 1024
+const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -252,6 +265,7 @@ type AsyncExecutionParams = {
requestId: string
workflowId: string
userId: string
+ billingAttribution: BillingAttributionSnapshot
workspaceId: string
input: any
triggerType: CoreTriggerType
@@ -259,9 +273,54 @@ type AsyncExecutionParams = {
callChain?: string[]
}
+type ValidatedPreprocessContext = {
+ actorUserId: string
+ workflow: PreprocessExecutionSuccess['workflowRecord']
+ billingAttribution: BillingAttributionSnapshot
+ workspaceId: string
+}
+
+function requirePreprocessedExecutionContext(
+ result: PreprocessExecutionSuccess
+): ValidatedPreprocessContext {
+ if (!result.actorUserId) {
+ throw new Error('Preprocessing succeeded without an actor user')
+ }
+ if (!result.workflowRecord) {
+ throw new Error('Preprocessing succeeded without a workflow record')
+ }
+ if (!result.workflowRecord.workspaceId) {
+ throw new Error('Preprocessing succeeded without a workflow workspace')
+ }
+
+ const billingAttribution = assertBillingAttributionSnapshot(result.billingAttribution)
+ if (billingAttribution.actorUserId !== result.actorUserId) {
+ throw new Error('Preprocessing actor does not match billing attribution')
+ }
+ if (billingAttribution.workspaceId !== result.workflowRecord.workspaceId) {
+ throw new Error('Preprocessing workspace does not match billing attribution')
+ }
+
+ return {
+ actorUserId: result.actorUserId,
+ workflow: result.workflowRecord,
+ billingAttribution,
+ workspaceId: result.workflowRecord.workspaceId,
+ }
+}
+
async function handleAsyncExecution(params: AsyncExecutionParams): Promise {
- const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
- params
+ const {
+ requestId,
+ workflowId,
+ userId,
+ billingAttribution,
+ workspaceId,
+ input,
+ triggerType,
+ executionId,
+ callChain,
+ } = params
const asyncLogger = logger.withMetadata({
requestId,
workflowId,
@@ -281,6 +340,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise {
+ let workerOwnsReservation = false
try {
await jobQueue.startJob(jobId)
+ workerOwnsReservation = true
const output = await executeWorkflowJob(payload)
await jobQueue.completeJob(jobId, output)
} catch (error) {
@@ -311,11 +374,13 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise 0 ? rest : validatedInput
@@ -689,7 +757,8 @@ async function handleExecutePost(
)
}
- executionId = isClientSession && requestedExecutionId ? requestedExecutionId : generateId()
+ const callerProvidedExecutionId = requestedExecutionId
+ executionId = callerProvidedExecutionId ?? generateId()
reqLogger = reqLogger.withMetadata({ userId, executionId })
reqLogger.info('Starting server-side execution', {
@@ -705,15 +774,11 @@ async function handleExecutePost(
if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) {
loggingTriggerType = triggerType as CoreTriggerType
}
- const loggingSession = new LoggingSession(
- workflowId,
- executionId,
- loggingTriggerType,
- requestId
- )
- // Client-side sessions and personal API keys bill/permission-check the
- // authenticated user, not the workspace billed account.
+ /**
+ * Interactive sessions and personal keys preserve the authenticated human
+ * as actor. Preprocessing resolves the workspace payer independently.
+ */
const useAuthenticatedUserAsActor =
isClientSession ||
(auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal') ||
@@ -733,6 +798,28 @@ async function handleExecutePost(
)
}
+ const workflowWorkspaceId = workflowAuthorization.workflow?.workspaceId
+ if (auth.authType === AuthType.API_KEY) {
+ if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowWorkspaceId) {
+ return NextResponse.json(
+ { error: 'API key is not authorized for this workspace' },
+ { status: 403 }
+ )
+ }
+
+ if (auth.apiKeyType === 'personal') {
+ const workspaceSettings = workflowWorkspaceId
+ ? await getWorkspaceBillingSettings(workflowWorkspaceId)
+ : null
+ if (!workspaceSettings?.allowPersonalApiKeys) {
+ return NextResponse.json(
+ { error: 'Personal API keys are not allowed for this workspace' },
+ { status: 403 }
+ )
+ }
+ }
+ }
+
/**
* Workflow-in-workflow invocations (e.g. the agent `workflow_executor`
* tool) declare the parent execution's workspace. Reject execution when
@@ -752,11 +839,67 @@ async function handleExecutePost(
)
}
+ const upstreamBillingAttribution =
+ auth.authType === AuthType.INTERNAL_JWT && workflowAuthorization.workflow?.workspaceId
+ ? requireBillingAttributionHeader(req.headers, {
+ actorUserId: userId,
+ workspaceId: workflowAuthorization.workflow.workspaceId,
+ })
+ : undefined
+
if (req.signal.aborted) {
return clientCancelledResponse()
}
- // Pass the pre-fetched workflow record to skip the redundant Step 1 DB query in preprocessing.
+ try {
+ for (let attempt = 1; attempt <= SERVER_EXECUTION_ID_CLAIM_ATTEMPTS; attempt++) {
+ executionIdClaim = await claimExecutionId(executionId)
+ if (executionIdClaim || callerProvidedExecutionId) {
+ break
+ }
+
+ if (attempt < SERVER_EXECUTION_ID_CLAIM_ATTEMPTS) {
+ executionId = generateId()
+ reqLogger = reqLogger.withMetadata({ executionId })
+ }
+ }
+ } catch (error) {
+ reqLogger.error('Failed to claim workflow execution ID', {
+ error: getErrorMessage(error),
+ })
+ return NextResponse.json(
+ { error: 'Workflow execution identity is temporarily unavailable' },
+ { status: 503 }
+ )
+ }
+
+ if (!executionIdClaim) {
+ if (callerProvidedExecutionId) {
+ return NextResponse.json(
+ {
+ error: 'Execution ID has already been used',
+ code: 'EXECUTION_ID_CONFLICT',
+ executionId,
+ },
+ { status: 409 }
+ )
+ }
+
+ reqLogger.error('Failed to allocate a unique server execution ID')
+ return NextResponse.json(
+ { error: 'Unable to allocate workflow execution identity' },
+ { status: 503 }
+ )
+ }
+
+ const loggingSession = new LoggingSession(
+ workflowId,
+ executionId,
+ loggingTriggerType,
+ requestId
+ )
+
+ /** The pre-fetched record avoids a redundant initial workflow lookup. */
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -765,15 +908,16 @@ async function handleExecutePost(
requestId,
checkDeployment: !shouldUseDraftState,
loggingSession,
- useDraftState: shouldUseDraftState,
useAuthenticatedUserAsActor,
workflowRecord: workflowAuthorization.workflow ?? undefined,
+ billingAttribution: upstreamBillingAttribution,
})
if (!preprocessResult.success) {
+ const preprocessError = preprocessResult.error
return NextResponse.json(
- { error: preprocessResult.error!.message },
- { status: preprocessResult.error!.statusCode }
+ { error: preprocessError.message },
+ { status: preprocessError.statusCode }
)
}
@@ -785,38 +929,40 @@ async function handleExecutePost(
return clientCancelledResponse()
}
- const actorUserId = preprocessResult.actorUserId!
- const workflow = preprocessResult.workflowRecord!
-
- if (!workflow.workspaceId) {
- reqLogger.error('Workflow has no workspaceId')
- await releaseExecutionSlot(executionId)
- return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
- }
- const workspaceId = workflow.workspaceId
- reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId })
-
- if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workspaceId) {
+ let validatedContext: ValidatedPreprocessContext
+ try {
+ validatedContext = requirePreprocessedExecutionContext(preprocessResult)
+ } catch (error) {
+ reqLogger.error('Preprocessing returned an invalid execution context', {
+ error: getErrorMessage(error),
+ })
await releaseExecutionSlot(executionId)
return NextResponse.json(
- { error: 'API key is not authorized for this workspace' },
- { status: 403 }
+ { error: 'Invalid execution context returned by preprocessing' },
+ { status: 500 }
)
}
+ const { actorUserId, workflow, billingAttribution, workspaceId } = validatedContext
+ reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId })
reqLogger.info('Preprocessing passed')
if (isAsyncMode) {
- return handleAsyncExecution({
+ const response = await handleAsyncExecution({
requestId,
workflowId,
userId: actorUserId,
+ billingAttribution,
workspaceId,
input,
triggerType: loggingTriggerType,
executionId,
callChain,
})
+ if (response.status === 202) {
+ executionIdClaimCommitted = true
+ }
+ return response
}
let cachedWorkflowData: {
@@ -891,8 +1037,9 @@ async function handleExecutePost(
} catch (fileError) {
reqLogger.error('Failed to process input file fields:', fileError)
- await loggingSession.safeStart({
+ executionIdClaimCommitted = await loggingSession.safeStart({
userId: actorUserId,
+ billingAttribution,
workspaceId,
variables: {},
})
@@ -933,6 +1080,7 @@ async function handleExecutePost(
workflowId,
workspaceId,
userId: actorUserId,
+ billingAttribution,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
@@ -1209,6 +1357,7 @@ async function handleExecutePost(
base64MaxBytes,
abortSignal,
executionMode: 'stream',
+ billingAttribution,
largeValueKeys,
fileKeys,
stopAfterBlockId,
@@ -1218,6 +1367,7 @@ async function handleExecutePost(
),
})
+ executionIdClaimCommitted = true
return new NextResponse(stream, {
status: 200,
headers: SSE_HEADERS,
@@ -1248,6 +1398,7 @@ async function handleExecutePost(
)
}
+ executionIdClaimCommitted = true
const stream = new ReadableStream({
async start(controller) {
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
@@ -1511,6 +1662,7 @@ async function handleExecutePost(
workflowId,
workspaceId,
userId: actorUserId,
+ billingAttribution,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
@@ -1823,5 +1975,28 @@ async function handleExecutePost(
{ error: error.message || 'Failed to start workflow execution' },
{ status: 500 }
)
+ } finally {
+ if (executionIdClaim && !executionIdClaimCommitted) {
+ try {
+ executionIdClaimCommitted = await hasDurableExecutionOwner(executionId)
+ } catch (error) {
+ executionIdClaimCommitted = true
+ reqLogger.warn('Unable to verify execution ID ownership; retaining claim', {
+ error: toError(error).message,
+ executionId,
+ })
+ }
+ }
+
+ if (executionIdClaim && !executionIdClaimCommitted) {
+ try {
+ await releaseExecutionIdClaim(executionIdClaim)
+ } catch (error) {
+ reqLogger.warn('Failed to release pre-start execution ID claim', {
+ error: toError(error).message,
+ executionId,
+ })
+ }
+ }
}
}
diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
index fbbdb0abb5f..d3ff2d2a8a6 100644
--- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
@@ -10,6 +10,7 @@ import {
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import type { PausePoint } from '@/executor/types'
@@ -146,6 +147,7 @@ export const GET = withRouteHandler(
id: pausedExecutions.id,
status: pausedExecutions.status,
pausePoints: pausedExecutions.pausePoints,
+ metadata: pausedExecutions.metadata,
resumedCount: pausedExecutions.resumedCount,
pausedAt: pausedExecutions.pausedAt,
nextResumeAt: pausedExecutions.nextResumeAt,
@@ -168,11 +170,14 @@ export const GET = withRouteHandler(
if (isCurrentlyPaused && pausedRow) {
const points = normalizePausePoints(pausedRow.pausePoints)
const earliest = pickEarliestPausePoint(points)
+ const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata)
paused = {
pausedAt: pausedRow.pausedAt.toISOString(),
resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null,
pauseKind: earliest?.pauseKind ?? null,
blockedOnBlockId: earliest?.blockId ?? null,
+ automaticResumeWaitingReason:
+ automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null,
pausedExecutionId: pausedRow.id,
pausePointCount: points.length,
resumedCount: pausedRow.resumedCount,
diff --git a/apps/sim/app/api/workflows/[id]/log/route.test.ts b/apps/sim/app/api/workflows/[id]/log/route.test.ts
index f607ba3d0f0..b6d0169c1fe 100644
--- a/apps/sim/app/api/workflows/[id]/log/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/log/route.test.ts
@@ -8,9 +8,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
// Override global db mock with the configurable chain mock
vi.mock('@sim/db', () => dbChainMock)
-const { mockValidateWorkflowAccess, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
+const {
+ mockValidateWorkflowAccess,
+ mockGetWorkspaceBilledAccountUserId,
+ mockResolveBillingAttribution,
+ mockAssertBillingAttributionSnapshot,
+ mockStart,
+ mockSafeComplete,
+ mockSafeCompleteWithError,
+} = vi.hoisted(() => ({
mockValidateWorkflowAccess: vi.fn(),
mockGetWorkspaceBilledAccountUserId: vi.fn(),
+ mockResolveBillingAttribution: vi.fn(),
+ mockAssertBillingAttributionSnapshot: vi.fn((value) => value),
+ mockStart: vi.fn().mockResolvedValue(undefined),
+ mockSafeComplete: vi.fn().mockResolvedValue(undefined),
+ mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/app/api/workflows/middleware', () => ({
@@ -21,17 +34,24 @@ vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
+ resolveBillingAttribution: mockResolveBillingAttribution,
+}))
+
vi.mock('@/lib/logs/execution/logging-session', () => ({
- LoggingSession: vi.fn().mockImplementation(() => ({
- start: vi.fn().mockResolvedValue(undefined),
- markAsFailed: vi.fn().mockResolvedValue(undefined),
- safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
- safeComplete: vi.fn().mockResolvedValue(undefined),
- })),
+ LoggingSession: vi.fn(function LoggingSession() {
+ return {
+ start: mockStart,
+ markAsFailed: vi.fn().mockResolvedValue(undefined),
+ safeCompleteWithError: mockSafeCompleteWithError,
+ safeComplete: mockSafeComplete,
+ }
+ }),
}))
vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
- buildTraceSpans: vi.fn().mockReturnValue([]),
+ buildTraceSpans: vi.fn().mockReturnValue({ traceSpans: [], totalDuration: 0 }),
}))
import { POST } from './route'
@@ -45,7 +65,20 @@ const makeRequest = (workflowId: string, body: unknown) =>
const validResult = { success: true, output: { value: 42 } }
-describe('POST /api/workflows/[id]/log cross-tenant guard', () => {
+const storedBillingAttribution = {
+ actorUserId: 'user-1',
+ workspaceId: 'workspace-1',
+ organizationId: 'org-original',
+ billedAccountUserId: 'owner-original',
+ billingEntity: { type: 'organization', id: 'org-original' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
+describe('POST /api/workflows/[id]/log completion attribution', () => {
const OWNER_WORKFLOW_ID = 'wf-owner'
const ATTACKER_WORKFLOW_ID = 'wf-attacker'
const VICTIM_EXECUTION_ID = 'exec-victim-uuid'
@@ -54,14 +87,42 @@ describe('POST /api/workflows/[id]/log cross-tenant guard', () => {
vi.clearAllMocks()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
- mockValidateWorkflowAccess.mockResolvedValue({ error: null })
- mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
- // Default: no existing log (fresh execution)
+ mockValidateWorkflowAccess.mockResolvedValue({
+ workflow: {
+ id: OWNER_WORKFLOW_ID,
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ },
+ auth: {
+ success: true,
+ userId: 'user-1',
+ authType: 'session',
+ },
+ })
+ mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1')
+ mockResolveBillingAttribution.mockResolvedValue({
+ actorUserId: 'user-1',
+ workspaceId: 'workspace-1',
+ organizationId: 'org-1',
+ billedAccountUserId: 'owner-1',
+ billingEntity: { type: 'organization', id: 'org-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ })
dbChainMockFns.limit.mockResolvedValue([])
})
it('returns 404 when executionId belongs to a different workflow', async () => {
- dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: { billingAttribution: storedBillingAttribution },
+ },
+ ])
const res = await POST(
makeRequest(ATTACKER_WORKFLOW_ID, {
@@ -76,33 +137,175 @@ describe('POST /api/workflows/[id]/log cross-tenant guard', () => {
expect(body.error).toBe('Execution not found')
})
- it('proceeds when executionId belongs to the same workflow', async () => {
- dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])
+ it('fails closed when a completion has no persisted execution row', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([])
const res = await POST(
makeRequest(OWNER_WORKFLOW_ID, {
- executionId: VICTIM_EXECUTION_ID,
+ executionId: 'missing-execution-id',
result: validResult,
}),
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
)
- expect(res.status).not.toBe(404)
- expect(res.status).not.toBe(403)
+ expect(res.status).toBe(404)
+ expect(await res.json()).toMatchObject({ error: 'Execution not found' })
+ expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockStart).not.toHaveBeenCalled()
+ expect(mockSafeComplete).not.toHaveBeenCalled()
})
- it('proceeds when executionId has no existing log row (fresh execution)', async () => {
- dbChainMockFns.limit.mockResolvedValueOnce([])
+ it('fails closed when the persisted execution has no attribution snapshot', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: {},
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'legacy-execution-id',
+ result: validResult,
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(500)
+ expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockStart).not.toHaveBeenCalled()
+ })
+
+ it('rejects a completion from an actor other than the persisted execution actor', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: {
+ billingAttribution: {
+ ...storedBillingAttribution,
+ actorUserId: 'different-user',
+ },
+ },
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'actor-mismatch-execution-id',
+ result: validResult,
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(403)
+ expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockStart).not.toHaveBeenCalled()
+ })
+
+ it('rejects a persisted attribution snapshot bound to another workspace', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: {
+ billingAttribution: {
+ ...storedBillingAttribution,
+ workspaceId: 'workspace-other',
+ },
+ },
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'workspace-mismatch-execution-id',
+ result: validResult,
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(500)
+ expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockStart).not.toHaveBeenCalled()
+ })
+
+ it('uses the persisted attribution after a workflow transfer and payer change', async () => {
+ mockValidateWorkflowAccess.mockResolvedValueOnce({
+ workflow: {
+ id: OWNER_WORKFLOW_ID,
+ userId: 'owner-current',
+ workspaceId: 'workspace-current',
+ },
+ auth: {
+ success: true,
+ userId: 'user-1',
+ authType: 'session',
+ },
+ })
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: { billingAttribution: storedBillingAttribution },
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'existing-execution-id',
+ result: validResult,
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockStart).toHaveBeenCalledWith({
+ userId: 'user-1',
+ actorUserId: 'user-1',
+ billingAttribution: storedBillingAttribution,
+ workspaceId: 'workspace-1',
+ variables: {},
+ skipLogCreation: true,
+ })
+ })
+
+ it('completes with a valid persisted attribution snapshot', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: { billingAttribution: storedBillingAttribution },
+ },
+ ])
const res = await POST(
makeRequest(OWNER_WORKFLOW_ID, {
- executionId: 'brand-new-execution-id',
+ executionId: 'existing-execution-id',
result: validResult,
}),
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
)
- expect(res.status).not.toBe(404)
- expect(res.status).not.toBe(403)
+ expect(res.status).toBe(200)
+ expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
+ expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
+ expect(mockStart).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ actorUserId: 'user-1',
+ billingAttribution: storedBillingAttribution,
+ workspaceId: 'workspace-1',
+ skipLogCreation: true,
+ })
+ )
+ expect(mockSafeComplete).toHaveBeenCalledOnce()
})
})
diff --git a/apps/sim/app/api/workflows/[id]/log/route.ts b/apps/sim/app/api/workflows/[id]/log/route.ts
index 8d319910a88..f79888ca363 100644
--- a/apps/sim/app/api/workflows/[id]/log/route.ts
+++ b/apps/sim/app/api/workflows/[id]/log/route.ts
@@ -5,11 +5,14 @@ import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { workflowLogContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+} from '@/lib/billing/core/billing-attribution'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
-import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { ExecutionResult } from '@/executor/types'
@@ -44,45 +47,86 @@ export const POST = withRouteHandler(
}
const [existingLog] = await db
- .select({ workflowId: workflowExecutionLogs.workflowId })
+ .select({
+ workflowId: workflowExecutionLogs.workflowId,
+ workspaceId: workflowExecutionLogs.workspaceId,
+ executionData: workflowExecutionLogs.executionData,
+ })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
- if (existingLog && existingLog.workflowId !== id) {
+ if (!existingLog) {
+ logger.warn(`[${requestId}] No persisted start found for execution ${executionId}`)
+ return createErrorResponse('Execution not found', 404)
+ }
+
+ if (existingLog.workflowId !== id) {
logger.warn(
`[${requestId}] executionId ${executionId} belongs to workflow ${existingLog.workflowId}, not ${id}`
)
return createErrorResponse('Execution not found', 404)
}
+ const storedBillingAttributionValue =
+ existingLog.executionData &&
+ typeof existingLog.executionData === 'object' &&
+ 'billingAttribution' in existingLog.executionData &&
+ existingLog.executionData.billingAttribution
+ ? existingLog.executionData.billingAttribution
+ : undefined
+ if (!storedBillingAttributionValue) {
+ logger.error(
+ `[${requestId}] Existing execution ${executionId} is missing immutable billing attribution`
+ )
+ return createErrorResponse('Execution billing attribution is unavailable', 500)
+ }
+
+ let billingAttribution: BillingAttributionSnapshot
+ try {
+ billingAttribution = assertBillingAttributionSnapshot(storedBillingAttributionValue)
+ } catch (error) {
+ logger.error(
+ `[${requestId}] Existing execution ${executionId} has invalid immutable billing attribution`,
+ { error }
+ )
+ return createErrorResponse('Execution billing attribution is unavailable', 500)
+ }
+
+ if (
+ !existingLog.workspaceId ||
+ billingAttribution.workspaceId !== existingLog.workspaceId
+ ) {
+ logger.error(
+ `[${requestId}] Existing execution ${executionId} has inconsistent workspace attribution`
+ )
+ return createErrorResponse('Execution billing attribution is unavailable', 500)
+ }
+
+ const actorUserId = accessValidation.auth?.userId
+ if (!actorUserId || actorUserId !== billingAttribution.actorUserId) {
+ logger.warn(
+ `[${requestId}] Authenticated actor does not match execution ${executionId} attribution`
+ )
+ return createErrorResponse('Execution is not authorized for this caller', 403)
+ }
+
logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, {
executionId,
success: result.success,
})
const isChatExecution = result.metadata?.source === 'chat'
-
const triggerType = isChatExecution ? 'chat' : 'manual'
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
- const workspaceId = accessValidation.workflow.workspaceId
- if (!workspaceId) {
- logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
- return createErrorResponse('Workflow has no associated workspace', 500)
- }
- const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
- if (!billedAccountUserId) {
- logger.error(
- `[${requestId}] Unable to resolve billed account for workspace ${workspaceId}`
- )
- return createErrorResponse('Unable to resolve billing account for this workspace', 500)
- }
-
- await loggingSession.safeStart({
- userId: billedAccountUserId,
- workspaceId,
+ await loggingSession.start({
+ userId: actorUserId,
+ actorUserId,
+ billingAttribution,
+ workspaceId: existingLog.workspaceId,
variables: {},
+ skipLogCreation: true,
})
const resultWithOutput = {
diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts
new file mode 100644
index 00000000000..67b354a5268
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts
@@ -0,0 +1,94 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetApiKeyDisplayFormat,
+ mockGetSession,
+ mockGetUserEntityPermissions,
+ mockGetWorkspaceById,
+ mockOrderBy,
+} = vi.hoisted(() => ({
+ mockGetApiKeyDisplayFormat: vi.fn(),
+ mockGetSession: vi.fn(),
+ mockGetUserEntityPermissions: vi.fn(),
+ mockGetWorkspaceById: vi.fn(),
+ mockOrderBy: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => ({
+ db: {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: mockOrderBy,
+ })),
+ })),
+ })),
+ },
+}))
+
+vi.mock('@/lib/api-key/auth', () => ({
+ getApiKeyDisplayFormat: mockGetApiKeyDisplayFormat,
+}))
+
+vi.mock('@/lib/api-key/orchestration', () => ({
+ performCreateWorkspaceApiKey: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getUserEntityPermissions: mockGetUserEntityPermissions,
+ getWorkspaceById: mockGetWorkspaceById,
+}))
+
+import { GET } from '@/app/api/workspaces/[id]/api-keys/route'
+
+describe('GET /api/workspaces/[id]/api-keys', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'reader-1' } })
+ mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-1' })
+ mockGetUserEntityPermissions.mockResolvedValue('read')
+ mockGetApiKeyDisplayFormat.mockResolvedValue('sim_••••legacy')
+ mockOrderBy.mockResolvedValue([
+ {
+ id: 'key-1',
+ name: 'Legacy key',
+ key: 'sim_plaintext_legacy_secret',
+ createdAt: new Date('2026-07-01T00:00:00.000Z'),
+ lastUsed: null,
+ expiresAt: null,
+ createdBy: 'owner-1',
+ },
+ ])
+ })
+
+ it('returns metadata without exposing the stored key value', async () => {
+ const response = await GET(createMockRequest('GET'), {
+ params: Promise.resolve({ id: 'workspace-1' }),
+ })
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.keys).toEqual([
+ {
+ id: 'key-1',
+ name: 'Legacy key',
+ displayKey: 'sim_••••legacy',
+ createdAt: '2026-07-01T00:00:00.000Z',
+ lastUsed: null,
+ expiresAt: null,
+ createdBy: 'owner-1',
+ },
+ ])
+ expect(body.keys[0]).not.toHaveProperty('key')
+ expect(mockGetApiKeyDisplayFormat).toHaveBeenCalledWith('sim_plaintext_legacy_secret')
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts
index 5706dd1699d..ea9521e37b6 100644
--- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts
@@ -63,8 +63,12 @@ export const GET = withRouteHandler(
workspaceKeys.map(async (key) => {
const displayFormat = await getApiKeyDisplayFormat(key.key)
return {
- ...key,
- key: key.key,
+ id: key.id,
+ name: key.name,
+ createdAt: key.createdAt,
+ lastUsed: key.lastUsed,
+ expiresAt: key.expiresAt,
+ createdBy: key.createdBy,
displayKey: displayFormat,
}
})
diff --git a/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts b/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts
new file mode 100644
index 00000000000..b4d90966f77
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts
@@ -0,0 +1,113 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession, mockGetWorkspaceCreditAvailability, mockGetWorkspaceHostContextForViewer } =
+ vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockGetWorkspaceCreditAvailability: vi.fn(),
+ mockGetWorkspaceHostContextForViewer: vi.fn(),
+ }))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/billing/core/workspace-usage-gate', () => ({
+ getWorkspaceCreditAvailability: mockGetWorkspaceCreditAvailability,
+}))
+
+vi.mock('@/lib/workspaces/host-context', () => ({
+ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer,
+}))
+
+import { GET } from '@/app/api/workspaces/[id]/credit-availability/route'
+
+const HOST_CONTEXT = {
+ workspace: {
+ id: 'workspace-b',
+ name: 'Workspace B',
+ workspaceMode: 'organization',
+ billedAccountUserId: 'owner-b',
+ },
+ hostOrganizationId: 'org-b',
+ ownerBilling: {
+ plan: 'team_25000',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: true,
+ isEnterprise: false,
+ isOrgScoped: true,
+ organizationId: 'org-b',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ },
+ viewer: {
+ permission: 'write',
+ isHostOrganizationMember: false,
+ isHostOrganizationAdmin: false,
+ },
+}
+
+async function callGet() {
+ const response = await GET(createMockRequest('GET'), {
+ params: Promise.resolve({ id: 'workspace-b' }),
+ })
+ return { status: response.status, body: await response.json() }
+}
+
+describe('GET /api/workspaces/[id]/credit-availability', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'external-a' } })
+ mockGetWorkspaceHostContextForViewer.mockResolvedValue(HOST_CONTEXT)
+ mockGetWorkspaceCreditAvailability.mockResolvedValue({
+ remainingDollars: 20,
+ scope: 'member',
+ })
+ })
+
+ it('authenticates before resolving workspace or billing context', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ const { status } = await callGet()
+
+ expect(status).toBe(401)
+ expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled()
+ expect(mockGetWorkspaceCreditAvailability).not.toHaveBeenCalled()
+ })
+
+ it('uses workspace B payer for an external actor without exposing the host pool', async () => {
+ const { status, body } = await callGet()
+
+ expect(status).toBe(200)
+ expect(body).toEqual({ remainingDollars: 20, scope: 'member' })
+ expect(mockGetWorkspaceCreditAvailability).toHaveBeenCalledWith({
+ actorUserId: 'external-a',
+ workspaceId: 'workspace-b',
+ canViewPayerPool: false,
+ })
+ })
+
+ it('allows a target-organization admin to view the host pool availability', async () => {
+ mockGetWorkspaceHostContextForViewer.mockResolvedValue({
+ ...HOST_CONTEXT,
+ viewer: {
+ ...HOST_CONTEXT.viewer,
+ isHostOrganizationMember: true,
+ isHostOrganizationAdmin: true,
+ },
+ })
+
+ await callGet()
+
+ expect(mockGetWorkspaceCreditAvailability).toHaveBeenCalledWith(
+ expect.objectContaining({ canViewPayerPool: true })
+ )
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/credit-availability/route.ts b/apps/sim/app/api/workspaces/[id]/credit-availability/route.ts
new file mode 100644
index 00000000000..9bc0ad31be1
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/credit-availability/route.ts
@@ -0,0 +1,35 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { getWorkspaceCreditAvailabilityContract } from '@/lib/api/contracts/workspaces'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { getWorkspaceCreditAvailability } from '@/lib/billing/core/workspace-usage-gate'
+import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
+
+export const GET = withRouteHandler(
+ async (request: 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(getWorkspaceCreditAvailabilityContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const workspaceId = parsed.data.params.id
+ const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
+ if (!hostContext) {
+ return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 })
+ }
+
+ const availability = await getWorkspaceCreditAvailability({
+ actorUserId: session.user.id,
+ workspaceId,
+ canViewPayerPool: canManageWorkspaceBilling(hostContext, session.user.id),
+ })
+
+ return NextResponse.json(availability)
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
index 8fee00a3f25..53a70ec7a0d 100644
--- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
@@ -11,16 +11,21 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockCheckStorageQuota, mockGenerateWorkspaceFileKey, mockUseBlobStorage } = vi.hoisted(
- () => ({
- mockCheckStorageQuota: vi.fn(),
- mockGenerateWorkspaceFileKey: vi.fn(),
- mockUseBlobStorage: { value: false },
- })
-)
+const {
+ mockCheckStorageQuota,
+ mockGenerateWorkspaceFileKey,
+ mockResolveStorageBillingContext,
+ mockUseBlobStorage,
+} = vi.hoisted(() => ({
+ mockCheckStorageQuota: vi.fn(),
+ mockGenerateWorkspaceFileKey: vi.fn(),
+ mockResolveStorageBillingContext: vi.fn(),
+ mockUseBlobStorage: { value: false },
+}))
vi.mock('@/lib/billing/storage', () => ({
- checkStorageQuota: mockCheckStorageQuota,
+ checkStorageQuotaForBillingContext: mockCheckStorageQuota,
+ resolveStorageBillingContext: mockResolveStorageBillingContext,
}))
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
@@ -38,6 +43,13 @@ vi.mock('@/lib/uploads/config', () => ({
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
+const STORAGE_CONTEXT = {
+ workspaceId: WS,
+ billedAccountUserId: 'workspace-owner',
+ billingEntity: { type: 'organization' as const, id: 'workspace-org' },
+ plan: 'team_25000',
+ customStorageLimitGB: null,
+}
import { POST } from '@/app/api/workspaces/[id]/files/presigned/route'
@@ -62,6 +74,7 @@ describe('POST /api/workspaces/[id]/files/presigned', () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
+ mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`)
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({
@@ -138,6 +151,8 @@ describe('POST /api/workspaces/[id]/files/presigned', () => {
expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' })
expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4')
+ expect(mockResolveStorageBillingContext).toHaveBeenCalledWith(WS)
+ expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, validBody.fileSize)
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith(
expect.objectContaining({
context: 'workspace',
diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
index abb83b3f6c9..64ba36f0fc5 100644
--- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
@@ -4,7 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { checkStorageQuota } from '@/lib/billing/storage'
+import {
+ checkStorageQuotaForBillingContext,
+ resolveStorageBillingContext,
+} from '@/lib/billing/storage'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
@@ -68,7 +71,8 @@ export const POST = withRouteHandler(
})
}
- const quotaCheck = await checkStorageQuota(userId, fileSize)
+ const storageBillingContext = await resolveStorageBillingContext(workspaceId)
+ const quotaCheck = await checkStorageQuotaForBillingContext(storageBillingContext, fileSize)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts
index 3d9fd1465e3..cce56f7b8e8 100644
--- a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts
@@ -167,5 +167,11 @@ describe('POST /api/workspaces/[id]/files/register', () => {
expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }),
expect.any(Object)
)
+ expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ actorId: 'user-1',
+ workspaceId: WS,
+ })
+ )
})
})
diff --git a/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts b/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts
new file mode 100644
index 00000000000..15a573533e8
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession, mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockGetWorkspaceHostContextForViewer: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/workspaces/host-context', () => ({
+ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer,
+}))
+
+import { GET } from '@/app/api/workspaces/[id]/host-context/route'
+
+const HOST_CONTEXT = {
+ workspace: {
+ id: 'workspace-1',
+ name: 'Workspace 1',
+ workspaceMode: 'organization',
+ billedAccountUserId: 'owner-1',
+ },
+ hostOrganizationId: 'org-host',
+ ownerBilling: {
+ plan: 'enterprise',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: false,
+ isEnterprise: true,
+ isOrgScoped: true,
+ organizationId: 'org-host',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ },
+ viewer: {
+ permission: 'read',
+ isHostOrganizationMember: false,
+ isHostOrganizationAdmin: false,
+ },
+}
+
+async function callGet() {
+ const response = await GET(createMockRequest('GET'), {
+ params: Promise.resolve({ id: 'workspace-1' }),
+ })
+ return { status: response.status, body: await response.json() }
+}
+
+describe('GET /api/workspaces/[id]/host-context', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } })
+ mockGetWorkspaceHostContextForViewer.mockResolvedValue(HOST_CONTEXT)
+ })
+
+ it('authenticates before resolving workspace context', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ const { status, body } = await callGet()
+
+ expect(status).toBe(401)
+ expect(body).toEqual({ error: 'Unauthorized' })
+ expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled()
+ })
+
+ it('returns 403 without leaking host context when access is denied', async () => {
+ mockGetWorkspaceHostContextForViewer.mockResolvedValue(null)
+
+ const { status, body } = await callGet()
+
+ expect(status).toBe(403)
+ expect(body).toEqual({ error: 'Workspace access denied' })
+ })
+
+ it('returns route-derived host context for an external collaborator', async () => {
+ const { status, body } = await callGet()
+
+ expect(status).toBe(200)
+ expect(body).toEqual(HOST_CONTEXT)
+ expect(mockGetWorkspaceHostContextForViewer).toHaveBeenCalledWith('workspace-1', 'viewer-1')
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/host-context/route.ts b/apps/sim/app/api/workspaces/[id]/host-context/route.ts
new file mode 100644
index 00000000000..d0d7d6d3c8c
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/host-context/route.ts
@@ -0,0 +1,29 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
+
+export const GET = withRouteHandler(
+ async (request: 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(getWorkspaceHostContextContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const hostContext = await getWorkspaceHostContextForViewer(
+ parsed.data.params.id,
+ session.user.id
+ )
+ if (!hostContext) {
+ return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 })
+ }
+
+ return NextResponse.json(hostContext)
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts b/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts
new file mode 100644
index 00000000000..a95c44df843
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts
@@ -0,0 +1,121 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCheckWorkspaceUsageGate, mockGetSession, mockGetWorkspaceHostContextForViewer } =
+ vi.hoisted(() => ({
+ mockCheckWorkspaceUsageGate: vi.fn(),
+ mockGetSession: vi.fn(),
+ mockGetWorkspaceHostContextForViewer: vi.fn(),
+ }))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/billing/core/workspace-usage-gate', () => ({
+ checkWorkspaceUsageGate: mockCheckWorkspaceUsageGate,
+}))
+
+vi.mock('@/lib/workspaces/host-context', () => ({
+ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer,
+}))
+
+import { GET } from '@/app/api/workspaces/[id]/usage-gate/route'
+
+const HOST_CONTEXT = {
+ workspace: {
+ id: 'workspace-b',
+ name: 'Workspace B',
+ workspaceMode: 'organization',
+ billedAccountUserId: 'owner-b',
+ },
+ hostOrganizationId: 'org-b',
+ ownerBilling: {
+ plan: 'team_25000',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: true,
+ isEnterprise: false,
+ isOrgScoped: true,
+ organizationId: 'org-b',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ },
+ viewer: {
+ permission: 'write',
+ isHostOrganizationMember: false,
+ isHostOrganizationAdmin: false,
+ },
+}
+
+async function callGet() {
+ const response = await GET(createMockRequest('GET'), {
+ params: Promise.resolve({ id: 'workspace-b' }),
+ })
+ return { status: response.status, body: await response.json() }
+}
+
+describe('GET /api/workspaces/[id]/usage-gate', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({
+ user: { id: 'external-a' },
+ session: { activeOrganizationId: 'org-a' },
+ })
+ mockGetWorkspaceHostContextForViewer.mockResolvedValue(HOST_CONTEXT)
+ mockCheckWorkspaceUsageGate.mockResolvedValue({
+ isExceeded: false,
+ message: null,
+ scope: null,
+ })
+ })
+
+ it('authenticates before resolving workspace or billing context', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ const { status, body } = await callGet()
+
+ expect(status).toBe(401)
+ expect(body).toEqual({ error: 'Unauthorized' })
+ expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled()
+ expect(mockCheckWorkspaceUsageGate).not.toHaveBeenCalled()
+ })
+
+ it('uses workspace B payer and external actor A without consulting active organization A', async () => {
+ mockCheckWorkspaceUsageGate.mockResolvedValue({
+ isExceeded: true,
+ message: 'Member credit limit exceeded.',
+ scope: 'member',
+ })
+
+ const { status, body } = await callGet()
+
+ expect(status).toBe(200)
+ expect(body).toEqual({
+ isExceeded: true,
+ message: 'Member credit limit exceeded.',
+ scope: 'member',
+ })
+ expect(mockGetWorkspaceHostContextForViewer).toHaveBeenCalledWith('workspace-b', 'external-a')
+ expect(mockCheckWorkspaceUsageGate).toHaveBeenCalledWith({
+ actorUserId: 'external-a',
+ workspaceId: 'workspace-b',
+ })
+ })
+
+ it('returns 403 before checking usage when workspace access is denied', async () => {
+ mockGetWorkspaceHostContextForViewer.mockResolvedValue(null)
+
+ const { status, body } = await callGet()
+
+ expect(status).toBe(403)
+ expect(body).toEqual({ error: 'Workspace access denied' })
+ expect(mockCheckWorkspaceUsageGate).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/usage-gate/route.ts b/apps/sim/app/api/workspaces/[id]/usage-gate/route.ts
new file mode 100644
index 00000000000..ae9d2cf2418
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/usage-gate/route.ts
@@ -0,0 +1,33 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { getWorkspaceUsageGateContract } from '@/lib/api/contracts/workspaces'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { checkWorkspaceUsageGate } from '@/lib/billing/core/workspace-usage-gate'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
+
+export const GET = withRouteHandler(
+ async (request: 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(getWorkspaceUsageGateContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const workspaceId = parsed.data.params.id
+ const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
+ if (!hostContext) {
+ return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 })
+ }
+
+ const usageGate = await checkWorkspaceUsageGate({
+ actorUserId: session.user.id,
+ workspaceId,
+ })
+
+ return NextResponse.json(usageGate)
+ }
+)
diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx
index ef043006658..2d452af0199 100644
--- a/apps/sim/app/invite/[id]/invite.test.tsx
+++ b/apps/sim/app/invite/[id]/invite.test.tsx
@@ -6,14 +6,19 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
+ mockCancelQueries,
+ mockGetSession,
mockInvalidateQueries,
mockPush,
mockRequestJson,
mockSearchParams,
mockSetActive,
+ mockSetQueryData,
mockSignOut,
mockUseSession,
} = vi.hoisted(() => ({
+ mockCancelQueries: vi.fn(),
+ mockGetSession: vi.fn(),
mockInvalidateQueries: vi.fn(),
mockPush: vi.fn(),
mockRequestJson: vi.fn(),
@@ -21,6 +26,7 @@ const {
get: (key: string) => (key === 'token' ? 'token-1' : null),
},
mockSetActive: vi.fn(),
+ mockSetQueryData: vi.fn(),
mockSignOut: vi.fn(),
mockUseSession: vi.fn(),
}))
@@ -32,7 +38,11 @@ vi.mock('next/navigation', () => ({
}))
vi.mock('@tanstack/react-query', () => ({
- useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+ useQueryClient: () => ({
+ cancelQueries: mockCancelQueries,
+ invalidateQueries: mockInvalidateQueries,
+ setQueryData: mockSetQueryData,
+ }),
}))
vi.mock('@/lib/api/client/request', () => ({
@@ -41,6 +51,7 @@ vi.mock('@/lib/api/client/request', () => ({
vi.mock('@/lib/auth/auth-client', () => ({
client: {
+ getSession: mockGetSession,
organization: { setActive: mockSetActive },
signOut: mockSignOut,
},
@@ -65,9 +76,21 @@ vi.mock('@/app/invite/components', () => ({
}))
import Invite from '@/app/invite/[id]/invite'
+import { sessionKeys } from '@/hooks/queries/session'
let container: HTMLDivElement
let root: Root
+let membershipIntent: 'external' | 'internal'
+
+const EXTERNAL_REFRESHED_SESSION = {
+ user: { id: 'user-1', email: 'invitee@example.com' },
+ session: { id: 'session-1', userId: 'user-1', activeOrganizationId: 'organization-a' },
+}
+
+const INTERNAL_REFRESHED_SESSION = {
+ user: { id: 'user-1', email: 'invitee@example.com' },
+ session: { id: 'session-1', userId: 'user-1', activeOrganizationId: 'organization-2' },
+}
async function flush(): Promise {
await act(async () => {
@@ -77,6 +100,24 @@ async function flush(): Promise {
})
}
+async function acceptCurrentInvitation(): Promise {
+ act(() => {
+ root.render( )
+ })
+ await flush()
+
+ const acceptButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Accept Invitation'
+ )
+ expect(acceptButton).toBeDefined()
+
+ await act(async () => {
+ acceptButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
@@ -88,6 +129,9 @@ beforeEach(() => {
data: { user: { id: 'user-1', email: 'invitee@example.com' } },
isPending: false,
})
+ membershipIntent = 'external'
+ mockCancelQueries.mockResolvedValue(undefined)
+ mockGetSession.mockResolvedValue({ data: EXTERNAL_REFRESHED_SESSION })
mockInvalidateQueries.mockResolvedValue(undefined)
mockRequestJson.mockImplementation((contract: { method?: string }) => {
if (contract.method === 'GET') {
@@ -98,7 +142,7 @@ beforeEach(() => {
email: 'invitee@example.com',
organizationId: 'organization-2',
organizationName: 'External Team',
- membershipIntent: 'external',
+ membershipIntent,
role: 'admin',
status: 'pending',
expiresAt: '2026-07-10T00:00:00.000Z',
@@ -138,23 +182,26 @@ afterEach(() => {
})
describe('Invite', () => {
- it('does not replace the active organization after accepting an external workspace invite', async () => {
- act(() => {
- root.render( )
+ it('refreshes an external acceptance without replacing the viewer organization client-side', async () => {
+ await acceptCurrentInvitation()
+
+ expect(mockSetActive).not.toHaveBeenCalled()
+ expect(mockGetSession).toHaveBeenCalledWith({
+ query: { disableCookieCache: true },
+ })
+ expect(mockCancelQueries).toHaveBeenCalledWith({
+ queryKey: sessionKeys.detail(),
})
- await flush()
+ expect(mockSetQueryData).toHaveBeenCalledWith(sessionKeys.detail(), EXTERNAL_REFRESHED_SESSION)
+ })
- const acceptButton = Array.from(container.querySelectorAll('button')).find(
- (button) => button.textContent === 'Accept Invitation'
- )
- expect(acceptButton).toBeDefined()
+ it('stores the server-selected active organization after an internal join', async () => {
+ membershipIntent = 'internal'
+ mockGetSession.mockResolvedValue({ data: INTERNAL_REFRESHED_SESSION })
- await act(async () => {
- acceptButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
- await Promise.resolve()
- await Promise.resolve()
- })
+ await acceptCurrentInvitation()
expect(mockSetActive).not.toHaveBeenCalled()
+ expect(mockSetQueryData).toHaveBeenCalledWith(sessionKeys.detail(), INTERNAL_REFRESHED_SESSION)
})
})
diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx
index 8bf5b82fd86..a708de49578 100644
--- a/apps/sim/app/invite/[id]/invite.tsx
+++ b/apps/sim/app/invite/[id]/invite.tsx
@@ -14,6 +14,7 @@ import {
import { client, useSession } from '@/lib/auth/auth-client'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { organizationKeys } from '@/hooks/queries/organization'
+import { refreshSessionQuery } from '@/hooks/queries/session'
import { subscriptionKeys } from '@/hooks/queries/subscription'
const logger = createLogger('InviteById')
@@ -236,6 +237,7 @@ export default function Invite() {
})
await Promise.all([
+ refreshSessionQuery(queryClient),
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
queryClient.invalidateQueries({ queryKey: organizationKeys.all }),
])
diff --git a/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx
new file mode 100644
index 00000000000..ab2f2fce4ae
--- /dev/null
+++ b/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx
@@ -0,0 +1,72 @@
+import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import {
+ getOrganizationSettingsFeatures,
+ getSettingsSectionMeta,
+ isOrganizationSettingsSectionAvailable,
+ ORGANIZATION_SETTINGS_ITEMS,
+ ORGANIZATION_SETTINGS_PATH_ALIASES,
+ parseSettingsPathSection,
+} from '@/components/settings/navigation'
+import { OrganizationSettingsRenderer } from '@/components/settings/organization-settings-renderer'
+import { SettingsUnavailable } from '@/components/settings/settings-unavailable'
+import { getSession } from '@/lib/auth'
+import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
+import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access'
+
+interface OrganizationSettingsSectionPageProps {
+ params: Promise<{ organizationId: string; section: string }>
+}
+
+export async function generateMetadata({
+ params,
+}: OrganizationSettingsSectionPageProps): Promise {
+ const { section } = await params
+ const parsed = parseSettingsPathSection({
+ path: section,
+ items: ORGANIZATION_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: ORGANIZATION_SETTINGS_PATH_ALIASES,
+ })
+ const meta = parsed ? getSettingsSectionMeta('organization', parsed) : null
+ return { title: meta ? `${meta.label} - Organization settings` : 'Organization settings' }
+}
+
+export default async function OrganizationSettingsSectionPage({
+ params,
+}: OrganizationSettingsSectionPageProps) {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+
+ const { organizationId, section } = await params
+ const parsed = parseSettingsPathSection({
+ path: section,
+ items: ORGANIZATION_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: ORGANIZATION_SETTINGS_PATH_ALIASES,
+ })
+ if (!parsed) notFound()
+
+ const canOpen = await canOpenOrganizationSettingsSection(organizationId, session.user.id, parsed)
+ if (!canOpen) return
+ const hasEnterprisePlan =
+ parsed !== 'members' &&
+ parsed !== 'billing' &&
+ (await isOrganizationOnEnterprisePlan(organizationId))
+ if (
+ !isOrganizationSettingsSectionAvailable(
+ parsed,
+ getOrganizationSettingsFeatures(hasEnterprisePlan)
+ )
+ ) {
+ return (
+
+ )
+ }
+
+ return
+}
diff --git a/apps/sim/app/organization/[organizationId]/settings/layout.tsx b/apps/sim/app/organization/[organizationId]/settings/layout.tsx
new file mode 100644
index 00000000000..12dce2f96e3
--- /dev/null
+++ b/apps/sim/app/organization/[organizationId]/settings/layout.tsx
@@ -0,0 +1,35 @@
+import { redirect } from 'next/navigation'
+import { SettingsUnavailable } from '@/components/settings/settings-unavailable'
+import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
+import { getSession } from '@/lib/auth'
+import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
+import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access'
+
+interface OrganizationSettingsLayoutProps {
+ children: React.ReactNode
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function OrganizationSettingsLayout({
+ children,
+ params,
+}: OrganizationSettingsLayoutProps) {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+
+ const { organizationId } = await params
+ const access = await getOrganizationSettingsAccess(organizationId, session.user.id)
+ if (!access.isMember) return
+ const hasEnterprisePlan = access.isAdmin && (await isOrganizationOnEnterprisePlan(organizationId))
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/organization/[organizationId]/settings/page.tsx b/apps/sim/app/organization/[organizationId]/settings/page.tsx
new file mode 100644
index 00000000000..c0d5c917a48
--- /dev/null
+++ b/apps/sim/app/organization/[organizationId]/settings/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from 'next/navigation'
+import { getOrganizationSettingsHref } from '@/components/settings/navigation'
+
+interface OrganizationSettingsPageProps {
+ params: Promise<{ organizationId: string }>
+}
+
+export default async function OrganizationSettingsPage({ params }: OrganizationSettingsPageProps) {
+ const { organizationId } = await params
+ redirect(getOrganizationSettingsHref(organizationId, 'members'))
+}
diff --git a/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx b/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx
new file mode 100644
index 00000000000..9e4bd26f677
--- /dev/null
+++ b/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx
@@ -0,0 +1,5 @@
+import { SettingsUnavailable } from '@/components/settings/settings-unavailable'
+
+export default function OrganizationSettingsUnavailablePage() {
+ return
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
new file mode 100644
index 00000000000..cf391833684
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
@@ -0,0 +1,26 @@
+import { ChipLink } from '@sim/emcn'
+import { CircleAlert } from '@sim/emcn/icons'
+
+export function WorkspaceAccessDenied() {
+ return (
+
+
+
+
+
+
+
+ Workspace access denied
+
+
+ You do not have access to this workspace. Ask a workspace administrator for access or
+ choose another workspace.
+
+
+
+ View your workspaces
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx
new file mode 100644
index 00000000000..6fdcac86f98
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx
@@ -0,0 +1,143 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockHostContext, mockPush, mockSession, mockUseWorkspaceCreditAvailability } = vi.hoisted(
+ () => ({
+ mockHostContext: {
+ current: {
+ workspace: {
+ id: 'workspace-b',
+ name: 'Workspace B',
+ workspaceMode: 'organization',
+ billedAccountUserId: 'owner-b',
+ },
+ hostOrganizationId: 'org-b',
+ ownerBilling: {
+ plan: 'team_25000',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: true,
+ isEnterprise: false,
+ isOrgScoped: true,
+ organizationId: 'org-b',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ },
+ viewer: {
+ permission: 'write',
+ isHostOrganizationMember: false,
+ isHostOrganizationAdmin: false,
+ },
+ },
+ },
+ mockPush: vi.fn(),
+ mockSession: { current: { user: { id: 'external-a' } } },
+ mockUseWorkspaceCreditAvailability: vi.fn(),
+ })
+)
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-b' }),
+ useRouter: () => ({ prefetch: vi.fn(), push: mockPush }),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({}),
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: mockSession.current }),
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ isBillingEnabled: true,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useWorkspaceHostContext: () => mockHostContext.current,
+}))
+
+vi.mock('@/hooks/queries/workspace-usage', () => ({
+ useWorkspaceCreditAvailability: mockUseWorkspaceCreditAvailability,
+}))
+
+vi.mock('@/hooks/queries/workspace', () => ({
+ prefetchWorkspaceSettings: vi.fn(),
+}))
+
+import { CreditsChip } from '@/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip'
+
+let container: HTMLDivElement
+let root: Root
+
+describe('CreditsChip', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockUseWorkspaceCreditAvailability.mockReturnValue({
+ data: { remainingDollars: 20, scope: 'member' },
+ isLoading: false,
+ })
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('shows external members workspace-effective credits without an upgrade control', async () => {
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(container.querySelector('[aria-label="Workspace credits remaining"]')).not.toBeNull()
+ expect(container.querySelector('[aria-label*="upgrade plan"]')).toBeNull()
+ expect(mockUseWorkspaceCreditAvailability).toHaveBeenCalledWith('workspace-b')
+ })
+
+ it('shows opaque availability instead of a false unlimited balance', async () => {
+ mockUseWorkspaceCreditAvailability.mockReturnValue({
+ data: { remainingDollars: null, scope: 'effective' },
+ isLoading: false,
+ })
+
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(container.textContent).toContain('Available')
+ expect(container.textContent).not.toContain('∞')
+ })
+
+ it('allows target-organization admins to open workspace upgrade pricing', async () => {
+ mockSession.current = { user: { id: 'admin-b' } }
+ mockHostContext.current = {
+ ...mockHostContext.current,
+ viewer: {
+ permission: 'admin',
+ isHostOrganizationMember: true,
+ isHostOrganizationAdmin: true,
+ },
+ }
+
+ await act(async () => {
+ root.render( )
+ })
+
+ const upgradeButton = container.querySelector(
+ '[aria-label="Workspace credits remaining — upgrade plan"]'
+ )
+ expect(upgradeButton).not.toBeNull()
+
+ act(() => upgradeButton?.click())
+ expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-b/upgrade?reason=credits')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
index 909ee90005a..0785ff9a53e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
@@ -1,18 +1,18 @@
'use client'
import { useCallback } from 'react'
-import { Chip } from '@sim/emcn'
+import { Chip, ChipTag, Tooltip } from '@sim/emcn'
import { Credit } from '@sim/emcn/icons'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
+import { useSession } from '@/lib/auth/auth-client'
import { formatCredits } from '@/lib/billing/credits/conversion'
-import { getPooledCreditsRemaining } from '@/lib/billing/on-demand'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
-import { useMyMemberCredits } from '@/hooks/queries/organization'
-import { usePlanView } from '@/hooks/queries/plan-view'
-import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription'
+import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
+import { useWorkspaceCreditAvailability } from '@/hooks/queries/workspace-usage'
export function CreditsChip() {
if (!isBillingEnabled) return null
@@ -21,78 +21,59 @@ export function CreditsChip() {
}
function CreditsChipInner() {
- const { planView, isLoading, hasData } = usePlanView()
- /**
- * `usePlanView` is built on top of `useSubscriptionData`, so the second call
- * dedups against the same React Query cache entry. We read the raw usage
- * fields here because `planView` intentionally only exposes plan-derived
- * decisions, not display math.
- */
- const { data } = useSubscriptionData()
const router = useRouter()
const queryClient = useQueryClient()
const { workspaceId } = useParams<{ workspaceId: string }>()
- const { data: memberCredits, isLoading: memberLoading } = useMyMemberCredits(workspaceId)
+ const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
+ const { data: availability, isLoading } = useWorkspaceCreditAvailability(
+ hostContext.ownerBilling.isEnterprise ? undefined : workspaceId
+ )
const upgradeHref = buildUpgradeHref(workspaceId, 'credits')
+ const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id)
/**
- * Warm the route bundle and the exact queries the Upgrade page gates on, so
- * the click navigates into already-cached data instead of a blank, loading page.
+ * Warms the workspace-scoped upgrade route and settings data.
*/
const prefetchUpgrade = useCallback(() => {
router.prefetch(upgradeHref)
- prefetchUpgradeBillingData(queryClient)
prefetchWorkspaceSettings(queryClient, workspaceId)
}, [router, queryClient, upgradeHref, workspaceId])
- const renderChip = (dollars: number) => (
+ if (hostContext.ownerBilling.isEnterprise || isLoading || !availability) return null
+
+ const formattedCredits =
+ availability.remainingDollars === null
+ ? 'Available'
+ : formatCredits(availability.remainingDollars)
+
+ if (!canManageBilling) {
+ const unavailableMessage = hostContext.hostOrganizationId
+ ? 'Contact an organization admin to manage this workspace’s billing.'
+ : 'Only the workspace owner can manage billing.'
+
+ return (
+
+
+
+ {formattedCredits}
+
+
+ {unavailableMessage}
+
+ )
+ }
+
+ return (
router.push(upgradeHref)}
onMouseEnter={prefetchUpgrade}
onFocus={prefetchUpgrade}
leftIcon={Credit}
>
- {formatCredits(dollars)}
+ {formattedCredits}
)
-
- // Wait for the per-member cap result before rendering: until it resolves,
- // `limitDollars` is null and a capped member would briefly see the larger
- // pooled number. Disabled (no workspace) → not loading, so non-org users are
- // unaffected; cached after the first load (30s staleTime), so it's a one-time
- // wait, not a per-navigation one.
- if (memberLoading) return null
-
- /**
- * Pooled/plan remaining (dollars): unused plan allowance, matching enforcement
- * (`currentUsage >= limit` blocks, so remaining is `limit - currentUsage`).
- * Granted credits are already folded into `usageLimit`, so they are not added
- * again here. Null when the plan-based chip wouldn't show on its own (data not
- * ready, or the plan isn't credit-metered). The unlimited sentinel renders as ∞.
- */
- const pooledData = !isLoading && hasData && planView.showCredits ? (data?.data ?? null) : null
- const pooledRemaining =
- pooledData === null
- ? null
- : getPooledCreditsRemaining(pooledData.usageLimit, pooledData.currentUsage)
-
- /**
- * A per-member cap is the authoritative personal remaining, but the actor gate
- * blocks on the pooled cap first — so show the tighter of the two, or a member
- * could see credits left while every action 402s on org/plan usage. Clamp at 0.
- * Fall back to personal alone when pooled isn't available/shown, so a capped
- * member still sees a balance even where the plan chip would be hidden.
- */
- const limitDollars = memberCredits?.limitDollars ?? null
- if (limitDollars !== null) {
- const personalRemaining = Math.max(0, limitDollars - (memberCredits?.usedDollars ?? 0))
- return renderChip(
- pooledRemaining === null ? personalRemaining : Math.min(personalRemaining, pooledRemaining)
- )
- }
-
- if (pooledRemaining === null) return null
- return renderChip(pooledRemaining)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index b303f7522bb..50eb261d7a9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -15,6 +15,8 @@ import {
} from '@sim/emcn'
import { useParams } from 'next/navigation'
import { ThinkingLoader } from '@/components/ui'
+import { useSession } from '@/lib/auth/auth-client'
+import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
@@ -23,6 +25,7 @@ import type {
ChatMessageContext,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
usePersonalEnvironment,
@@ -33,6 +36,7 @@ import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
+import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
export interface OptionsItemData {
title: string
@@ -772,9 +776,15 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
}
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
- const { workspaceId } = useParams<{ workspaceId: string }>()
- const settingsPath = `/workspace/${workspaceId}/settings/billing`
+ const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
+ const { getSettingsHref } = useSettingsNavigation()
+ const settingsPath = getSettingsHref({ section: 'billing' })
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
+ const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id)
+ const unavailableMessage = hostContext.hostOrganizationId
+ ? 'Contact an organization admin to manage this workspace’s usage limits.'
+ : 'Only the workspace owner can manage this workspace’s usage limits.'
return (
@@ -801,13 +811,19 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
{data.message}
-
- {buttonLabel}
-
-
+ {canManageBilling ? (
+
+ {buttonLabel}
+
+
+ ) : (
+
+ {unavailableMessage}
+
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 0bfe6bdb233..95a79eee750 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -1,7 +1,7 @@
'use client'
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react'
-import { Button, PlayOutline, Skeleton, Tooltip } from '@sim/emcn'
+import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn'
import {
Calendar,
Download,
@@ -17,6 +17,8 @@ import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { useRouter } from 'next/navigation'
import { isApiClientError } from '@/lib/api/client/errors'
+import { useSession } from '@/lib/auth/auth-client'
+import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import {
cancelRunToolExecution,
@@ -44,6 +46,7 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import { LogDetailsContent } from '@/app/workspace/[workspaceId]/logs/components'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import {
useUserPermissionsContext,
useWorkspacePermissionsContext,
@@ -325,20 +328,28 @@ interface EmbeddedWorkflowActionsProps {
export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) {
const { navigateToSettings } = useSettingsNavigation()
+ const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext()
const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow)
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
const isExecuting = useExecutionStore(
(state) => state.workflowExecutions.get(workflowId)?.isExecuting ?? false
)
- const { usageExceeded } = useUsageLimits()
+ const {
+ usageExceeded,
+ message: usageLimitMessage,
+ scope: usageLimitScope,
+ isLoading: isUsageGateLoading,
+ } = useUsageLimits({ workspaceId })
useEffect(() => {
void setActiveWorkflow(workflowId)
}, [workflowId, setActiveWorkflow])
const isRunButtonDisabled =
- !isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading
+ !isExecuting &&
+ (isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading))
const handleRun = async () => {
setActiveWorkflow(workflowId)
@@ -351,8 +362,18 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
return
}
+ if (isUsageGateLoading) return
+
if (usageExceeded) {
- navigateToSettings({ section: 'billing' })
+ const action = getWorkspaceUsageLimitAction(hostContext, session?.user?.id, {
+ message: usageLimitMessage,
+ scope: usageLimitScope,
+ })
+ if (action.type === 'manage-billing') {
+ navigateToSettings({ section: 'billing' })
+ } else {
+ toast.error(action.message)
+ }
return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
index 4e1e7a5ca87..8738542fa6d 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
@@ -22,7 +22,7 @@ import {
} from '@sim/emcn'
import { ArrowLeft, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { getSubscriptionAccessState } from '@/lib/billing/client'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
@@ -31,17 +31,17 @@ import {
} from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
+import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorMeta } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
-import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_META_REGISTRY)
@@ -76,11 +76,10 @@ export function AddConnectorModal({
const [searchTerm, setSearchTerm] = useState('')
const { workspaceId } = useParams<{ workspaceId: string }>()
+ const { ownerBilling } = useWorkspaceHostContext()
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
- const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
- const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
- const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
+ const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling, isBillingEnabled)
const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts
new file mode 100644
index 00000000000..be610b98877
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts
@@ -0,0 +1,70 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces'
+import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
+
+const HOST_MAX_BILLING: WorkspaceOwnerBilling = {
+ plan: 'team_25000',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: true,
+ isEnterprise: false,
+ isOrgScoped: true,
+ organizationId: 'org-b',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+}
+
+describe('hasWorkspaceMaxConnectorAccess', () => {
+ it('uses the workspace host Max entitlement', () => {
+ expect(hasWorkspaceMaxConnectorAccess(HOST_MAX_BILLING, true)).toBe(true)
+ })
+
+ it('does not unlock live sync from a free host plan', () => {
+ expect(
+ hasWorkspaceMaxConnectorAccess(
+ {
+ ...HOST_MAX_BILLING,
+ plan: 'free',
+ status: null,
+ isPaid: false,
+ isTeam: false,
+ isOrgScoped: false,
+ organizationId: null,
+ },
+ true
+ )
+ ).toBe(false)
+ })
+
+ it('does not unlock live sync for a blocked Max payer', () => {
+ expect(
+ hasWorkspaceMaxConnectorAccess(
+ {
+ ...HOST_MAX_BILLING,
+ billingBlocked: true,
+ billingBlockedReason: 'payment_failed',
+ },
+ true
+ )
+ ).toBe(false)
+ })
+
+ it('keeps connector intervals available when billing is disabled', () => {
+ expect(
+ hasWorkspaceMaxConnectorAccess(
+ {
+ ...HOST_MAX_BILLING,
+ plan: 'free',
+ status: null,
+ isPaid: false,
+ },
+ false
+ )
+ ).toBe(true)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts
new file mode 100644
index 00000000000..a25769cf001
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts
@@ -0,0 +1,10 @@
+import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces'
+import { getSubscriptionAccessState } from '@/lib/billing/client'
+
+export function hasWorkspaceMaxConnectorAccess(
+ ownerBilling: WorkspaceOwnerBilling,
+ billingEnabled: boolean
+): boolean {
+ if (!billingEnabled) return true
+ return getSubscriptionAccessState(ownerBilling).hasUsableMaxAccess
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
index 434e4738546..943f975cbe5 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
@@ -17,8 +17,9 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { ExternalLink, RotateCcw } from 'lucide-react'
-import { getSubscriptionAccessState } from '@/lib/billing/client'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
+import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import type {
@@ -26,7 +27,7 @@ import type {
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
@@ -36,7 +37,6 @@ import {
useRestoreConnectorDocument,
useUpdateConnector,
} from '@/hooks/queries/kb/connectors'
-import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('EditConnectorModal')
@@ -191,11 +191,10 @@ export function EditConnectorModal({
initialCanonicalModes,
})
+ const { ownerBilling } = useWorkspaceHostContext()
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
- const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
- const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
- const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
+ const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling, isBillingEnabled)
const persistedCanonicalModes = useMemo(
() => readPersistedCanonicalModes(connector.sourceConfig),
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
new file mode 100644
index 00000000000..4df63d68863
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
@@ -0,0 +1,181 @@
+/**
+ * @vitest-environment node
+ */
+import type { ReactNode } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockBrandingProvider,
+ mockGetOrgWhitelabelSettings,
+ mockGetSession,
+ mockPrefetchWorkspaceHostContext,
+ mockPrefetchWorkspaceSidebar,
+} = vi.hoisted(() => ({
+ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children),
+ mockGetOrgWhitelabelSettings: vi.fn(),
+ mockGetSession: vi.fn(),
+ mockPrefetchWorkspaceHostContext: vi.fn(),
+ mockPrefetchWorkspaceSidebar: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ ToastProvider: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ dehydrate: vi.fn(() => ({})),
+ HydrationBoundary: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(async () => ({ get: vi.fn(() => undefined) })),
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/app/_shell/providers/get-query-client', () => ({
+ getQueryClient: () => ({ setQueryData: vi.fn() }),
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({
+ prefetchWorkspaceHostContext: mockPrefetchWorkspaceHostContext,
+ prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar,
+}))
+
+vi.mock('@/ee/whitelabeling/org-branding', () => ({
+ getOrgWhitelabelSettings: mockGetOrgWhitelabelSettings,
+}))
+
+vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({
+ BrandingProvider: mockBrandingProvider,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/components/impersonation-banner', () => ({
+ ImpersonationBanner: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({
+ WorkspaceChrome: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({
+ WorkspaceAccessDenied: () => Workspace access denied
,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/custom-blocks-loader', () => ({
+ CustomBlocksLoader: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({
+ GlobalCommandsProvider: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/provider-models-loader', () => ({
+ ProviderModelsLoader: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/settings-loader', () => ({
+ SettingsLoader: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ WorkspaceHostProvider: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ WorkspacePermissionsProvider: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-scope-sync', () => ({
+ WorkspaceScopeSync: () => null,
+}))
+
+import WorkspaceLayout from '@/app/workspace/[workspaceId]/layout'
+
+const HOST_CONTEXT = {
+ workspace: {
+ id: 'workspace-b',
+ name: 'Workspace B',
+ workspaceMode: 'organization',
+ billedAccountUserId: 'owner-b',
+ },
+ hostOrganizationId: 'org-b',
+ ownerBilling: {
+ plan: 'enterprise',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: false,
+ isEnterprise: true,
+ isOrgScoped: true,
+ organizationId: 'org-b',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ },
+ viewer: {
+ permission: 'read',
+ isHostOrganizationMember: false,
+ isHostOrganizationAdmin: false,
+ },
+} as const
+
+describe('WorkspaceLayout host context', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({
+ user: { id: 'viewer-1' },
+ session: { activeOrganizationId: 'org-a' },
+ })
+ mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT)
+ mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined)
+ mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' })
+ })
+
+ it('hydrates branding from routed workspace B instead of viewer organization A', async () => {
+ const element = await WorkspaceLayout({
+ children: Workspace child
,
+ params: Promise.resolve({ workspaceId: 'workspace-b' }),
+ })
+ renderToStaticMarkup(element)
+
+ expect(mockGetOrgWhitelabelSettings).toHaveBeenCalledWith('org-b')
+ expect(mockGetOrgWhitelabelSettings).not.toHaveBeenCalledWith('org-a')
+ expect(mockPrefetchWorkspaceSidebar).toHaveBeenCalledWith(
+ expect.anything(),
+ 'workspace-b',
+ 'viewer-1',
+ HOST_CONTEXT
+ )
+ expect(mockBrandingProvider).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hostOrganizationId: 'org-b',
+ viewerIsHostOrganizationMember: false,
+ initialOrgSettings: { brandName: 'Host B' },
+ }),
+ undefined
+ )
+ })
+
+ it('renders an explicit denial without loading workspace data or branding', async () => {
+ mockPrefetchWorkspaceHostContext.mockResolvedValue(null)
+
+ const element = await WorkspaceLayout({
+ children: Secret workspace child
,
+ params: Promise.resolve({ workspaceId: 'workspace-denied' }),
+ })
+ const html = renderToStaticMarkup(element)
+
+ expect(html).toContain('Workspace access denied')
+ expect(html).not.toContain('Secret workspace child')
+ expect(mockPrefetchWorkspaceSidebar).not.toHaveBeenCalled()
+ expect(mockGetOrgWhitelabelSettings).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index 4fdea0c52d6..3be8f413339 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -5,12 +5,17 @@ import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
+import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
-import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
+import {
+ prefetchWorkspaceHostContext,
+ prefetchWorkspaceSidebar,
+} from '@/app/workspace/[workspaceId]/prefetch'
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
+import { WorkspaceHostProvider } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
@@ -29,36 +34,47 @@ export default async function WorkspaceLayout({
}
const { workspaceId } = await params
- const initialSidebarCollapsed = (await cookies()).get('sidebar_collapsed')?.value === '1'
const queryClient = getQueryClient()
- const sidebarPrefetch = prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id)
-
- // The organization plugin is conditionally spread so TS can't infer activeOrganizationId on the base session type.
- const orgId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId
- const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null
+ const hostContext = await prefetchWorkspaceHostContext(queryClient, workspaceId, session.user.id)
+ if (!hostContext) {
+ return
+ }
- await sidebarPrefetch
+ const [cookieStore, initialOrgSettings] = await Promise.all([
+ cookies(),
+ hostContext.hostOrganizationId
+ ? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
+ : Promise.resolve(null),
+ prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id, hostContext),
+ ])
+ const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
return (
-
-
-
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
index 729d2dc0ad8..527905b9c22 100644
--- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
@@ -1,11 +1,10 @@
import type { QueryClient } from '@tanstack/react-query'
+import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { listFoldersForWorkspace } from '@/lib/folders/queries'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
-import {
- checkWorkspaceAccess,
- getWorkspacePermissionsForViewer,
-} from '@/lib/workspaces/permissions/utils'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
+import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils'
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
import {
MOTHERSHIP_CHAT_LIST_STALE_TIME,
@@ -16,15 +15,25 @@ import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
+import {
+ WORKSPACE_HOST_CONTEXT_STALE_TIME,
+ workspaceHostKeys,
+} from '@/hooks/queries/workspace-host'
-/** Resolves whether the user may access the workspace, swallowing errors to a `false`. */
-async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise {
- try {
- const access = await checkWorkspaceAccess(workspaceId, userId)
- return access.exists && access.hasAccess
- } catch {
- return false
- }
+/**
+ * Resolves and caches the route-derived host context before any workspace UI or
+ * host branding renders. A `null` result is an explicit access denial.
+ */
+export function prefetchWorkspaceHostContext(
+ queryClient: QueryClient,
+ workspaceId: string,
+ userId: string
+): Promise {
+ return queryClient.fetchQuery({
+ queryKey: workspaceHostKeys.detail(workspaceId),
+ queryFn: () => getWorkspaceHostContextForViewer(workspaceId, userId),
+ staleTime: WORKSPACE_HOST_CONTEXT_STALE_TIME,
+ })
}
/**
@@ -35,15 +44,17 @@ async function userCanAccessWorkspace(workspaceId: string, userId: string): Prom
* the data layer directly — the same functions the API routes use — with no internal
* HTTP hop.
*
- * Skips silently when the user can't access the workspace, leaving the client to
- * fetch and surface the real error instead of caching an empty list.
+ * The host context is the authorization proof for this server-render pass, so
+ * permission prefetch can reuse its effective permission without repeating
+ * workspace and membership reads.
*/
export async function prefetchWorkspaceSidebar(
queryClient: QueryClient,
workspaceId: string,
- userId: string
+ userId: string,
+ hostContext: WorkspaceHostContext
): Promise {
- if (!(await userCanAccessWorkspace(workspaceId, userId))) return
+ if (hostContext.workspace.id !== workspaceId) return
await Promise.all([
queryClient.prefetchQuery({
queryKey: workflowKeys.list(workspaceId, 'active'),
@@ -71,11 +82,12 @@ export async function prefetchWorkspaceSidebar(
}),
queryClient.prefetchQuery({
queryKey: workspaceKeys.permissions(workspaceId),
- queryFn: async () => {
- const result = await getWorkspacePermissionsForViewer(workspaceId, userId)
- if (!result) throw new Error('Workspace not found or access denied')
- return result
- },
+ queryFn: () =>
+ getWorkspacePermissionsForAuthorizedViewer(
+ workspaceId,
+ userId,
+ hostContext.viewer.permission
+ ),
staleTime: WORKSPACE_PERMISSIONS_STALE_TIME,
}),
])
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.test.tsx b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.test.tsx
new file mode 100644
index 00000000000..3715c6f70cf
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.test.tsx
@@ -0,0 +1,102 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockBuildCustomBlockConfig,
+ mockGetCustomBlockIcon,
+ mockHydrateClientCustomBlocks,
+ mockUseCustomBlocks,
+ mockUseOrgBrandConfig,
+} = vi.hoisted(() => ({
+ mockBuildCustomBlockConfig: vi.fn(),
+ mockGetCustomBlockIcon: vi.fn(),
+ mockHydrateClientCustomBlocks: vi.fn(),
+ mockUseCustomBlocks: vi.fn(),
+ mockUseOrgBrandConfig: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-b' }),
+}))
+
+vi.mock('@/blocks/custom/build-config', () => ({
+ buildCustomBlockConfig: mockBuildCustomBlockConfig,
+}))
+
+vi.mock('@/blocks/custom/client-overlay', () => ({
+ hydrateClientCustomBlocks: mockHydrateClientCustomBlocks,
+}))
+
+vi.mock('@/blocks/custom/custom-block-icon', () => ({
+ getCustomBlockIcon: mockGetCustomBlockIcon,
+}))
+
+vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({
+ useOrgBrandConfig: mockUseOrgBrandConfig,
+}))
+
+vi.mock('@/hooks/queries/custom-blocks', () => ({
+ useCustomBlocks: mockUseCustomBlocks,
+}))
+
+import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
+
+let container: HTMLDivElement
+let root: Root
+
+describe('CustomBlocksLoader', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockUseOrgBrandConfig.mockReturnValue({
+ logoUrl: 'https://host-b.example/logo.png',
+ })
+ mockUseCustomBlocks.mockReturnValue({
+ data: [
+ {
+ id: 'custom-block-1',
+ type: 'custom_host_block',
+ name: 'Host block',
+ description: 'A host-branded block',
+ workflowId: 'workflow-1',
+ exposedOutputs: [],
+ inputFields: [],
+ iconUrl: null,
+ enabled: true,
+ organizationId: 'org-b',
+ },
+ ],
+ })
+ mockGetCustomBlockIcon.mockReturnValue(() => null)
+ mockBuildCustomBlockConfig.mockReturnValue({ type: 'custom_host_block' })
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('hydrates no-icon blocks with the access-authorized host logo', () => {
+ act(() => {
+ root.render( )
+ })
+
+ expect(mockUseCustomBlocks).toHaveBeenCalledWith('workspace-b')
+ expect(mockGetCustomBlockIcon).toHaveBeenCalledWith(null, 'https://host-b.example/logo.png')
+ expect(mockBuildCustomBlockConfig).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'custom_host_block' }),
+ [],
+ expect.objectContaining({
+ bgColor: 'transparent',
+ hideFromToolbar: false,
+ })
+ )
+ expect(mockHydrateClientCustomBlocks).toHaveBeenCalledWith([{ type: 'custom_host_block' }])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx
index 8bc8f1ed2ae..48df9f694b3 100644
--- a/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx
@@ -5,7 +5,7 @@ import { useParams } from 'next/navigation'
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay'
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
-import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
+import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
/**
@@ -20,10 +20,8 @@ export function CustomBlocksLoader() {
const workspaceId = params?.workspaceId as string | undefined
const { data } = useCustomBlocks(workspaceId)
- // Blocks with no uploaded icon fall back to the org's whitelabel logo, then the
- // default glyph. All blocks share the org, so read it off the first row.
- const { data: whitelabel } = useWhitelabelSettings(data?.[0]?.organizationId)
- const fallbackIconUrl = whitelabel?.logoUrl ?? null
+ /** No-icon blocks use the access-authorized workspace host logo, then the default glyph. */
+ const fallbackIconUrl = useOrgBrandConfig().logoUrl ?? null
useEffect(() => {
hydrateClientCustomBlocks(
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx
new file mode 100644
index 00000000000..9aa14b79562
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import { createContext, type ReactNode, useContext } from 'react'
+import { isApiClientError } from '@/lib/api/client/errors'
+import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
+import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
+import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host'
+
+const WorkspaceHostContextValue = createContext(null)
+
+interface WorkspaceHostProviderProps {
+ children: ReactNode
+ workspaceId: string
+ initialContext: WorkspaceHostContext
+}
+
+/**
+ * Provides route-derived workspace host identity and entitlements to workspace
+ * UI. A later 403 (for example after access is revoked) replaces the workspace
+ * tree with an explicit denial instead of navigating to another workspace.
+ */
+export function WorkspaceHostProvider({
+ children,
+ workspaceId,
+ initialContext,
+}: WorkspaceHostProviderProps) {
+ const { data, error } = useWorkspaceHostContextQuery(workspaceId)
+
+ if (isApiClientError(error) && error.status === 403) {
+ return
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useWorkspaceHostContext(): WorkspaceHostContext {
+ const context = useContext(WorkspaceHostContextValue)
+ if (!context) {
+ throw new Error('useWorkspaceHostContext must be used within a WorkspaceHostProvider')
+ }
+ return context
+}
+
+/** Returns route-derived host context when called inside a workspace route. */
+export function useOptionalWorkspaceHostContext(): WorkspaceHostContext | null {
+ return useContext(WorkspaceHostContextValue)
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
index f9022cfb0b2..a4bd462c08a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
@@ -1,76 +1,97 @@
-import { Suspense } from 'react'
-import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
-import { redirect } from 'next/navigation'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
-import { getQueryClient } from '@/app/_shell/providers/get-query-client'
-import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
-import { prefetchGeneralSettings, prefetchSubscriptionData, prefetchUserProfile } from './prefetch'
-import { SettingsPage } from './settings'
+import { notFound, redirect } from 'next/navigation'
+import {
+ getLegacyTopLevelWorkspaceHref,
+ getSettingsSectionMeta,
+ LEGACY_SETTINGS_SECTIONS,
+ parseSettingsPathSection,
+ resolveLegacySettingsHref,
+ resolveWorkspaceNavigation,
+ WORKSPACE_SETTINGS_ITEMS,
+ WORKSPACE_SETTINGS_PATH_ALIASES,
+} from '@/components/settings/navigation'
+import { WorkspaceSettingsRenderer } from '@/components/settings/workspace-settings-renderer'
+import { getSession } from '@/lib/auth'
+import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
+import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isHosted } from '@/lib/core/config/env-flags'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
+import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check'
+import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz'
-/**
- * Legacy settings sections that moved to top-level workspace routes.
- * Old bookmarks and emails deep-link here; without the redirect the
- * section renderer has no matching branch and shows an empty panel.
- */
-const SETTINGS_REDIRECTS: Record string> = {
- integrations: (id) => `/workspace/${id}/integrations`,
- skills: (id) => `/workspace/${id}/skills`,
+interface WorkspaceSettingsSectionPageProps {
+ params: Promise<{ workspaceId: string; section: string }>
}
-const SECTION_TITLES: Record = {
- general: 'General',
- secrets: 'Secrets',
- 'access-control': 'Access Control',
- 'audit-logs': 'Audit Logs',
- apikeys: 'Sim API Keys',
- byok: 'BYOK',
- subscription: 'Subscription',
- billing: 'Billing',
- teammates: 'Teammates',
- team: 'Team',
- sso: 'Single Sign-On',
- whitelabeling: 'Whitelabeling',
- copilot: 'Chat Keys',
- forks: 'Workspace Forks',
- mcp: 'MCP Tools',
- 'custom-tools': 'Custom Tools',
- 'workflow-mcp-servers': 'MCP Servers',
- 'data-retention': 'Data Retention',
- 'recently-deleted': 'Recently Deleted',
- debug: 'Debug',
-} as const
-
export async function generateMetadata({
params,
-}: {
- params: Promise<{ section: string }>
-}): Promise {
+}: WorkspaceSettingsSectionPageProps): Promise {
const { section } = await params
- return { title: SECTION_TITLES[section] ?? 'Settings' }
+ const parsed = parseSettingsPathSection({
+ path: section,
+ items: WORKSPACE_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: WORKSPACE_SETTINGS_PATH_ALIASES,
+ })
+ const meta = parsed ? getSettingsSectionMeta('workspace', parsed) : null
+ return { title: meta ? `${meta.label} - Workspace settings` : 'Settings' }
}
-export default async function SettingsSectionPage({
+export default async function WorkspaceSettingsSectionPage({
params,
-}: {
- params: Promise<{ workspaceId: string; section: string }>
-}) {
+}: WorkspaceSettingsSectionPageProps) {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+
const { workspaceId, section } = await params
+ const topLevelHref = getLegacyTopLevelWorkspaceHref(workspaceId, section)
+ if (topLevelHref) redirect(topLevelHref)
+
+ const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
+ if (!hostContext) notFound()
+
+ const legacy = LEGACY_SETTINGS_SECTIONS.find((item) => item.legacySection === section)
+ if (legacy && (legacy.plane !== 'workspace' || legacy.section !== section)) {
+ redirect(
+ resolveLegacySettingsHref({
+ legacySection: section,
+ workspaceId,
+ hostOrganizationId: hostContext.hostOrganizationId,
+ isTargetOrganizationMember: hostContext.viewer.isHostOrganizationMember,
+ })
+ )
+ }
- const redirectTo = SETTINGS_REDIRECTS[section]
- if (redirectTo) redirect(redirectTo(workspaceId))
+ const parsed = parseSettingsPathSection({
+ path: section,
+ items: WORKSPACE_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: WORKSPACE_SETTINGS_PATH_ALIASES,
+ })
+ if (!parsed) notFound()
- const queryClient = getQueryClient()
+ const [permissionGroup, forksAvailable, inboxAvailable] = await Promise.all([
+ hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
+ ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
+ : null,
+ isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id),
+ hasWorkspaceInboxAccess(workspaceId),
+ ])
+ const customBlocksAvailable = isHosted
+ ? hostContext.ownerBilling.isEnterprise
+ : isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED'))
- void prefetchGeneralSettings(queryClient)
- void prefetchUserProfile(queryClient)
- if (isBillingEnabled) void prefetchSubscriptionData(queryClient)
+ const navigation = resolveWorkspaceNavigation({
+ permission: hostContext.viewer.permission,
+ permissionConfig: permissionGroup?.config ?? {},
+ entitlements: {
+ byok: isHosted,
+ inbox: inboxAvailable,
+ customBlocks: customBlocksAvailable,
+ forks: forksAvailable,
+ },
+ })
+ if (!navigation.some((item) => item.id === parsed)) notFound()
- return (
-
-
-
-
-
- )
+ return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
deleted file mode 100644
index 059690a037b..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import type { QueryClient } from '@tanstack/react-query'
-import { headers } from 'next/headers'
-import { getSession } from '@/lib/auth'
-import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
-import { getUserProfile, getUserSettings } from '@/lib/users/queries'
-import {
- GENERAL_SETTINGS_STALE_TIME,
- generalSettingsKeys,
- mapGeneralSettingsResponse,
-} from '@/hooks/queries/general-settings'
-import { SUBSCRIPTION_DATA_STALE_TIME, subscriptionKeys } from '@/hooks/queries/subscription'
-import {
- mapUserProfileResponse,
- USER_PROFILE_STALE_TIME,
- userProfileKeys,
-} from '@/hooks/queries/user-profile'
-
-/**
- * Prefetch general settings server-side via the shared data layer.
- * Uses the same query keys as the client `useGeneralSettings` hook
- * so data is shared via HydrationBoundary.
- */
-export function prefetchGeneralSettings(queryClient: QueryClient) {
- return queryClient.prefetchQuery({
- queryKey: generalSettingsKeys.settings(),
- queryFn: async () => {
- const session = await getSession()
- const data = await getUserSettings(session?.user?.id ?? null)
- return mapGeneralSettingsResponse(data)
- },
- staleTime: GENERAL_SETTINGS_STALE_TIME,
- })
-}
-
-/**
- * Prefetch subscription data server-side. Unlike the other prefetches this goes
- * through the internal billing API rather than calling the data layer directly:
- * the billing summary contains `Date` fields (and an untyped `metadata` blob) that
- * `NextResponse.json` serializes to the string wire shape the client caches. Going
- * through the route yields that exact shape, avoiding a Date-vs-string mismatch
- * between server-hydrated and client-fetched data. Uses the same query key as the
- * client `useSubscriptionData` hook (with includeOrg=false) so data is shared via
- * HydrationBoundary.
- */
-export function prefetchSubscriptionData(queryClient: QueryClient) {
- return queryClient.prefetchQuery({
- queryKey: subscriptionKeys.user(false),
- queryFn: async () => {
- const h = await headers()
- const cookie = h.get('cookie')
- const response = await fetch(`${getInternalApiBaseUrl()}/api/billing?context=user`, {
- headers: cookie ? { cookie } : {},
- })
- if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`)
- return response.json()
- },
- staleTime: SUBSCRIPTION_DATA_STALE_TIME,
- })
-}
-
-/**
- * Prefetch user profile server-side via the shared data layer.
- * Uses the same query keys as the client `useUserProfile` hook
- * so data is shared via HydrationBoundary.
- */
-export function prefetchUserProfile(queryClient: QueryClient) {
- return queryClient.prefetchQuery({
- queryKey: userProfileKeys.profile(),
- queryFn: async () => {
- const session = await getSession()
- if (!session?.user?.id) throw new Error('Unauthorized')
- const user = await getUserProfile(session.user.id)
- if (!user) throw new Error('User not found')
- return mapUserProfileResponse(user)
- },
- staleTime: USER_PROFILE_STALE_TIME,
- })
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
deleted file mode 100644
index 193c9646313..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-'use client'
-
-import { useEffect } from 'react'
-import dynamic from 'next/dynamic'
-import { usePostHog } from 'posthog-js/react'
-import { useSession } from '@/lib/auth/auth-client'
-import { captureEvent } from '@/lib/posthog/client'
-import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
-import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
-import { useSettingsBeforeUnload } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload'
-import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
-
-const Admin = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin)
-)
-const ApiKeys = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
- (m) => m.ApiKeys
- )
-)
-const BYOK = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK)
-)
-const Copilot = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then((m) => m.Copilot)
-)
-const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks))
-const Secrets = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets)
-)
-const CustomTools = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then(
- (m) => m.CustomTools
- )
-)
-const Inbox = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox)
-)
-const MCP = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP)
-)
-const Mothership = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then(
- (m) => m.Mothership
- )
-)
-const RecentlyDeleted = dynamic(() =>
- import(
- '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
- ).then((m) => m.RecentlyDeleted)
-)
-const Billing = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
-)
-const Teammates = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then(
- (m) => m.Teammates
- )
-)
-const TeamManagement = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
- (m) => m.TeamManagement
- )
-)
-const WorkflowMcpServers = dynamic(() =>
- import(
- '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers'
- ).then((m) => m.WorkflowMcpServers)
-)
-const AccessControl = dynamic(() =>
- import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
-)
-const CustomBlocks = dynamic(() =>
- import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
-)
-const AuditLogs = dynamic(() =>
- import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
-)
-const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
-const DataRetentionSettings = dynamic(() =>
- import('@/ee/data-retention/components/data-retention-settings').then(
- (m) => m.DataRetentionSettings
- )
-)
-const DataDrainsSettings = dynamic(() =>
- import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
-)
-const WhitelabelingSettings = dynamic(
- () =>
- import('@/ee/whitelabeling/components/whitelabeling-settings').then(
- (m) => m.WhitelabelingSettings
- ),
- { ssr: false }
-)
-
-interface SettingsPageProps {
- section: SettingsSection
-}
-
-export function SettingsPage({ section }: SettingsPageProps) {
- const { data: session, isPending: sessionLoading } = useSession()
- const posthog = usePostHog()
-
- useSettingsBeforeUnload()
-
- const isAdminRole = session?.user?.role === 'admin'
- const normalizedSection: SettingsSection =
- (section as string) === 'subscription' ? 'billing' : section
- const effectiveSection =
- !isBillingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization')
- ? 'general'
- : normalizedSection === 'admin' && !sessionLoading && !isAdminRole
- ? 'general'
- : normalizedSection === 'mothership' && !sessionLoading && !isAdminRole
- ? 'general'
- : normalizedSection
-
- useEffect(() => {
- if (sessionLoading) return
- captureEvent(posthog, 'settings_tab_viewed', { section: effectiveSection })
- }, [effectiveSection, sessionLoading, posthog])
-
- return (
-
- {effectiveSection === 'general' && }
- {effectiveSection === 'secrets' && }
- {effectiveSection === 'access-control' && }
- {effectiveSection === 'custom-blocks' && }
- {effectiveSection === 'audit-logs' && }
- {effectiveSection === 'apikeys' && }
- {isBillingEnabled && effectiveSection === 'billing' && }
- {effectiveSection === 'teammates' && }
- {isBillingEnabled && effectiveSection === 'organization' && }
- {effectiveSection === 'sso' && }
- {effectiveSection === 'data-retention' && }
- {effectiveSection === 'data-drains' && }
- {effectiveSection === 'whitelabeling' && }
- {effectiveSection === 'byok' && }
- {effectiveSection === 'copilot' && }
- {effectiveSection === 'mcp' && }
- {effectiveSection === 'forks' && }
- {effectiveSection === 'custom-tools' && }
- {effectiveSection === 'workflow-mcp-servers' && }
- {effectiveSection === 'inbox' && }
- {effectiveSection === 'recently-deleted' && }
- {effectiveSection === 'admin' && }
- {effectiveSection === 'mothership' && }
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx
index e44851bf789..a23bb63c0d7 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx
@@ -3,9 +3,7 @@
import { useState } from 'react'
import {
Calendar,
- Chip,
ChipCombobox,
- ChipLink,
type ComboboxOption,
chipVariants,
cn,
@@ -16,17 +14,18 @@ import {
} from '@sim/emcn'
import { ArrowLeft, Download } from '@sim/emcn/icons'
import { formatDateTime } from '@sim/utils/formatting'
+import { useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import type { UsageLogEntry, UsageLogPeriod } from '@/lib/api/contracts/user'
import { formatApportionedCreditCost, formatCreditsLabel } from '@/lib/billing/credits/conversion'
import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels'
-import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils'
import {
creditUsageParsers,
creditUsageUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useUsageLogs } from '@/hooks/queries/usage-logs'
const PERIOD_OPTIONS: ComboboxOption[] = [
@@ -63,13 +62,8 @@ function UsageLogRow({ log }: UsageLogRowProps) {
)
}
-interface CreditUsageViewProps {
- workspaceId: string
-}
-
-export function CreditUsageView({ workspaceId }: CreditUsageViewProps) {
- const billingHref = `/workspace/${workspaceId}/settings/billing`
-
+export function CreditUsageView() {
+ const router = useRouter()
const [{ period, startDate, endDate }, setFilters] = useQueryStates(
creditUsageParsers,
creditUsageUrlKeys
@@ -151,29 +145,23 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) {
const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0
return (
-
- Billing
-
- }
- actions={
- void handleExport()}
- disabled={logs.length === 0 || isPlaceholderData}
- >
- Export
-
- }
+ router.push('/account/settings/billing'),
+ }}
+ actions={[
+ {
+ text: 'Export',
+ icon: Download,
+ onSelect: () => void handleExport(),
+ disabled: logs.length === 0 || isPlaceholderData,
+ },
+ ]}
+ title='Credit usage'
+ description='Every credit-consuming event behind your usage.'
>
-
-
Credit usage
-
- Every credit-consuming event behind your usage.
-
-
-
Total: {formatCreditsLabel(totalCredits)}
@@ -242,6 +230,6 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) {
>
)}
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx
index 6f8fe66f7b6..9241dd618b4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx
@@ -1,8 +1,8 @@
'use client'
-import { Chip } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
-import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail'
+import { useRouter } from 'next/navigation'
+import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
/**
* Route-level loading fallback (Next.js convention) and the `Suspense`
@@ -11,20 +11,17 @@ import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components
* here means a suspend never flashes a blank frame.
*/
export default function CreditUsageLoading() {
+ const router = useRouter()
+
return (
-
- Billing
-
- }
- >
-
-
Credit usage
-
- Every credit-consuming event behind your usage.
-
-
-
+ router.push('/account/settings/billing'),
+ }}
+ title='Credit usage'
+ description='Every credit-consuming event behind your usage.'
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx
index 367f1cdbb7e..b05098a041e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx
@@ -1,46 +1,12 @@
-import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
-import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
-import { isEnterprise } from '@/lib/billing/plan-helpers'
-import { CreditUsageView } from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view'
-import CreditUsageLoading from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading'
+import { getAccountSettingsHref } from '@/components/settings/navigation'
export const metadata: Metadata = {
title: 'Credit usage',
}
-/**
- * `CreditUsageView` reads URL query params via nuqs (which uses
- * `useSearchParams` internally), so it must sit under a Suspense boundary.
- * The fallback renders the real chrome so a suspend never shows a blank
- * frame — `loading.tsx` covers the route-navigation transition the same way.
- *
- * Enterprise accounts manage billing out-of-band and never see this page —
- * Billing settings already hides the link to it, but hiding a link doesn't
- * stop direct navigation, so this redirects server-side before anything
- * renders (matching `getHighestPrioritySubscription`'s use elsewhere for
- * server-side plan checks).
- */
-export default async function CreditUsagePage({
- params,
-}: {
- params: Promise<{ workspaceId: string }>
-}) {
- const { workspaceId } = await params
-
- const session = await getSession()
- if (session?.user?.id) {
- const subscription = await getHighestPrioritySubscription(session.user.id)
- if (isEnterprise(subscription?.plan)) {
- redirect(`/workspace/${workspaceId}/settings/billing`)
- }
- }
-
- return (
- }>
-
-
- )
+/** Preserves the legacy workspace-scoped URL after billing moved to account settings. */
+export default function CreditUsagePage() {
+ redirect(`${getAccountSettingsHref('billing')}/credit-usage`)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
index 95e1faadb0b..a46263c1ebb 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
@@ -3,7 +3,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
-import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import type { MothershipEnvironment } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
@@ -33,8 +32,6 @@ const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[]
]
export function Admin() {
- const params = useParams()
- const workspaceId = params?.workspaceId as string
const { data: session } = useSession()
const { data: settings } = useGeneralSettings()
@@ -47,6 +44,7 @@ export function Admin() {
const impersonateUser = useImpersonateUser()
const [workflowId, setWorkflowId] = useState('')
+ const [targetWorkspaceId, setTargetWorkspaceId] = useState('')
const [{ q: searchQuery, offset: usersOffset }, setAdminParams] = useQueryStates(
adminParsers,
@@ -95,14 +93,6 @@ export function Admin() {
}
}
- const handleImport = () => {
- if (!workflowId.trim()) return
- importWorkflow.mutate(
- { workflowId: workflowId.trim(), targetWorkspaceId: workspaceId },
- { onSuccess: () => setWorkflowId('') }
- )
- }
-
const handleImpersonate = (userId: string) => {
setImpersonationGuardError(null)
if (session?.user?.role !== 'admin') {
@@ -126,6 +116,21 @@ export function Admin() {
)
}
+ const handleImport = () => {
+ const sourceId = workflowId.trim()
+ const targetId = targetWorkspaceId.trim()
+ if (!sourceId || !targetId) return
+ importWorkflow.mutate(
+ { workflowId: sourceId, targetWorkspaceId: targetId },
+ {
+ onSuccess: () => {
+ setWorkflowId('')
+ setTargetWorkspaceId('')
+ },
+ }
+ )
+ }
+
const pendingUserIds = useMemo(() => {
const ids = new Set()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -191,22 +196,31 @@ export function Admin() {
- Import a workflow by ID along with its associated copilot chats.
+ Import a workflow and its copilot chats into a target workspace.
{
- setWorkflowId(e.target.value)
+ onChange={(event) => {
+ setWorkflowId(event.target.value)
+ importWorkflow.reset()
+ }}
+ placeholder='Source workflow ID'
+ disabled={importWorkflow.isPending}
+ />
+ {
+ setTargetWorkspaceId(event.target.value)
importWorkflow.reset()
}}
- placeholder='Enter workflow ID'
+ placeholder='Target workspace ID'
disabled={importWorkflow.isPending}
/>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
index 95c1ddb931b..5142845f033 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Info, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
@@ -15,6 +16,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
type ApiKey,
+ type ApiKeyScope,
useApiKeys,
useDeleteApiKey,
useUpdateWorkspaceApiKeySettings,
@@ -65,19 +67,25 @@ function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuPro
)
}
-export function ApiKeys() {
+interface ApiKeysProps {
+ scope?: Extract
+}
+
+export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
const { data: session } = useSession()
const userId = session?.user?.id
- const params = useParams()
+ const params = useParams<{ workspaceId?: string }>()
const workspaceId = (params?.workspaceId as string) || ''
- const userPermissions = useUserPermissionsContext()
- const canManageWorkspaceKeys = userPermissions.canAdmin
+ const workspacePermissions = useUserPermissionsContext()
+ const isWorkspaceScope = scope === 'workspace'
+ const isPersonalScope = scope === 'personal'
+ const canManageWorkspaceKeys = canMutateWorkspaceSettingsSection('api-keys', workspacePermissions)
const {
data: apiKeysData,
isLoading: isLoadingKeys,
refetch: refetchApiKeys,
- } = useApiKeys(workspaceId)
+ } = useApiKeys(workspaceId, scope)
const { data: workspaceSettingsData, isLoading: isLoadingSettings } =
useWorkspaceSettings(workspaceId)
const deleteApiKeyMutation = useDeleteApiKey()
@@ -86,7 +94,7 @@ export function ApiKeys() {
const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS
const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS
const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES
- const isLoading = isLoadingKeys || isLoadingSettings
+ const isLoading = isLoadingKeys || (isWorkspaceScope && isLoadingSettings)
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
@@ -96,8 +104,8 @@ export function ApiKeys() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
- const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
- const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
+ const defaultKeyType = isPersonalScope ? 'personal' : 'workspace'
+ const createButtonDisabled = isLoading || (isWorkspaceScope && !canManageWorkspaceKeys)
const filteredWorkspaceKeys = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
@@ -127,16 +135,13 @@ export function ApiKeys() {
if (!userId || !deleteKey) return
try {
- const isWorkspaceKey = workspaceKeys.some((k) => k.id === deleteKey.id)
- const keyTypeToDelete = isWorkspaceKey ? 'workspace' : 'personal'
-
setShowDeleteDialog(false)
setDeleteKey(null)
await deleteApiKeyMutation.mutateAsync({
workspaceId,
keyId: deleteKey.id,
- keyType: keyTypeToDelete,
+ keyType: scope,
})
} catch (error) {
logger.error('Error deleting API key:', { error })
@@ -171,7 +176,7 @@ export function ApiKeys() {
Click "Create API key" above to get started
) : (
- {!searchTerm.trim() ? (
+ {isWorkspaceScope && !searchTerm.trim() ? (
{workspaceKeys.length === 0 ? (
No workspace API keys yet
@@ -189,7 +194,7 @@ export function ApiKeys() {
- {key.displayKey || key.key}
+ {key.displayKey}
)}
- ) : filteredWorkspaceKeys.length > 0 ? (
+ ) : isWorkspaceScope && filteredWorkspaceKeys.length > 0 ? (
{filteredWorkspaceKeys.map(({ key }) => (
@@ -220,7 +225,7 @@ export function ApiKeys() {
- {key.displayKey || key.key}
+ {key.displayKey}
) : null}
- {(!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
+ {isPersonalScope && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
{filteredPersonalKeys.map(({ key }) => {
@@ -255,7 +260,7 @@ export function ApiKeys() {
- {key.displayKey || key.key}
+ {key.displayKey}
)}
- {!isLoading && canManageWorkspaceKeys && (
+ {isWorkspaceScope && !isLoading && canManageWorkspaceKeys && (
@@ -306,8 +311,9 @@ export function ApiKeys() {
- Allow collaborators to create and use their own keys with billing charged to
- them.
+ Allow collaborators to authenticate with their own keys. Hosted usage is
+ billed to this workspace, attributed to the key owner, and counted toward
+ their member cap.
@@ -338,7 +344,7 @@ export function ApiKeys() {
onOpenChange={setIsCreateDialogOpen}
workspaceId={workspaceId}
existingKeyNames={[...workspaceKeys, ...personalKeys].map((k) => k.name)}
- allowPersonalApiKeys={allowPersonalApiKeys}
+ allowPersonalApiKeys={isPersonalScope}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
index bd85229e898..789bd75ec1d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
@@ -14,7 +14,7 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
+import { type CreatedApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
const logger = createLogger('CreateApiKeyModal')
@@ -27,7 +27,7 @@ interface CreateApiKeyModalProps {
canManageWorkspaceKeys?: boolean
defaultKeyType?: 'personal' | 'workspace'
source?: 'settings' | 'deploy_modal'
- onKeyCreated?: (key: ApiKey) => void
+ onKeyCreated?: (key: CreatedApiKey) => void
}
const EMPTY_KEY_NAMES: string[] = []
@@ -50,7 +50,7 @@ export function CreateApiKeyModal({
const [keyName, setKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>(defaultKeyType)
const [createError, setCreateError] = useState(null)
- const [newKey, setNewKey] = useState(null)
+ const [newKey, setNewKey] = useState(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const createApiKeyMutation = useCreateApiKey()
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx
new file mode 100644
index 00000000000..58c041482a2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx
@@ -0,0 +1,352 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockOrganizationQuery,
+ mockPersonalQuery,
+ mockUpdateOrganizationLimit,
+ mockUpdateUserLimit,
+ mockUseOrganizationBilling,
+ mockUseSubscriptionData,
+ mockUseUsageLimitData,
+} = vi.hoisted(() => ({
+ mockOrganizationQuery: { current: null as unknown },
+ mockPersonalQuery: { current: null as unknown },
+ mockUpdateOrganizationLimit: vi.fn(),
+ mockUpdateUserLimit: vi.fn(),
+ mockUseOrganizationBilling: vi.fn(),
+ mockUseSubscriptionData: vi.fn(),
+ mockUseUsageLimitData: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ ArrowRight: () => ,
+ Badge: ({ children }: { children: ReactNode }) => {children} ,
+ Chip: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: ReactNode
+ disabled?: boolean
+ onClick?: () => void
+ }) => (
+
+ {children}
+
+ ),
+ ChipLink: ({ children, href }: { children: ReactNode; href: string }) => (
+ {children}
+ ),
+ Credit: () => ,
+ Switch: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ }: {
+ checked: boolean
+ disabled?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ }) => (
+ onCheckedChange?.(!checked)}
+ />
+ ),
+ Tooltip: {
+ Root: ({ children }: { children: ReactNode }) => <>{children}>,
+ Trigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ Content: ({ children }: { children: ReactNode }) => <>{children}>,
+ },
+ chipVariants: () => '',
+ cn: (...values: Array) => values.filter(Boolean).join(' '),
+ toast: { error: vi.fn() },
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({}),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ prefetch: vi.fn() }),
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { id: 'viewer-a' } } }),
+ useSubscription: () => ({
+ cancel: vi.fn(),
+ restore: vi.fn(),
+ }),
+}))
+
+vi.mock('@/hooks/queries/general-settings', () => ({
+ useBillingUsageNotifications: () => false,
+ useUpdateGeneralSetting: () => ({ isPending: false, mutate: vi.fn() }),
+}))
+
+vi.mock('@/hooks/queries/organization', () => ({
+ useOrganizationBilling: (...args: unknown[]) => {
+ mockUseOrganizationBilling(...args)
+ return mockOrganizationQuery.current
+ },
+ useUpdateOrganizationUsageLimit: () => ({
+ isPending: false,
+ mutateAsync: mockUpdateOrganizationLimit,
+ }),
+}))
+
+vi.mock('@/hooks/queries/subscription', () => ({
+ useInvoices: () => ({ data: { invoices: [], hasMore: false } }),
+ useOpenBillingPortal: () => ({ isPending: false, mutate: vi.fn() }),
+ useSubscriptionData: (...args: unknown[]) => {
+ mockUseSubscriptionData(...args)
+ return mockPersonalQuery.current
+ },
+ useUpdateUsageLimit: () => ({ isPending: false, mutateAsync: mockUpdateUserLimit }),
+ useUsageLimitData: (...args: unknown[]) => {
+ mockUseUsageLimitData(...args)
+ return {
+ data: {
+ data: {
+ currentLimit: 999,
+ minimumLimit: 999,
+ },
+ },
+ isLoading: false,
+ }
+ },
+}))
+
+vi.mock('@/hooks/queries/workspace', () => ({
+ prefetchWorkspaceSettings: vi.fn(),
+}))
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section',
+ () => ({
+ CreditUsageSection: () => Personal credit usage
,
+ })
+)
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field',
+ () => ({
+ UsageLimitField: ({
+ context,
+ currentLimit,
+ organizationId,
+ }: {
+ context: string
+ currentLimit: number
+ organizationId?: string
+ }) => (
+
+ ),
+ })
+)
+
+vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
+ SettingsPanel: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section',
+ () => ({
+ SettingsSection: ({ children, label }: { children: ReactNode; label: string }) => (
+
+ ),
+ })
+)
+
+import { Billing } from '@/app/workspace/[workspaceId]/settings/components/billing/billing'
+
+const PERSONAL_DATA = {
+ plan: 'pro_6000',
+ status: 'active',
+ usageLimit: 30,
+ creditBalance: 500,
+ billingInterval: 'month',
+ periodEnd: '2026-08-01T00:00:00.000Z',
+ cancelAtPeriodEnd: false,
+ billingBlocked: false,
+ upgradeWorkspaceId: 'personal-workspace',
+ usage: {
+ current: 10,
+ limit: 30,
+ percentUsed: 33,
+ },
+}
+
+function organizationResponse(overrides: Record = {}): Record {
+ return {
+ success: true,
+ context: 'organization',
+ userRole: 'owner',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ data: {
+ organizationId: 'org-target',
+ organizationName: 'Target organization',
+ subscriptionState: 'active',
+ hasSubscription: true,
+ subscriptionPlan: 'team_25000',
+ subscriptionStatus: 'active',
+ creditBalance: 5,
+ billingInterval: 'year',
+ cancelAtPeriodEnd: true,
+ totalSeats: 3,
+ usedSeats: 2,
+ seatsCount: 3,
+ totalCurrentUsage: 100,
+ totalUsageLimit: 150,
+ minimumBillingAmount: 125,
+ averageUsagePerMember: 50,
+ billingPeriodStart: '2026-07-01T00:00:00.000Z',
+ billingPeriodEnd: '2026-08-01T00:00:00.000Z',
+ members: [],
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ upgradeWorkspaceId: 'organization-workspace',
+ ...overrides,
+ },
+ }
+}
+
+let container: HTMLDivElement
+let root: Root
+
+describe('Billing payer scope', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockPersonalQuery.current = {
+ data: { success: true, context: 'user', data: PERSONAL_DATA },
+ isLoading: false,
+ refetch: vi.fn(),
+ }
+ mockOrganizationQuery.current = {
+ data: organizationResponse(),
+ isLoading: false,
+ refetch: vi.fn(),
+ }
+ mockUpdateOrganizationLimit.mockResolvedValue(undefined)
+ mockUpdateUserLimit.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => {
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(mockUseSubscriptionData).toHaveBeenCalledWith(
+ expect.objectContaining({ enabled: false })
+ )
+ expect(mockUseUsageLimitData).not.toHaveBeenCalled()
+ expect(
+ container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
+ ).toBe('Explore organization plans')
+ expect(container.textContent).toContain('Organization Max for Teams plan')
+ expect(container.textContent).toContain('billed annually')
+ expect(container.textContent).toContain('Access until')
+ expect(container.textContent).toContain('Subscription canceled')
+ expect(container.textContent).toContain('Restore')
+ expect(container.querySelector('[data-testid="usage-limit"]')).toHaveAttribute(
+ 'data-organization-id',
+ 'org-target'
+ )
+
+ const onDemandSwitch = container.querySelector('[role="switch"]')
+ expect(onDemandSwitch).toHaveAttribute('aria-checked', 'true')
+ await act(async () => {
+ onDemandSwitch?.click()
+ })
+ expect(mockUpdateOrganizationLimit).toHaveBeenCalledWith({
+ organizationId: 'org-target',
+ limit: 130,
+ })
+ expect(mockUpdateUserLimit).not.toHaveBeenCalled()
+ })
+
+ it('uses a guaranteed personal payer workspace for account upgrades', async () => {
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(
+ container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent
+ ).toBe('Explore personal plans')
+ expect(container.textContent).toContain('Personal Pro plan')
+ })
+
+ it('renders an explicit free organization state without subscription controls', async () => {
+ mockOrganizationQuery.current = {
+ data: organizationResponse({
+ subscriptionState: 'free',
+ hasSubscription: false,
+ subscriptionPlan: 'free',
+ subscriptionStatus: null,
+ billingInterval: 'month',
+ cancelAtPeriodEnd: false,
+ billingPeriodStart: null,
+ billingPeriodEnd: null,
+ }),
+ isLoading: false,
+ refetch: vi.fn(),
+ }
+
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(container.textContent).toContain('Organization Free plan')
+ expect(container.textContent).toContain('No active organization subscription')
+ expect(container.textContent).not.toContain('Payment method')
+ })
+
+ it('renders lapsed organization plans as ended rather than active', async () => {
+ mockOrganizationQuery.current = {
+ data: organizationResponse({
+ subscriptionState: 'lapsed',
+ subscriptionStatus: 'canceled',
+ billingInterval: 'year',
+ cancelAtPeriodEnd: false,
+ billingPeriodStart: null,
+ billingPeriodEnd: '2026-06-01T00:00:00.000Z',
+ }),
+ isLoading: false,
+ refetch: vi.fn(),
+ }
+
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(container.textContent).toContain('Organization Max for Teams plan ended')
+ expect(container.textContent).toContain('Choose a new plan for this organization')
+ expect(container.textContent).not.toContain('Cancel subscription')
+ expect(
+ container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
+ ).toBe('Explore organization plans')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx
index c3c2230649a..8c365495bb2 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx
@@ -15,8 +15,7 @@ import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
-import { useQueryClient } from '@tanstack/react-query'
-import { useParams, useRouter } from 'next/navigation'
+import { useRouter } from 'next/navigation'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
@@ -54,18 +53,14 @@ import {
} from '@/hooks/queries/general-settings'
import {
useOrganizationBilling,
- useOrganizations,
useUpdateOrganizationUsageLimit,
} from '@/hooks/queries/organization'
import {
- prefetchUpgradeBillingData,
useInvoices,
useOpenBillingPortal,
useSubscriptionData,
useUpdateUsageLimit,
- useUsageLimitData,
} from '@/hooks/queries/subscription'
-import { prefetchWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
const logger = createLogger('Billing')
@@ -102,30 +97,30 @@ function formatInvoiceAmount(amountMinor: number, currency: string): string {
return getInvoiceAmountFormatter(currency).format(amountMinor / 100)
}
-export function Billing() {
- const { workspaceId } = useParams<{ workspaceId: string }>()
+interface BillingProps {
+ scope: 'account' | 'organization'
+ organizationId?: string
+}
+
+export function Billing({ scope, organizationId }: BillingProps) {
const router = useRouter()
- const queryClient = useQueryClient()
+ const isOrganizationScope = scope === 'organization'
const {
data: subscriptionData,
isLoading: isSubscriptionLoading,
refetch: refetchSubscription,
} = useSubscriptionData({
- includeOrg: true,
+ includeOrg: false,
+ enabled: !isOrganizationScope,
})
- const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
- const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
-
- const { data: orgsData } = useOrganizations()
- const activeOrgId = orgsData?.activeOrganization?.id
- const workspaceOrganizationId = workspaceData?.settings?.workspace?.organizationId ?? null
- const billingOrganizationId =
- workspaceOrganizationId ?? subscriptionData?.data?.organization?.id ?? activeOrgId ?? null
+ const billingOrganizationId = isOrganizationScope ? (organizationId ?? null) : null
- const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
- billingOrganizationId || ''
- )
+ const {
+ data: organizationBillingData,
+ isLoading: isOrgBillingLoading,
+ refetch: refetchOrganizationBilling,
+ } = useOrganizationBilling(billingOrganizationId || '', { enabled: isOrganizationScope })
const updateUserLimit = useUpdateUsageLimit()
const updateOrgLimit = useUpdateOrganizationUsageLimit()
@@ -137,60 +132,63 @@ export function Billing() {
const betterAuthSubscription = useSubscription()
const openBillingPortal = useOpenBillingPortal()
- const upgradeHref = buildUpgradeHref(workspaceId)
-
- /**
- * Warm the Upgrade route bundle and the exact queries that page gates on, so
- * the click navigates into already-cached data instead of a loading state.
- */
+ const organizationBilling = organizationBillingData?.data
+ const upgradeWorkspaceId = isOrganizationScope
+ ? organizationBilling?.upgradeWorkspaceId
+ : subscriptionData?.data?.upgradeWorkspaceId
+ const upgradeHref = upgradeWorkspaceId ? buildUpgradeHref(upgradeWorkspaceId) : null
const prefetchUpgrade = () => {
- router.prefetch(upgradeHref)
- prefetchUpgradeBillingData(queryClient)
- prefetchWorkspaceSettings(queryClient, workspaceId)
+ if (upgradeHref) router.prefetch(upgradeHref)
}
- const hasOrgScopedSubscription = Boolean(subscriptionData?.data?.isOrgScoped)
- const isLoading =
- isSubscriptionLoading ||
- isUsageLimitLoading ||
- isWorkspaceLoading ||
- (hasOrgScopedSubscription && isOrgBillingLoading)
+ const plan = isOrganizationScope
+ ? (organizationBilling?.subscriptionPlan ?? 'free')
+ : (subscriptionData?.data?.plan ?? 'free')
+ const status = isOrganizationScope
+ ? (organizationBilling?.subscriptionStatus ?? 'inactive')
+ : (subscriptionData?.data?.status ?? 'inactive')
+ const isLoading = isOrganizationScope ? isOrgBillingLoading : isSubscriptionLoading
const subscription = {
- isFree: isFree(subscriptionData?.data?.plan),
- isPro: isPro(subscriptionData?.data?.plan),
- isTeam: isTeam(subscriptionData?.data?.plan),
- isEnterprise: isEnterprise(subscriptionData?.data?.plan),
- isPaid:
- isPaid(subscriptionData?.data?.plan) &&
- hasPaidSubscriptionStatus(subscriptionData?.data?.status),
+ isFree: isFree(plan),
+ isPro: isPro(plan),
+ isTeam: isTeam(plan),
+ isEnterprise: isEnterprise(plan),
+ isPaid: isPaid(plan) && hasPaidSubscriptionStatus(status),
/**
* True when the subscription is attached to an org (regardless of plan
* name). Drives routing of usage-limit edits and whether we show pooled
* or personal usage.
*/
- isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped),
- plan: subscriptionData?.data?.plan || 'free',
- status: subscriptionData?.data?.status || 'inactive',
- seats: getEffectiveSeats(subscriptionData?.data),
+ isOrgScoped: isOrganizationScope,
+ plan,
+ status,
+ seats: isOrganizationScope
+ ? (organizationBilling?.totalSeats ?? 0)
+ : getEffectiveSeats(subscriptionData?.data),
}
const usage = {
- current: subscriptionData?.data?.usage?.current || 0,
- limit: subscriptionData?.data?.usage?.limit || 0,
- percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
- }
-
- const usageLimitData = {
- currentLimit: usageLimitResponse?.data?.currentLimit || 0,
- minimumLimit: usageLimitResponse?.data?.minimumLimit || getPlanTierDollars(subscription.plan),
+ current: isOrganizationScope
+ ? (organizationBilling?.totalCurrentUsage ?? 0)
+ : (subscriptionData?.data?.usage?.current ?? 0),
+ limit: isOrganizationScope
+ ? (organizationBilling?.totalUsageLimit ?? 0)
+ : (subscriptionData?.data?.usage?.limit ?? 0),
+ percentUsed: isOrganizationScope
+ ? organizationBilling?.totalUsageLimit
+ ? (organizationBilling.totalCurrentUsage / organizationBilling.totalUsageLimit) * 100
+ : 0
+ : (subscriptionData?.data?.usage?.percentUsed ?? 0),
}
- const isBlocked = Boolean(subscriptionData?.data?.billingBlocked)
+ const isBlocked = isOrganizationScope
+ ? Boolean(organizationBilling?.billingBlocked)
+ : Boolean(subscriptionData?.data?.billingBlocked)
- const userRole = subscriptionData?.data?.organization?.role ?? 'member'
+ const userRole = isOrganizationScope ? (organizationBillingData?.userRole ?? 'member') : 'owner'
const isTeamAdmin = isOrgAdminRole(userRole)
- const shouldUseOrganizationBillingContext = subscription.isOrgScoped && isTeamAdmin
+ const shouldUseOrganizationBillingContext = isOrganizationScope
const { data: invoicesData } = useInvoices({
context: shouldUseOrganizationBillingContext ? 'organization' : 'user',
@@ -201,18 +199,18 @@ export function Billing() {
})
const planIncludedAmount =
- subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
- ? organizationBillingData.data.minimumBillingAmount
+ subscription.isOrgScoped && organizationBilling
+ ? organizationBilling.minimumBillingAmount
: getPlanTierCredits(subscription.plan) / CREDIT_MULTIPLIER
const effectiveUsageLimit =
- subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
- ? organizationBillingData.data.totalUsageLimit
- : usageLimitData.currentLimit || usage.limit
+ subscription.isOrgScoped && organizationBilling
+ ? organizationBilling.totalUsageLimit
+ : usage.limit
const effectiveCurrentUsage =
- subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null
- ? organizationBillingData.data.totalCurrentUsage
+ subscription.isOrgScoped && organizationBilling?.totalCurrentUsage != null
+ ? organizationBilling.totalCurrentUsage
: usage.current
/**
@@ -220,10 +218,11 @@ export function Billing() {
* `setUsageLimitForCredits` (limit = planBase + creditBalance). `covered` is
* that same never-billed ceiling, so on-demand is "on" only when the limit is
* raised above it — a credit grant alone must not read as on-demand.
- * `creditBalance` is the org's balance for org-scoped admins (resolved
- * server-side by `getCreditBalance`) and the user's balance otherwise.
+ * Each scope reads its exact payer balance from the matching billing DTO.
*/
- const creditBalance = subscriptionData?.data?.creditBalance ?? 0
+ const creditBalance = isOrganizationScope
+ ? (organizationBilling?.creditBalance ?? 0)
+ : (subscriptionData?.data?.creditBalance ?? 0)
const covered = getCoveredUsage(planIncludedAmount, creditBalance)
const isOnDemandActive = getIsOnDemandActive({
@@ -380,7 +379,8 @@ export function Billing() {
}
const referenceId = subscription.isOrgScoped ? billingOrganizationId : session?.user?.id
await betterAuthSubscription.restore({ referenceId: referenceId || '' })
- await refetchSubscription()
+ if (isOrganizationScope) await refetchOrganizationBilling()
+ else await refetchSubscription()
} catch (error) {
logger.error('Failed to restore subscription', { error })
toast.error("Couldn't restore subscription", {
@@ -390,18 +390,34 @@ export function Billing() {
}
if (isLoading) return null
- if (!subscriptionData?.data) return null
-
- const plan = subscription.plan
- const planName = getDisplayPlanName(plan)
- const billingPeriod =
- subscriptionData.data.billingInterval === 'year' ? 'billed annually' : 'billed monthly'
- const priceText = subscription.isEnterprise
- ? 'Custom pricing'
- : `$${getPlanTierDollars(plan)} per user/month, ${billingPeriod}`
-
- const periodEnd = subscriptionData.data.periodEnd ?? null
- const isCancelledAtPeriodEnd = subscriptionData.data.cancelAtPeriodEnd === true
+ if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) return null
+
+ const planName = getDisplayPlanName(subscription.plan)
+ const billingInterval = isOrganizationScope
+ ? organizationBilling?.billingInterval
+ : subscriptionData?.data?.billingInterval
+ const billingPeriod = billingInterval === 'year' ? 'billed annually' : 'billed monthly'
+ const organizationSubscriptionState = organizationBilling?.subscriptionState
+ const planTitle = isOrganizationScope
+ ? organizationSubscriptionState === 'lapsed'
+ ? `Organization ${planName} plan ended`
+ : `Organization ${planName} plan`
+ : `Personal ${planName} plan`
+ const priceText =
+ organizationSubscriptionState === 'free'
+ ? 'No active organization subscription'
+ : organizationSubscriptionState === 'lapsed'
+ ? 'Choose a new plan for this organization'
+ : subscription.isEnterprise
+ ? 'Custom pricing'
+ : `$${getPlanTierDollars(subscription.plan)} per user/month, ${billingPeriod}`
+
+ const periodEnd = isOrganizationScope
+ ? (organizationBilling?.billingPeriodEnd ?? null)
+ : (subscriptionData?.data?.periodEnd ?? null)
+ const isCancelledAtPeriodEnd = isOrganizationScope
+ ? organizationBilling?.cancelAtPeriodEnd === true
+ : subscriptionData?.data?.cancelAtPeriodEnd === true
const invoices = (invoicesData?.invoices ?? []).map((invoice) => ({
id: invoice.id,
@@ -412,18 +428,22 @@ export function Billing() {
}))
const canManageBilling = permissions.canEditUsageLimit
- const showUsageLimit = !subscription.isFree && !subscription.isEnterprise
+ const canExplorePlans = permissions.showUpgradePlans
+ const showUsageLimit = subscription.isPaid && !subscription.isEnterprise
const showOnDemand = hasUsablePaidAccess && !subscription.isEnterprise
const usageLimitCurrent =
- subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
- ? organizationBillingData.data.totalUsageLimit
- : usageLimitData.currentLimit || usage.limit
+ subscription.isOrgScoped && organizationBilling
+ ? organizationBilling.totalUsageLimit
+ : usage.limit
const usageLimitMinimum =
- subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
- ? organizationBillingData.data.minimumBillingAmount
- : usageLimitData.minimumLimit
+ subscription.isOrgScoped && organizationBilling
+ ? organizationBilling.minimumBillingAmount
+ : getPlanTierDollars(subscription.plan)
+ const explorePlansLabel = isOrganizationScope
+ ? 'Explore organization plans'
+ : 'Explore personal plans'
return (
@@ -435,12 +455,12 @@ export function Billing() {
- {planName} plan
+ {planTitle}
{priceText}
{!subscription.isEnterprise &&
- (canManageBilling ? (
+ (canExplorePlans && upgradeHref ? (
- Explore plans
+ {explorePlansLabel}
) : (
- Explore plans
+ {explorePlansLabel}
))}
@@ -501,7 +521,7 @@ export function Billing() {
)}
- {!subscription.isFree && !subscription.isEnterprise && (
+ {!isOrganizationScope && !subscription.isFree && !subscription.isEnterprise && (
@@ -634,7 +654,7 @@ export function Billing() {
)}
- {!subscription.isEnterprise && }
+ {!isOrganizationScope && !subscription.isEnterprise && }
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx
index 9631b1aeccc..55ff17b26c0 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx
@@ -8,16 +8,12 @@ import { useUsageSummary } from '@/hooks/queries/usage-logs'
/** Period the compact Billing glance summarizes; the full page offers finer control. */
const SUMMARY_PERIOD = '30d'
-interface CreditUsageSectionProps {
- workspaceId: string
-}
-
/**
* Compact "how much have I used" glance in Billing settings — a single total
* plus a link to the full, filterable Credit usage page. Shown to every plan
* except Enterprise, which manages billing out-of-band.
*/
-export function CreditUsageSection({ workspaceId }: CreditUsageSectionProps) {
+export function CreditUsageSection() {
const { data: totalCredits, isPending, isError } = useUsageSummary(SUMMARY_PERIOD)
return (
@@ -29,9 +25,7 @@ export function CreditUsageSection({ workspaceId }: CreditUsageSectionProps) {
Last 30 days
-
- View usage logs
-
+ View usage logs
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx
index e274f191076..bc1c0b2d394 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx
@@ -59,6 +59,7 @@ interface BYOKKeyManagerBaseProps {
isLoading: boolean
isSaving?: boolean
isDeleting?: boolean
+ readOnly?: boolean
/** Labeled provider groups. When omitted, renders a single flat list. */
sections?: BYOKProviderSection[]
/** Optional subtitle shown above the provider list. */
@@ -131,6 +132,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
isLoading,
isSaving = false,
isDeleting = false,
+ readOnly = false,
sections,
description,
showSearch = true,
@@ -248,6 +250,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
const renderActions = (provider: BYOKManagerProvider) => {
if (!hasStoredKey(provider.id)) {
+ if (readOnly) return null
return (
openEditModal(provider.id)}>
Add Key
@@ -262,11 +265,14 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
{keyCount} {keyCount === 1 ? 'key' : 'keys'}
- setManagingProviderId(provider.id)}>Manage
+ setManagingProviderId(provider.id)}>
+ {readOnly ? 'View' : 'Manage'}
+
)
}
+ if (readOnly) return null
return (
openEditModal(provider.id)}>Update
@@ -350,6 +356,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
provider={managingMeta}
keys={managingProviderId ? getProviderKeys(managingProviderId) : NO_KEYS}
maxKeys={props.maxKeysPerProvider}
+ readOnly={readOnly}
onAddKey={() => managingProviderId && openEditModal(managingProviderId)}
onUpdateKey={(key) => managingProviderId && openEditModal(managingProviderId, key)}
onDeleteKey={(key) => managingProviderId && openDeleteConfirm(managingProviderId, key.id)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx
index 139500d319f..2098db5a0cc 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx
@@ -14,6 +14,7 @@ interface BYOKProviderKeysModalProps {
keys: BYOKManagerKey[]
/** Maximum keys allowed per provider; disables adding once reached. */
maxKeys: number
+ readOnly?: boolean
onAddKey: () => void
onUpdateKey: (key: BYOKManagerKey) => void
onDeleteKey: (key: BYOKManagerKey) => void
@@ -31,6 +32,7 @@ export function BYOKProviderKeysModal({
provider,
keys,
maxKeys,
+ readOnly = false,
onAddKey,
onUpdateKey,
onDeleteKey,
@@ -57,10 +59,12 @@ export function BYOKProviderKeysModal({
{key.maskedKey}
-
- onUpdateKey(key)}>Update
- onDeleteKey(key)}>Delete
-
+ {!readOnly && (
+
+ onUpdateKey(key)}>Update
+ onDeleteKey(key)}>Delete
+
+ )}
))}
@@ -72,11 +76,16 @@ export function BYOKProviderKeysModal({
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
index c365196abcd..c5f50d8f9e0 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
@@ -35,7 +35,9 @@ import {
WizaIcon,
ZeroBounceIcon,
} from '@/components/icons'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { MAX_BYOK_KEYS_PER_PROVIDER } from '@/lib/api/contracts/byok-keys'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
BYOKKeyManager,
type BYOKManagerKey,
@@ -323,6 +325,8 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [
export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
+ const workspacePermissions = useUserPermissionsContext()
+ const canManage = canMutateWorkspaceSettingsSection('byok', workspacePermissions)
const { data, isLoading } = useBYOKKeys(workspaceId)
const upsertKey = useUpsertBYOKKey()
@@ -349,6 +353,7 @@ export function BYOK() {
isLoading={isLoading}
isSaving={upsertKey.isPending}
isDeleting={deleteKey.isPending}
+ readOnly={!canManage}
onSaveKey={async ({ providerId, apiKey, keyId, name }) => {
await upsertKey.mutateAsync({
workspaceId,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
index 2825860e593..7254477ac83 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
@@ -6,6 +6,8 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
@@ -18,6 +20,8 @@ const logger = createLogger('CustomToolsSettings')
export function CustomTools() {
const params = useParams()
const workspaceId = params.workspaceId as string
+ const workspacePermissions = useUserPermissionsContext()
+ const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions)
const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId)
const deleteToolMutation = useDeleteCustomTool()
@@ -87,15 +91,17 @@ export function CustomTools() {
const showEmptyState = !hasTools && !showAddForm && !editingTool
const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0
- const actions: SettingsAction[] = [
- {
- text: 'Add tool',
- icon: Plus,
- variant: 'primary',
- onSelect: () => setShowAddForm(true),
- disabled: isLoading,
- },
- ]
+ const actions: SettingsAction[] = canEdit
+ ? [
+ {
+ text: 'Add tool',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowAddForm(true),
+ disabled: isLoading,
+ },
+ ]
+ : []
return (
<>
@@ -115,7 +121,9 @@ export function CustomTools() {
) : isLoading ? null : showEmptyState ? (
- Click "Add tool" above to get started
+
+ {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'}
+
) : (
{filteredTools.map((tool) => (
@@ -130,20 +138,22 @@ export function CustomTools() {
)}
-
- setEditingTool(tool.id) },
- {
- label: 'Delete',
- destructive: true,
- disabled: deletingTools.has(tool.id),
- onSelect: () => handleDeleteClick(tool.id),
- },
- ]}
- />
-
+ {canEdit && (
+
+ setEditingTool(tool.id) },
+ {
+ label: 'Delete',
+ destructive: true,
+ disabled: deletingTools.has(tool.id),
+ onSelect: () => handleDeleteClick(tool.id),
+ },
+ ]}
+ />
+
+ )}
))}
{showNoResults && (
@@ -155,43 +165,47 @@ export function CustomTools() {
)}
- {
- if (!open) {
- setShowAddForm(false)
- setEditingTool(null)
+ {canEdit && (
+ {
+ if (!open) {
+ setShowAddForm(false)
+ setEditingTool(null)
+ }
+ }}
+ onSave={handleToolSaved}
+ onDelete={() => {}}
+ blockId=''
+ initialValues={
+ editingTool
+ ? (() => {
+ const tool = tools.find((t) => t.id === editingTool)
+ return tool?.schema
+ ? { id: tool.id, schema: tool.schema, code: tool.code }
+ : undefined
+ })()
+ : undefined
}
- }}
- onSave={handleToolSaved}
- onDelete={() => {}}
- blockId=''
- initialValues={
- editingTool
- ? (() => {
- const tool = tools.find((t) => t.id === editingTool)
- return tool?.schema
- ? { id: tool.id, schema: tool.schema, code: tool.code }
- : undefined
- })()
- : undefined
- }
- />
-
- {
- if (!open) setShowDeleteDialog(false)
- }}
- srTitle='Delete Custom Tool'
- title='Delete Custom Tool'
- text={[
- 'Are you sure you want to delete ',
- { text: toolToDelete?.name ?? 'this tool', bold: true },
- '? This action cannot be undone.',
- ]}
- confirm={{ label: 'Delete', onClick: handleDeleteTool }}
- />
+ />
+ )}
+
+ {canEdit && (
+ {
+ if (!open) setShowDeleteDialog(false)
+ }}
+ srTitle='Delete Custom Tool'
+ title='Delete Custom Tool'
+ text={[
+ 'Are you sure you want to delete ',
+ { text: toolToDelete?.name ?? 'this tool', bold: true },
+ '? This action cannot be undone.',
+ ]}
+ confirm={{ label: 'Delete', onClick: handleDeleteTool }}
+ />
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
index 270eb72a563..596755f2f86 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
@@ -3,6 +3,7 @@
import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
InboxEnableToggle,
@@ -19,7 +20,8 @@ export function Inbox() {
const workspaceId = params.workspaceId as string
const { data: config, isLoading } = useInboxConfig(workspaceId)
- const { canAdmin } = useUserPermissionsContext()
+ const workspacePermissions = useUserPermissionsContext()
+ const canAdmin = canMutateWorkspaceSettingsSection('inbox', workspacePermissions)
if (isLoading) {
return null
@@ -47,13 +49,15 @@ export function Inbox() {
work on your behalf.
- router.push(`/workspace/${workspaceId}/settings/billing`)}
- >
- Upgrade to Max
-
+ {canAdmin && (
+ router.push('/account/settings/billing')}
+ >
+ Upgrade to Max
+
+ )}
@@ -63,11 +67,11 @@ export function Inbox() {
return (
-
+ {canAdmin && }
{config?.enabled && (
<>
-
+ {canAdmin && }
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index 9bcdb853fad..6894bb87684 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -8,6 +8,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import {
@@ -17,6 +18,7 @@ import {
type McpToolIssue,
} from '@/lib/mcp/tool-validation'
import type { McpTransport } from '@/lib/mcp/types'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
mcpServerIdParam,
mcpServerIdUrlKeys,
@@ -69,6 +71,7 @@ function formatToolsLabel(tools: McpTool[], connectionStatus?: string): string {
}
interface ServerListItemProps {
+ canManage: boolean
server: McpServer
tools: McpTool[]
isDeleting: boolean
@@ -79,6 +82,7 @@ interface ServerListItemProps {
}
function ServerListItem({
+ canManage,
server,
tools,
isDeleting,
@@ -118,7 +122,16 @@ function ServerListItem({
label='Server actions'
actions={[
{ label: 'Details', onSelect: onViewDetails },
- { label: 'Delete', destructive: true, disabled: isDeleting, onSelect: onRemove },
+ ...(canManage
+ ? [
+ {
+ label: 'Delete',
+ destructive: true,
+ disabled: isDeleting,
+ onSelect: onRemove,
+ },
+ ]
+ : []),
]}
/>
@@ -148,6 +161,8 @@ function buildEditInitialData(server: McpServer) {
export function MCP() {
const params = useParams()
const workspaceId = params.workspaceId as string
+ const workspacePermissions = useUserPermissionsContext()
+ const canEdit = canMutateWorkspaceSettingsSection('mcp', workspacePermissions)
const {
data: servers = [],
@@ -192,9 +207,9 @@ export function MCP() {
if (didDeepLinkRefreshRef.current) return
if (!initialServerIdRef.current) return
didDeepLinkRefreshRef.current = true
- forceRefreshTools(workspaceId)
+ if (canEdit) forceRefreshTools(workspaceId)
refetchStoredTools()
- }, [workspaceId, forceRefreshTools, refetchStoredTools])
+ }, [canEdit, workspaceId, forceRefreshTools, refetchStoredTools])
const [expandedTools, setExpandedTools] = useState>(() => new Set())
@@ -242,7 +257,7 @@ export function MCP() {
const handleViewDetails = (serverId: string) => {
setSelectedServerId(serverId)
- forceRefreshTools(workspaceId)
+ if (canEdit) forceRefreshTools(workspaceId)
refetchStoredTools()
}
@@ -386,17 +401,21 @@ export function MCP() {
handleRefreshServer(server.id),
- disabled: refreshingServerId === server.id || refreshedServerId === server.id,
- },
- {
- text: 'Edit',
- onSelect: () => setEditingServerId(server.id),
- },
- ]}
+ actions={
+ canEdit
+ ? [
+ {
+ text: refreshLabel,
+ onSelect: () => handleRefreshServer(server.id),
+ disabled: refreshingServerId === server.id || refreshedServerId === server.id,
+ },
+ {
+ text: 'Edit',
+ onSelect: () => setEditingServerId(server.id),
+ },
+ ]
+ : []
+ }
>
@@ -426,7 +445,7 @@ export function MCP() {
)}
- {server.authType === 'oauth' && server.connectionStatus !== 'connected' && (
+ {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && (
Authentication
@@ -565,28 +584,30 @@ export function MCP() {
)}
-
{
- if (!open) setEditingServerId(null)
- }}
- mode='edit'
- initialData={editInitialData}
- onSubmit={async (config) => {
- const currentServer = servers.find((s) => s.id === selectedServerId)
- await updateServerMutation.mutateAsync({
- workspaceId,
- serverId: selectedServerId!,
- updates: {
- ...config,
- enabled: currentServer?.enabled ?? true,
- },
- })
- }}
- workspaceId={workspaceId}
- availableEnvVars={availableEnvVars}
- allowedMcpDomains={allowedMcpDomains}
- />
+ {canEdit && (
+ {
+ if (!open) setEditingServerId(null)
+ }}
+ mode='edit'
+ initialData={editInitialData}
+ onSubmit={async (config) => {
+ const currentServer = servers.find((s) => s.id === selectedServerId)
+ await updateServerMutation.mutateAsync({
+ workspaceId,
+ serverId: selectedServerId!,
+ updates: {
+ ...config,
+ enabled: currentServer?.enabled ?? true,
+ },
+ })
+ }}
+ workspaceId={workspaceId}
+ availableEnvVars={availableEnvVars}
+ allowedMcpDomains={allowedMcpDomains}
+ />
+ )}
)
}
@@ -599,15 +620,19 @@ export function MCP() {
onChange: setSearchTerm,
placeholder: 'Search MCPs...',
}}
- actions={[
- {
- text: 'Add server',
- icon: Plus,
- variant: 'primary',
- onSelect: () => setShowAddModal(true),
- disabled: serversLoading,
- },
- ]}
+ actions={
+ canEdit
+ ? [
+ {
+ text: 'Add server',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowAddModal(true),
+ disabled: serversLoading,
+ },
+ ]
+ : []
+ }
>
{error ? (
@@ -616,7 +641,9 @@ export function MCP() {
) : serversLoading ? null : !hasServers ? (
- Click "Add server" above to get started
+
+ {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'}
+
) : (
{filteredServers.map((server) => {
@@ -630,6 +657,7 @@ export function MCP() {
return (
- {
- const result = await createServerMutation.mutateAsync({
- workspaceId,
- config: { ...config, enabled: true },
- })
- if (result.authType === 'oauth') {
- await startOauthForServer(result.serverId)
- }
- }}
- workspaceId={workspaceId}
- availableEnvVars={availableEnvVars}
- allowedMcpDomains={allowedMcpDomains}
- />
-
- {
- if (!open) setServerToDeleteId(null)
- }}
- srTitle='Delete MCP Server'
- title='Delete MCP Server'
- text={[
- 'Are you sure you want to delete ',
- {
- text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server',
- bold: true,
- },
- '? This action cannot be undone.',
- ]}
- confirm={{ label: 'Delete', onClick: confirmDeleteServer }}
- />
+ {canEdit && (
+ {
+ const result = await createServerMutation.mutateAsync({
+ workspaceId,
+ config: { ...config, enabled: true },
+ })
+ if (result.authType === 'oauth') {
+ await startOauthForServer(result.serverId)
+ }
+ }}
+ workspaceId={workspaceId}
+ availableEnvVars={availableEnvVars}
+ allowedMcpDomains={allowedMcpDomains}
+ />
+ )}
+
+ {canEdit && (
+ {
+ if (!open) setServerToDeleteId(null)
+ }}
+ srTitle='Delete MCP Server'
+ title='Delete MCP Server'
+ text={[
+ 'Are you sure you want to delete ',
+ {
+ text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server',
+ bold: true,
+ },
+ '? This action cannot be undone.',
+ ]}
+ confirm={{ label: 'Delete', onClick: confirmDeleteServer }}
+ />
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
index 7ff7790e78e..7c86d4721ff 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
@@ -3,7 +3,6 @@
import { useCallback, useMemo, useState } from 'react'
import { Badge, Button, ChipInput, ChipSelect, cn, Label, Skeleton } from '@sim/emcn'
import { formatDateTime } from '@sim/utils/formatting'
-import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
import {
@@ -165,8 +164,8 @@ export function Mothership() {
}
function ByokTab() {
- const params = useParams()
- const workspaceId = (params?.workspaceId as string) || ''
+ const [targetWorkspaceId, setTargetWorkspaceId] = useState('')
+ const workspaceId = targetWorkspaceId.trim()
const { data, isLoading } = useMothershipByokKeys(workspaceId)
const upsert = useUpsertMothershipByok()
@@ -178,21 +177,38 @@ function ByokTab() {
)
return (
- {
- await upsert.mutateAsync({ workspaceId, provider, apiKey })
- }}
- onDelete={async (provider) => {
- await del.mutateAsync({ workspaceId, provider })
- }}
- />
+
+
+ Target workspace
+ setTargetWorkspaceId(event.target.value)}
+ placeholder='Workspace ID'
+ className='w-[280px]'
+ />
+
+ {workspaceId ? (
+
{
+ await upsert.mutateAsync({ workspaceId, provider, apiKey })
+ }}
+ onDelete={async (provider) => {
+ await del.mutateAsync({ workspaceId, provider })
+ }}
+ />
+ ) : (
+
+ Enter a workspace ID to manage its Mothership BYOK keys.
+
+ )}
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index e1f79ef2cd5..25f0c59e857 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -7,9 +7,11 @@ import { toError } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
DEFAULT_RECENTLY_DELETED_SORT_COLUMN,
DEFAULT_RECENTLY_DELETED_SORT_DIRECTION,
@@ -155,6 +157,8 @@ export function RecentlyDeleted() {
const params = useParams()
const router = useRouter()
const workspaceId = params?.workspaceId as string
+ const workspacePermissions = useUserPermissionsContext()
+ const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions)
const [
{ tab: activeTab, sort: sortColumn, dir: sortDirection, search: urlSearchTerm },
setRecentlyDeletedFilters,
@@ -477,7 +481,7 @@ export function RecentlyDeleted() {
>
}
trailing={
- isRestoring ? (
+ !canEdit ? null : isRestoring ? (
Restoring...
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
index b49c4e801e5..03481c6c7c6 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import {
clearPendingCredentialCreateRequest,
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
@@ -197,7 +198,7 @@ interface WorkspaceVariableRowProps {
onRenameEnd: (key: string, value: string) => void
onValueChange: (key: string, value: string) => void
onDelete: (key: string) => void
- onViewDetails: (envKey: string) => void
+ onViewDetails?: (envKey: string) => void
}
function WorkspaceVariableRow({
@@ -243,7 +244,7 @@ function WorkspaceVariableRow({
/>
copyName(envKey)}
- onViewDetails={hasCredential ? () => onViewDetails(envKey) : undefined}
+ onViewDetails={hasCredential && onViewDetails ? () => onViewDetails(envKey) : undefined}
onDelete={canEdit ? () => onDelete(envKey) : undefined}
/>
@@ -350,8 +351,10 @@ export function SecretsManager() {
const queryClient = useQueryClient()
const isWorkspaceAdmin = workspacePermissions?.viewer?.isAdmin ?? false
- const canCreateWorkspaceSecret =
- isWorkspaceAdmin || workspacePermissions?.viewer?.permissionType === 'write'
+ const canCreateWorkspaceSecret = canMutateWorkspaceSettingsSection('secrets', {
+ canEdit: isWorkspaceAdmin || workspacePermissions?.viewer?.permissionType === 'write',
+ canAdmin: isWorkspaceAdmin,
+ })
const isLoading = isPersonalLoading || isWorkspaceLoading
@@ -987,7 +990,7 @@ export function SecretsManager() {
: Object.entries(workspaceVars)
).map(([key, value]) => {
const cred = workspaceEnvKeyToCredential.get(key)
- const canEditRow = cred?.role === 'admin'
+ const canEditRow = canCreateWorkspaceSecret && cred?.role === 'admin'
return (
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx
index 52d3a19cbd7..bef4fb99cbc 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx
@@ -1,219 +1,9 @@
-'use client'
-
-import {
- type ComponentType,
- createContext,
- type ReactNode,
- type Ref,
- useCallback,
- useContext,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from 'react'
-import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn'
-
-/** `useLayoutEffect` on the client (flush header changes before paint), `useEffect` during SSR. */
-const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
-
-/** The strict contract for a settings header action — rendered as a {@link Chip}, data only. */
-export interface SettingsAction {
- text: string
- icon?: ComponentType<{ className?: string }>
- variant?: 'primary' | 'destructive'
- active?: boolean
- onSelect: () => void
- disabled?: boolean
- /** Hover/focus tooltip (e.g. why the action is disabled) — the shell renders it; no per-page JSX. */
- tooltip?: string
- /** Warm a lazy resource on hover/focus (e.g. prefetch the upgrade flow). */
- onPrefetch?: () => void
-}
-
-export interface SettingsHeaderSearch {
- value: string
- onChange: (value: string) => void
- placeholder?: string
- disabled?: boolean
-}
-
-/** Left-aligned back chip for a detail sub-view, returning to the section's list. */
-export interface SettingsBackAction {
- text: string
- icon?: ComponentType<{ className?: string }>
- onSelect: () => void
-}
-
-export interface SettingsHeaderConfig {
- title?: string
- description?: string
- docsLink?: string
- back?: SettingsBackAction
- actions?: SettingsAction[]
- search?: SettingsHeaderSearch
- /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
- scrollContainerRef?: Ref
-}
-
-const EMPTY_CONFIG: SettingsHeaderConfig = {}
-
-const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null)
-
-interface ReadContextValue {
- configRef: { current: SettingsHeaderConfig }
- signature: string
-}
-
-const ReadContext = createContext(null)
-
-/**
- * Serializes only the visible/structural fields — callbacks stay in the ref and
- * the shell dereferences them at call time, so registering never loops and never
- * serves a stale handler. `scrollContainerRef` is intentionally excluded (refs are
- * identity-stable and ride along with the first content-bearing register). Any new
- * VISIBLE field must be added here too.
- */
-function computeSignature(c: SettingsHeaderConfig): string {
- return JSON.stringify({
- t: c.title ?? '',
- d: c.description ?? '',
- k: c.docsLink ?? '',
- b: c.back ? [c.back.text, c.back.icon ? 1 : 0] : null,
- a: c.actions?.map((x) => [
- x.text,
- x.variant ?? '',
- x.active ?? false,
- x.disabled ?? false,
- x.icon ? 1 : 0,
- x.tooltip ?? '',
- x.onPrefetch ? 1 : 0,
- ]),
- s: c.search ? [c.search.value, c.search.placeholder ?? '', c.search.disabled ?? false] : null,
- })
-}
-
-export function SettingsHeaderProvider({ children }: { children: ReactNode }) {
- const configRef = useRef(EMPTY_CONFIG)
- const [signature, setSignature] = useState('')
-
- const register = useCallback((config: SettingsHeaderConfig) => {
- configRef.current = config
- const next = computeSignature(config)
- setSignature((prev) => (prev === next ? prev : next))
- }, [])
-
- const readValue = useMemo(() => ({ configRef, signature }), [signature])
-
- return (
-
- {children}
-
- )
-}
-
-/** Registers a section's header content into the persistent settings chrome. */
-export function useSettingsHeader(config: SettingsHeaderConfig) {
- const register = useContext(RegisterContext)
-
- useIsomorphicLayoutEffect(() => {
- register?.(config)
- })
-
- useIsomorphicLayoutEffect(() => {
- return () => register?.(EMPTY_CONFIG)
- }, [register])
-}
-
-/**
- * The single owner of settings page chrome: the header bar (back chip, Docs link,
- * action chips), the scroll region, and the centered column led by the title +
- * description, then search and `{children}`.
- */
-export function SettingsHeaderShell({ children }: { children: ReactNode }) {
- const read = useContext(ReadContext)
- const configRef = read?.configRef
- const config = configRef?.current ?? EMPTY_CONFIG
- const { title, description, docsLink, back, actions, search, scrollContainerRef } = config
-
- return (
-
-
- {back ? (
-
configRef?.current.back?.onSelect()}>
- {back.text}
-
- ) : (
-
- )}
-
- {docsLink && (
-
- Docs
-
- )}
- {actions?.map((action, index) => {
- const chip = (
- configRef?.current.actions?.[index]?.onSelect()}
- onMouseEnter={
- action.onPrefetch
- ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
- : undefined
- }
- onFocus={
- action.onPrefetch
- ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
- : undefined
- }
- disabled={action.disabled}
- >
- {action.text}
-
- )
- return action.tooltip ? (
-
-
- {chip}
-
- {action.tooltip}
-
- ) : (
- chip
- )
- })}
-
-
-
-
- {(title || description) && (
-
- {title &&
{title} }
- {description &&
{description}
}
-
- )}
- {search && (
-
configRef?.current.search?.onChange(event.target.value)}
- disabled={search.disabled}
- autoComplete='off'
- className='w-full'
- />
- )}
- {children}
-
-
-
- )
-}
+export {
+ type SettingsAction,
+ type SettingsBackAction,
+ type SettingsHeaderConfig,
+ SettingsHeaderProvider,
+ type SettingsHeaderSearch,
+ SettingsHeaderShell,
+ useSettingsHeader,
+} from '@/components/settings/settings-header'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
index 9ec6ea696c4..cc02b79fb01 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
@@ -1,93 +1,4 @@
-'use client'
-
-import { createContext, type ReactNode, type Ref, useContext } from 'react'
-import {
- type SettingsAction,
- type SettingsBackAction,
- type SettingsHeaderSearch,
- useSettingsHeader,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
-import {
- getSettingsSectionMeta,
- type SettingsSection,
-} from '@/app/workspace/[workspaceId]/settings/navigation'
-
-const SettingsSectionContext = createContext(null)
-
-/**
- * Provides the active settings section to descendants so `SettingsPanel` can
- * resolve its title/description from navigation metadata. Set once by the
- * settings shell with the resolved (post-redirect) section.
- */
-export function SettingsSectionProvider({
- section,
- children,
-}: {
- section: SettingsSection
- children: ReactNode
-}) {
- return (
- {children}
- )
-}
-
-function useSettingsSection(): SettingsSection | null {
- return useContext(SettingsSectionContext)
-}
-
-interface SettingsPanelProps {
- /** Body content rendered below the header in the centered content column. */
- children?: ReactNode
- /** Strict top-right action chips — data only (`text`/`icon`/`variant`/…), never JSX. */
- actions?: SettingsAction[]
- /** Left-aligned back chip for a detail sub-view; omit on list/panel pages. */
- back?: SettingsBackAction
- /** Renders the canonical search field directly below the title. */
- search?: SettingsHeaderSearch
- /** Overrides the nav-driven title (e.g. for a detail sub-view). */
- title?: string
- /**
- * Overrides the nav-driven description. When `title` is set (a detail sub-view),
- * the description is used verbatim — it never falls back to the section's meta
- * blurb, so an entity with no description renders no description.
- */
- description?: string
- /** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */
- docsLink?: string
- /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
- scrollContainerRef?: Ref
-}
-
-/**
- * Registers a settings section's header content (title, description, docs link,
- * action chips, search) into the persistent settings layout, then renders the
- * section body. It owns **no** chrome: the header bar, scroll region, centered
- * column, and spacing all live in the layout's `SettingsHeaderShell` and stay
- * mounted across section navigation. Sections supply data only — the structured
- * `actions` contract makes it impossible to inject a `` or a padding change.
- */
-export function SettingsPanel({
- children,
- actions,
- back,
- search,
- title,
- description,
- docsLink,
- scrollContainerRef,
-}: SettingsPanelProps) {
- const section = useSettingsSection()
- const meta = section ? getSettingsSectionMeta(section) : null
-
- useSettingsHeader({
- title: title ?? meta?.label,
- description: title !== undefined ? description : (description ?? meta?.description),
- docsLink: docsLink ?? meta?.docsLink,
- back,
- actions,
- search,
- scrollContainerRef,
- })
-
- return <>{children}>
-}
+export {
+ SettingsPanel,
+ SettingsSectionProvider,
+} from '@/components/settings/settings-panel'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
index 6c99ebe282f..3d81108fbe2 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
@@ -68,6 +68,7 @@ function buildActionsMenu(actions: RowAction[]) {
}
interface OrganizationMemberListsProps {
+ canManage: boolean
organizationId: string
roster: OrganizationRoster | null | undefined
isLoadingRoster: boolean
@@ -83,6 +84,7 @@ interface OrganizationMemberListsProps {
* section; sections with no matches collapse while a search is active.
*/
export function OrganizationMemberLists({
+ canManage,
organizationId,
roster,
isLoadingRoster,
@@ -114,8 +116,8 @@ export function OrganizationMemberLists({
const isSelf = member.userId === currentUserId
const isOwner = member.role === 'owner'
const isExternal = member.role === 'external'
- const editable = !isSelf && !isOwner && !isExternal
- const canRemove = !isSelf && !isOwner
+ const editable = canManage && !isSelf && !isOwner && !isExternal
+ const canRemove = canManage && !isSelf && !isOwner
return (
copyToClipboard(member.email) },
- ...(!isOwner
+ ...(canManage && !isOwner
? [
{
label: 'Manage Credits',
@@ -187,7 +189,7 @@ export function OrganizationMemberLists({
...(isSelf && isOwner && onTransferOwnership
? [{ label: 'Transfer ownership', onSelect: () => onTransferOwnership() }]
: []),
- ...(isSelf && !isOwner
+ ...(canManage && isSelf && !isOwner
? [
{
label: 'Leave organization',
@@ -225,21 +227,25 @@ export function OrganizationMemberLists({
roleControl={roleControl}
menu={buildActionsMenu([
{ label: 'Copy email', onSelect: () => copyToClipboard(invitation.email) },
- {
- label: 'Resend invite',
- onSelect: () =>
- resendInvitation
- .mutateAsync({ invitationId: invitation.id, orgId: organizationId })
- .catch((error) => logger.error('Failed to resend invitation', { error })),
- },
- {
- label: 'Revoke invite',
- destructive: true,
- onSelect: () =>
- cancelInvitation
- .mutateAsync({ invitationId: invitation.id, orgId: organizationId })
- .catch((error) => logger.error('Failed to revoke invitation', { error })),
- },
+ ...(canManage
+ ? [
+ {
+ label: 'Resend invite',
+ onSelect: () =>
+ resendInvitation
+ .mutateAsync({ invitationId: invitation.id, orgId: organizationId })
+ .catch((error) => logger.error('Failed to resend invitation', { error })),
+ },
+ {
+ label: 'Revoke invite',
+ destructive: true,
+ onSelect: () =>
+ cancelInvitation
+ .mutateAsync({ invitationId: invitation.id, orgId: organizationId })
+ .catch((error) => logger.error('Failed to revoke invitation', { error })),
+ },
+ ]
+ : []),
])}
/>
)
@@ -267,7 +273,7 @@ export function OrganizationMemberLists({
}
options={ORG_ROLE_OPTIONS}
matchTriggerWidth={false}
- disabled={updateInvitation.isPending}
+ disabled={!canManage || updateInvitation.isPending}
/>
)
return renderInviteRow(invitation, 'org-invite', roleControl)
@@ -281,9 +287,10 @@ export function OrganizationMemberLists({
const rowUserIsOrgAdmin = isOrgAdminRole(member.role)
const isSelf = member.userId === currentUserId
const wouldDemoteSelf = isSelf && access.permission === 'admin'
- const disabled = rowUserIsOrgAdmin || wouldDemoteSelf || updatePermissions.isPending
+ const disabled =
+ !canManage || rowUserIsOrgAdmin || wouldDemoteSelf || updatePermissions.isPending
const lockReason = rowUserIsOrgAdmin ? workspaceRoleLockReason('org-admin') : null
- const canRemoveFromWorkspace = !rowUserIsOrgAdmin && !isSelf
+ const canRemoveFromWorkspace = canManage && !rowUserIsOrgAdmin && !isSelf
return (
)
return renderInviteRow(invitation, `ws-${workspaceId}-invite`, roleControl)
@@ -457,15 +464,17 @@ export function OrganizationMemberLists({
)
})}
- {
- if (!open) setCreditsTarget(null)
- }}
- organizationId={organizationId}
- member={creditsTarget}
- />
+ {canManage && (
+ {
+ if (!open) setCreditsTarget(null)
+ }}
+ organizationId={organizationId}
+ member={creditsTarget}
+ />
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
index 0d371d78fff..63720940891 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
@@ -1,5 +1,4 @@
-import { Badge, Chip, cn } from '@sim/emcn'
-import { useParams, useRouter } from 'next/navigation'
+import { Badge, ChipLink, cn } from '@sim/emcn'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -14,6 +13,7 @@ type Subscription = {
}
interface TeamSeatsOverviewProps {
+ billingHref?: string
subscriptionData: Subscription | null
isLoadingSubscription: boolean
totalSeats: number
@@ -24,16 +24,13 @@ interface TeamSeatsOverviewProps {
}
export function TeamSeatsOverview({
+ billingHref,
subscriptionData,
isLoadingSubscription,
totalSeats,
usedSeats,
pendingSeats = 0,
}: TeamSeatsOverviewProps) {
- const router = useRouter()
- const params = useParams<{ workspaceId: string }>()
- const workspaceId = params?.workspaceId
-
if (isLoadingSubscription) {
return null
}
@@ -48,18 +45,11 @@ export function TeamSeatsOverview({
Purchase a Team plan to invite teammates to this organization.
- {
- if (workspaceId) {
- router.push(`/workspace/${workspaceId}/settings/billing`)
- }
- }}
- disabled={!workspaceId}
- >
- View plans
-
+ {billingHref && (
+
+ View plans
+
+ )}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
index 0955294757a..364a715d0c6 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
@@ -21,7 +21,6 @@ import {
useOrganization,
useOrganizationBilling,
useOrganizationRoster,
- useOrganizations,
useRemoveMember,
useTransferOwnership,
} from '@/hooks/queries/organization'
@@ -30,29 +29,28 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('TeamManagement')
-export function TeamManagement() {
+interface TeamManagementProps {
+ organizationId: string
+}
+
+export function TeamManagement({ organizationId }: TeamManagementProps) {
const { data: session } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()
- const { data: organizationsData } = useOrganizations()
- const activeOrganization = organizationsData?.activeOrganization
-
const { data: userSubscriptionData } = useSubscriptionData()
const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data)
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
- const {
- data: organization,
- isLoading,
- error: orgError,
- } = useOrganization(activeOrganization?.id || '')
+ const { data: organization, isLoading, error: orgError } = useOrganization(organizationId)
+ const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
- activeOrganization?.id || ''
+ organizationId,
+ { enabled: adminOrOwner }
)
- const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(activeOrganization?.id)
+ const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(organizationId)
const removeMemberMutation = useRemoveMember()
const transferOwnershipMutation = useTransferOwnership()
@@ -73,7 +71,6 @@ export function TeamManagement() {
const [orgName, setOrgName] = useState('')
const [orgSlug, setOrgSlug] = useState('')
- const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
const usedSeats = organizationBillingData?.data?.members?.length ?? 0
const reservedSeats = organizationBillingData?.data?.usedSeats ?? 0
@@ -155,7 +152,7 @@ export function TeamManagement() {
const handleRemoveMember = useCallback(
async (member: Member) => {
- if (!session?.user || !activeOrganization?.id) return
+ if (!session?.user) return
if (!member.user?.id) {
logger.error('Member object missing user ID', { member })
@@ -175,17 +172,17 @@ export function TeamManagement() {
isExternalRemoval: member.role === 'external',
})
},
- [session?.user, activeOrganization?.id]
+ [session?.user]
)
const confirmRemoveMember = useCallback(async () => {
const { memberId, isSelfRemoval } = removeMemberDialog
- if (!session?.user || !activeOrganization?.id || !memberId) return
+ if (!session?.user || !memberId) return
try {
await removeMemberMutation.mutateAsync({
memberId,
- orgId: activeOrganization?.id,
+ orgId: organizationId,
})
setRemoveMemberDialog({
@@ -205,7 +202,7 @@ export function TeamManagement() {
removeMemberDialog.memberId,
removeMemberDialog.isSelfRemoval,
session?.user?.id,
- activeOrganization?.id,
+ organizationId,
removeMemberMutation,
])
@@ -228,11 +225,9 @@ export function TeamManagement() {
const handleConfirmTransfer = useCallback(
async (newOwnerUserId: string) => {
- if (!activeOrganization?.id) return
-
try {
const result = await transferOwnershipMutation.mutateAsync({
- orgId: activeOrganization.id,
+ orgId: organizationId,
newOwnerUserId,
alsoLeave: true,
})
@@ -246,17 +241,16 @@ export function TeamManagement() {
logger.error('Failed to transfer ownership', error)
}
},
- [activeOrganization?.id, transferOwnershipMutation]
+ [organizationId, transferOwnershipMutation]
)
const handleOpenTransferBillingPortal = useCallback(() => {
- if (!activeOrganization?.id) return
setTransferPortalError(null)
const portalWindow = window.open('', '_blank')
openBillingPortal.mutate(
{
context: 'organization',
- organizationId: activeOrganization.id,
+ organizationId,
returnUrl: `${getBaseUrl()}/workspace`,
},
{
@@ -278,13 +272,13 @@ export function TeamManagement() {
},
}
)
- }, [activeOrganization?.id, openBillingPortal])
+ }, [organizationId, openBillingPortal])
const queryError = orgError
const errorMessage = queryError instanceof Error ? queryError.message : null
- const displayOrganization = organization || activeOrganization
+ const displayOrganization = organization
- if (isLoading && !displayOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
+ if (isLoading && !displayOrganization) {
return null
}
@@ -306,33 +300,37 @@ export function TeamManagement() {
)
}
- if (!adminOrOwner) {
- return null
- }
-
return (
<>
setInviteModalOpen(true),
- disabled: isInvitationsDisabled,
- tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
- },
- ]}
+ actions={
+ adminOrOwner
+ ? [
+ {
+ text: 'Invite',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setInviteModalOpen(true),
+ disabled: isInvitationsDisabled,
+ tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
+ },
+ ]
+ : []
+ }
>
-
+ {adminOrOwner && (
+
+ )}
-
+ {adminOrOwner && (
+
+ )}
workspace.id === workspaceId)
const inviteDisabledReason = activeWorkspace?.inviteDisabledReason ?? null
@@ -176,16 +180,20 @@ export function Teammates() {
onChange: (value) => void setSearchTerm(value),
placeholder: 'Search teammates...',
}}
- actions={[
- {
- text: 'Invite',
- icon: Plus,
- variant: 'primary',
- onSelect: handleInvite,
- tooltip: inviteDisabledReason ?? undefined,
- onPrefetch: isInvitationsDisabled ? prefetchUpgrade : undefined,
- },
- ]}
+ actions={
+ canManage
+ ? [
+ {
+ text: 'Invite',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: handleInvite,
+ tooltip: inviteDisabledReason ?? undefined,
+ onPrefetch: isInvitationsDisabled ? prefetchUpgrade : undefined,
+ },
+ ]
+ : []
+ }
>
-
+ {canManage && (
+
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
index c851726fce2..984f5cac0bf 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
@@ -25,6 +25,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Check, Clipboard, Plus, Server } from 'lucide-react'
import { useParams, useSearchParams } from 'next/navigation'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
@@ -47,11 +48,11 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
-import { useWorkspaceSettings } from '@/hooks/queries/workspace'
const logger = createLogger('WorkflowMcpServers')
interface ServerDetailViewProps {
+ canManage: boolean
workspaceId: string
serverId: string
onBack: () => void
@@ -59,7 +60,7 @@ interface ServerDetailViewProps {
type McpClientType = 'sim' | 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode'
-function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
+function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDetailViewProps) {
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
@@ -68,19 +69,13 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
const updateToolMutation = useUpdateWorkflowMcpTool()
const updateServerMutation = useUpdateWorkflowMcpServer()
- const { data: apiKeysData } = useApiKeys(workspaceId)
- const { data: workspaceSettingsData } = useWorkspaceSettings(workspaceId)
- const userPermissions = useUserPermissionsContext()
+ const { data: apiKeysData } = useApiKeys(workspaceId, 'workspace')
const [showCreateApiKeyModal, setShowCreateApiKeyModal] = useState(false)
- const existingKeyNames = [
- ...(apiKeysData?.workspaceKeys ?? []),
- ...(apiKeysData?.personalKeys ?? []),
- ].map((k) => k.name)
- const allowPersonalApiKeys =
- workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
- const canManageWorkspaceKeys = userPermissions.canAdmin
- const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
+ const existingKeyNames = (apiKeysData?.workspaceKeys ?? []).map((key) => key.name)
+ const allowPersonalApiKeys = false
+ const canManageWorkspaceKeys = canManage
+ const defaultKeyType = 'workspace'
const addToWorkspaceMutation = useCreateMcpServer()
const [addedToWorkspace, setAddedToWorkspace] = useState(false)
@@ -375,19 +370,23 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
setShowAddWorkflow(true),
- disabled: !canAddWorkflow,
- tooltip: showAddDisabledTooltip
- ? 'All deployed workflows have been added to this server.'
- : undefined,
- },
- ]}
+ actions={
+ canManage
+ ? [
+ { text: 'Edit server', onSelect: handleOpenEditServer },
+ {
+ text: 'Add workflows',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowAddWorkflow(true),
+ disabled: !canAddWorkflow,
+ tooltip: showAddDisabledTooltip
+ ? 'All deployed workflows have been added to this server.'
+ : undefined,
+ },
+ ]
+ : []
+ }
>
-
- handleOpenToolEdit(tool) },
- {
- label: 'Remove',
- destructive: true,
- disabled: deleteToolMutation.isPending,
- onSelect: () => setToolToDelete(tool),
- },
- ]}
- />
-
+ {canManage && (
+
+ handleOpenToolEdit(tool) },
+ {
+ label: 'Remove',
+ destructive: true,
+ disabled: deleteToolMutation.isPending,
+ onSelect: () => setToolToDelete(tool),
+ },
+ ]}
+ />
+
+ )}
))}
@@ -599,14 +600,19 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
{!server.isPublic && (
- Replace $SIM_API_KEY with your API key, or{' '}
- setShowCreateApiKeyModal(true)}
- className='underline hover-hover:text-[var(--text-secondary)]'
- >
- create one now
-
+ Replace $SIM_API_KEY with your API key
+ {canManage && (
+ <>
+ , or{' '}
+ setShowCreateApiKeyModal(true)}
+ className='underline hover-hover:text-[var(--text-secondary)]'
+ >
+ create one now
+
+ >
+ )}
)}
@@ -617,233 +623,243 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
- !open && setToolToDelete(null)}
- srTitle='Remove Workflow'
- title='Remove Workflow'
- text={[
- 'Are you sure you want to remove ',
- { text: toolToDelete?.toolName ?? 'this workflow', bold: true },
- ' from this server? The workflow will remain deployed and can be added back later.',
- ]}
- confirm={{
- label: 'Remove',
- onClick: handleDeleteTool,
- pending: deleteToolMutation.isPending,
- pendingLabel: 'Removing...',
- }}
- />
-
- {
- if (!open) {
- setToolToView(null)
- setEditingDescription('')
- setEditingParameterDescriptions({})
- }
- }}
- srTitle={toolToView?.toolName ?? 'Edit Tool'}
- >
- setToolToView(null)}>
- {toolToView?.toolName}
-
-
-
+ {canManage && (
+ !open && setToolToDelete(null)}
+ srTitle='Remove Workflow'
+ title='Remove Workflow'
+ text={[
+ 'Are you sure you want to remove ',
+ { text: toolToDelete?.toolName ?? 'this workflow', bold: true },
+ ' from this server? The workflow will remain deployed and can be added back later.',
+ ]}
+ confirm={{
+ label: 'Remove',
+ onClick: handleDeleteTool,
+ pending: deleteToolMutation.isPending,
+ pendingLabel: 'Removing...',
+ }}
+ />
+ )}
+
+ {canManage && (
+ {
+ if (!open) {
+ setToolToView(null)
+ setEditingDescription('')
+ setEditingParameterDescriptions({})
+ }
+ }}
+ srTitle={toolToView?.toolName ?? 'Edit Tool'}
+ >
+ setToolToView(null)}>
+ {toolToView?.toolName}
+
+
+
-
- {(() => {
- const schema = toolToView?.parameterSchema as
- | { properties?: Record }
- | undefined
- const properties = schema?.properties
- const hasParams = properties && Object.keys(properties).length > 0
- return hasParams ? (
-
- {Object.entries(properties).map(([name, prop]) => (
-
-
-
-
- {name}
-
-
- {prop.type || 'any'}
-
+
+ {(() => {
+ const schema = toolToView?.parameterSchema as
+ | { properties?: Record }
+ | undefined
+ const properties = schema?.properties
+ const hasParams = properties && Object.keys(properties).length > 0
+ return hasParams ? (
+
+ {Object.entries(properties).map(([name, prop]) => (
+
+
+
+
+ {name}
+
+
+ {prop.type || 'any'}
+
+
-
-
-
-
Description
-
- setEditingParameterDescriptions((prev) => ({
- ...prev,
- [name]: e.target.value,
- }))
- }
- placeholder={`Enter description for ${name}`}
- />
+
+
+ Description
+
+ setEditingParameterDescriptions((prev) => ({
+ ...prev,
+ [name]: e.target.value,
+ }))
+ }
+ placeholder={`Enter description for ${name}`}
+ />
+
-
- ))}
-
- ) : (
-
- No inputs configured for this workflow.
-
- )
- })()}
-
-
-
setToolToView(null)}
- primaryAction={{
- label: updateToolMutation.isPending ? 'Saving...' : 'Save',
- onClick: handleSaveToolEdit,
- disabled: isSaveToolDisabled,
+ ))}
+
+ ) : (
+
+ No inputs configured for this workflow.
+
+ )
+ })()}
+
+
+
setToolToView(null)}
+ primaryAction={{
+ label: updateToolMutation.isPending ? 'Saving...' : 'Save',
+ onClick: handleSaveToolEdit,
+ disabled: isSaveToolDisabled,
+ }}
+ />
+
+ )}
+
+ {canManage && (
+ {
+ if (!open) {
+ setShowAddWorkflow(false)
+ setSelectedWorkflowId(null)
+ }
}}
- />
-
-
- {
- if (!open) {
- setShowAddWorkflow(false)
- setSelectedWorkflowId(null)
- }
- }}
- srTitle='Add Workflow'
- >
- {
- setShowAddWorkflow(false)
- setSelectedWorkflowId(null)
+ srTitle='Add Workflow'
+ >
+ {
+ setShowAddWorkflow(false)
+ setSelectedWorkflowId(null)
+ }}
+ >
+ Add Workflow
+
+
+
+ Select a deployed workflow to add to this MCP server. The workflow will be available
+ as a tool.
+
+
+ setSelectedWorkflowId(value)}
+ placeholder='Select a workflow...'
+ searchable
+ searchPlaceholder='Search workflows...'
+ disabled={addToolMutation.isPending}
+ fullWidth
+ dropdownWidth='trigger'
+ align='start'
+ displayLabel={selectedWorkflow?.name}
+ />
+
+
+ {addToolMutation.isError
+ ? addToolMutation.error?.message || 'Failed to add workflow'
+ : null}
+
+
+ {
+ setShowAddWorkflow(false)
+ setSelectedWorkflowId(null)
+ }}
+ primaryAction={{
+ label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow',
+ onClick: handleAddWorkflow,
+ disabled: !selectedWorkflowId || addToolMutation.isPending,
+ }}
+ />
+
+ )}
+
+ {canManage && (
+ {
+ if (!open) {
+ setShowEditServer(false)
+ }
}}
+ srTitle='Edit Server'
>
- Add Workflow
-
-
-
- Select a deployed workflow to add to this MCP server. The workflow will be available as
- a tool.
-
-
- setSelectedWorkflowId(value)}
- placeholder='Select a workflow...'
- searchable
- searchPlaceholder='Search workflows...'
- disabled={addToolMutation.isPending}
- fullWidth
- dropdownWidth='trigger'
- align='start'
- displayLabel={selectedWorkflow?.name}
+ setShowEditServer(false)}>Edit Server
+
+
-
-
- {addToolMutation.isError
- ? addToolMutation.error?.message || 'Failed to add workflow'
- : null}
-
-
- {
- setShowAddWorkflow(false)
- setSelectedWorkflowId(null)
- }}
- primaryAction={{
- label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow',
- onClick: handleAddWorkflow,
- disabled: !selectedWorkflowId || addToolMutation.isPending,
- }}
- />
-
-
- {
- if (!open) {
- setShowEditServer(false)
- }
- }}
- srTitle='Edit Server'
- >
- setShowEditServer(false)}>Edit Server
-
-
-
+
+
+
setEditServerIsPublic(value === 'public')}
+ >
+ API Key
+ Public
+
+
+ {editServerIsPublic
+ ? 'Anyone with the URL can call this server without authentication'
+ : 'Requests must include your Sim API key in the X-API-Key header'}
+
+
+
+
+ setShowEditServer(false)}
+ primaryAction={{
+ label: updateServerMutation.isPending ? 'Saving...' : 'Save',
+ onClick: handleSaveServerEdit,
+ disabled:
+ !editServerName.trim() ||
+ updateServerMutation.isPending ||
+ (editServerName === server.name &&
+ editServerDescription === (server.description || '') &&
+ editServerIsPublic === server.isPublic),
+ }}
/>
-
-
-
setEditServerIsPublic(value === 'public')}
- >
- API Key
- Public
-
-
- {editServerIsPublic
- ? 'Anyone with the URL can call this server without authentication'
- : 'Requests must include your Sim API key in the X-API-Key header'}
-
-
-
-
- setShowEditServer(false)}
- primaryAction={{
- label: updateServerMutation.isPending ? 'Saving...' : 'Save',
- onClick: handleSaveServerEdit,
- disabled:
- !editServerName.trim() ||
- updateServerMutation.isPending ||
- (editServerName === server.name &&
- editServerDescription === (server.description || '') &&
- editServerIsPublic === server.isPublic),
- }}
+
+ )}
+
+ {canManage && (
+
-
-
-
+ )}
>
)
}
@@ -856,6 +872,8 @@ export function WorkflowMcpServers() {
const params = useParams()
const workspaceId = params.workspaceId as string
const searchParams = useSearchParams()
+ const workspacePermissions = useUserPermissionsContext()
+ const canAdmin = canMutateWorkspaceSettingsSection('workflow-mcp-servers', workspacePermissions)
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId)
@@ -910,6 +928,7 @@ export function WorkflowMcpServers() {
if (selectedServerId) {
return (
setSelectedServerId(null)}
@@ -917,15 +936,17 @@ export function WorkflowMcpServers() {
)
}
- const actions: SettingsAction[] = [
- {
- text: 'Add server',
- icon: Plus,
- variant: 'primary',
- onSelect: () => setShowAddModal(true),
- disabled: isLoading,
- },
- ]
+ const actions: SettingsAction[] = canAdmin
+ ? [
+ {
+ text: 'Add server',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowAddModal(true),
+ disabled: isLoading,
+ },
+ ]
+ : []
return (
<>
@@ -946,7 +967,7 @@ export function WorkflowMcpServers() {
) : isLoading ? null : !hasServers ? (
- Click "Add server" above to get started
+ {canAdmin ? 'Click "Add server" above to get started' : 'No MCP servers configured'}
) : (
@@ -974,12 +995,16 @@ export function WorkflowMcpServers() {
label='Server actions'
actions={[
{ label: 'Details', onSelect: () => setSelectedServerId(server.id) },
- {
- label: 'Delete',
- destructive: true,
- disabled: isDeleting,
- onSelect: () => setServerToDelete(server),
- },
+ ...(canAdmin
+ ? [
+ {
+ label: 'Delete',
+ destructive: true,
+ disabled: isDeleting,
+ onSelect: () => setServerToDelete(server),
+ },
+ ]
+ : []),
]}
/>
@@ -996,25 +1021,29 @@ export function WorkflowMcpServers() {
-
-
-
!open && setServerToDelete(null)}
- srTitle='Delete MCP Server'
- title='Delete MCP Server'
- text={[
- 'Are you sure you want to delete ',
- { text: serverToDelete?.name ?? 'this server', bold: true },
- '? This action cannot be undone.',
- ]}
- confirm={{ label: 'Delete', onClick: handleDeleteServer }}
- />
+ {canAdmin && (
+
+ )}
+
+ {canAdmin && (
+ !open && setServerToDelete(null)}
+ srTitle='Delete MCP Server'
+ title='Delete MCP Server'
+ text={[
+ 'Are you sure you want to delete ',
+ { text: serverToDelete?.name ?? 'this server', bold: true },
+ '? This action cannot be undone.',
+ ]}
+ confirm={{ label: 'Delete', onClick: handleDeleteServer }}
+ />
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload.ts
index 13715b5f68e..cce1b26f5bc 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload.ts
@@ -1,21 +1 @@
-import { useEffect } from 'react'
-import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
-
-/**
- * Registers a single `beforeunload` guard for the whole settings surface,
- * active only while some section reports unsaved changes via
- * `useSettingsDirtyStore`. Mounted once in the settings shell so individual
- * pages never register their own.
- */
-export function useSettingsBeforeUnload() {
- const isDirty = useSettingsDirtyStore((s) => s.isDirty)
-
- useEffect(() => {
- if (!isDirty) return
- const handleBeforeUnload = (event: BeforeUnloadEvent) => {
- event.preventDefault()
- }
- window.addEventListener('beforeunload', handleBeforeUnload)
- return () => window.removeEventListener('beforeunload', handleBeforeUnload)
- }, [isDirty])
-}
+export { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts
index d3dcdcf226b..baf8a42748f 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts
@@ -1,63 +1 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
-
-interface UseSettingsUnsavedGuardParams {
- isDirty: boolean
-}
-
-interface SettingsUnsavedGuard {
- showUnsavedModal: boolean
- setShowUnsavedModal: (open: boolean) => void
- /** Run `onLeave` immediately when clean; when dirty, open the confirm modal and defer it. */
- guardBack: (onLeave: () => void) => void
- /** Confirmed discard — close the modal and run the deferred leave action. */
- confirmDiscard: () => void
-}
-
-/**
- * Wires a settings surface's local dirty state into the shared
- * `useSettingsDirtyStore`, so the sidebar's leave confirmation (section switch,
- * Back, workspace switch) and the centralized `beforeunload` both apply without
- * per-page wiring. Also provides an in-view back/close guard (`guardBack` + the
- * shared `UnsavedChangesModal`) for detail sub-views whose "back" is an
- * in-component state change rather than a route navigation.
- */
-export function useSettingsUnsavedGuard({
- isDirty,
-}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
- const setDirty = useSettingsDirtyStore((s) => s.setDirty)
- const reset = useSettingsDirtyStore((s) => s.reset)
- const isDirtyRef = useRef(isDirty)
- const pendingLeaveRef = useRef<(() => void) | null>(null)
- const [showUnsavedModal, setShowUnsavedModal] = useState(false)
-
- useEffect(() => {
- isDirtyRef.current = isDirty
- setDirty(isDirty)
- if (!isDirty) {
- pendingLeaveRef.current = null
- setShowUnsavedModal(false)
- }
- }, [isDirty, setDirty])
-
- useEffect(() => {
- return () => reset()
- }, [reset])
-
- const guardBack = useCallback((onLeave: () => void) => {
- if (isDirtyRef.current) {
- pendingLeaveRef.current = onLeave
- setShowUnsavedModal(true)
- } else {
- onLeave()
- }
- }, [])
-
- const confirmDiscard = useCallback(() => {
- setShowUnsavedModal(false)
- pendingLeaveRef.current?.()
- pendingLeaveRef.current = null
- }, [])
-
- return { showUnsavedModal, setShowUnsavedModal, guardBack, confirmDiscard }
-}
+export { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx
index afd7974fd1f..f3b58312271 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx
@@ -1,3 +1,8 @@
+'use client'
+
+import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
+
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
+ useSettingsBeforeUnload()
return {children}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
deleted file mode 100644
index 6851d93bc6d..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-import {
- ClipboardList,
- Database,
- HexSimple,
- Key,
- KeySquare,
- Lock,
- LogIn,
- Palette,
- Send,
- Server,
- Settings,
- ShieldCheck,
- Shuffle,
- TerminalWindow,
- TrashOutline,
- Upload,
- User,
- Users,
- Wrench,
-} from '@sim/emcn'
-import { McpIcon } from '@/components/icons'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
-
-export type SettingsSection =
- | 'general'
- | 'secrets'
- | 'access-control'
- | 'custom-blocks'
- | 'audit-logs'
- | 'apikeys'
- | 'byok'
- | 'billing'
- | 'teammates'
- | 'organization'
- | 'sso'
- | 'whitelabeling'
- | 'copilot'
- | 'forks'
- | 'mcp'
- | 'custom-tools'
- | 'workflow-mcp-servers'
- | 'inbox'
- | 'admin'
- | 'data-retention'
- | 'data-drains'
- | 'mothership'
- | 'recently-deleted'
-
-export type NavigationSection =
- | 'account'
- | 'subscription'
- | 'tools'
- | 'system'
- | 'enterprise'
- | 'superuser'
-
-export interface NavigationItem {
- id: SettingsSection
- label: string
- /** One-line summary shown as the page subtitle under the title. */
- description: string
- icon: React.ComponentType<{ className?: string }>
- section: NavigationSection
- hideWhenBillingDisabled?: boolean
- requiresTeam?: boolean
- requiresEnterprise?: boolean
- requiresMax?: boolean
- requiresHosted?: boolean
- selfHostedOverride?: boolean
- requiresSuperUser?: boolean
- requiresAdminRole?: boolean
- /**
- * Exempt this item from the org admin/owner requirement that `requiresTeam` /
- * `requiresEnterprise` otherwise impose in the sidebar. The plan/hosted
- * entitlement still applies; the page enforces its own per-resource authz. Use
- * for workspace-admin-managed features (e.g. custom blocks) that live in the
- * Enterprise section but aren't org-admin concerns.
- */
- allowNonOrgAdmin?: boolean
- /** Show in the sidebar even when the user lacks the required plan, with an upgrade badge. */
- showWhenLocked?: boolean
- /** Hide for enterprise plans, which manage billing out-of-band. */
- hideForEnterprise?: boolean
- externalUrl?: string
- /** Absolute docs URL surfaced as a "Docs" link in the page header. */
- docsLink?: string
-}
-
-const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
-const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
-const isCustomBlocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED'))
-const isInboxEnabled = isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED'))
-const isWhitelabelingEnabled = isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED'))
-const isAuditLogsEnabled = isTruthy(getEnv('NEXT_PUBLIC_AUDIT_LOGS_ENABLED'))
-const isDataRetentionEnabled = isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED'))
-const isDataDrainsEnabled = isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED'))
-
-export const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
-
-export const sectionConfig: { key: NavigationSection; title: string }[] = [
- { key: 'account', title: 'Account' },
- { key: 'tools', title: 'Tools' },
- { key: 'subscription', title: 'Subscription' },
- { key: 'system', title: 'System' },
- { key: 'enterprise', title: 'Enterprise' },
- { key: 'superuser', title: 'Superuser' },
-]
-
-export const allNavigationItems: NavigationItem[] = [
- {
- id: 'general',
- label: 'General',
- description: 'Manage your profile, appearance, and preferences.',
- icon: Settings,
- section: 'account',
- },
- {
- id: 'access-control',
- label: 'Access control',
- description: 'Manage permission groups across your organization.',
- icon: ShieldCheck,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- selfHostedOverride: isAccessControlEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/access-control',
- },
- {
- id: 'audit-logs',
- label: 'Audit logs',
- description: 'Review activity and changes across your organization.',
- icon: ClipboardList,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- selfHostedOverride: isAuditLogsEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/audit-logs',
- },
- {
- id: 'forks',
- label: 'Workspace Forks',
- description: 'Fork this workspace and sync changes with its parent.',
- icon: Shuffle,
- section: 'enterprise',
- docsLink: 'https://docs.sim.ai/platform/enterprise/forks',
- },
- {
- id: 'billing',
- label: 'Billing',
- description: 'Manage your plan, pricing, and invoices.',
- icon: ClipboardList,
- section: 'subscription',
- hideWhenBillingDisabled: true,
- },
- {
- id: 'teammates',
- label: 'Teammates',
- description: 'Manage your teammates in this workspace.',
- icon: User,
- section: 'subscription',
- },
- {
- id: 'organization',
- label: 'Organization',
- description: "Manage your organization's members and seats.",
- icon: Users,
- section: 'subscription',
- hideWhenBillingDisabled: true,
- requiresHosted: true,
- requiresTeam: true,
- },
- {
- id: 'secrets',
- label: 'Secrets',
- description: 'Store environment variables for your workflows.',
- icon: Key,
- section: 'account',
- },
- {
- id: 'custom-tools',
- label: 'Custom tools',
- description: 'Create and manage custom tools for your agents.',
- icon: Wrench,
- section: 'tools',
- },
- {
- id: 'mcp',
- label: 'MCP tools',
- description: 'Connect MCP servers and use their tools in workflows.',
- icon: McpIcon,
- section: 'tools',
- },
- {
- id: 'apikeys',
- label: 'Sim API keys',
- description: 'Create and manage API keys for the Sim API.',
- icon: TerminalWindow,
- section: 'system',
- },
- {
- id: 'workflow-mcp-servers',
- label: 'MCP servers',
- description: 'Expose your workflows as tools on an MCP server.',
- icon: Server,
- section: 'system',
- },
- {
- id: 'byok',
- label: 'BYOK',
- description: 'Bring your own model-provider API keys.',
- icon: KeySquare,
- section: 'system',
- requiresHosted: true,
- },
- {
- id: 'copilot',
- label: 'Chat keys',
- description: 'Manage the model-provider keys that power Chat.',
- icon: HexSimple,
- section: 'system',
- requiresHosted: true,
- },
- {
- id: 'inbox',
- label: 'Sim mailer',
- description: 'Trigger and process workflows from incoming email.',
- icon: Send,
- section: 'system',
- requiresMax: true,
- requiresHosted: true,
- selfHostedOverride: isInboxEnabled,
- showWhenLocked: true,
- },
- {
- id: 'recently-deleted',
- label: 'Recently deleted',
- description: 'Restore items deleted in the last 30 days.',
- icon: TrashOutline,
- section: 'system',
- },
- {
- id: 'sso',
- label: 'Single sign-on',
- description: 'Configure single sign-on for your organization.',
- icon: LogIn,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- selfHostedOverride: isSSOEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/sso',
- },
- {
- id: 'data-retention',
- label: 'Data retention',
- description:
- 'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.',
- icon: Database,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- selfHostedOverride: isDataRetentionEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/data-retention',
- },
- {
- id: 'data-drains',
- label: 'Data drains',
- description: 'Stream your logs and events to external destinations.',
- icon: Upload,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- selfHostedOverride: isDataDrainsEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/data-drains',
- },
- {
- id: 'whitelabeling',
- label: 'Whitelabeling',
- description: 'Customize your workspace branding and appearance.',
- icon: Palette,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- selfHostedOverride: isWhitelabelingEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling',
- },
- {
- id: 'custom-blocks',
- label: 'Custom blocks',
- description: 'Publish workflows as reusable blocks for your organization.',
- icon: HexSimple,
- section: 'enterprise',
- requiresHosted: true,
- requiresEnterprise: true,
- allowNonOrgAdmin: true,
- selfHostedOverride: isCustomBlocksEnabled,
- docsLink: 'https://docs.sim.ai/platform/enterprise/custom-blocks',
- },
- {
- id: 'admin',
- label: 'Admin',
- description: 'Superuser administration and workspace tools.',
- icon: Lock,
- section: 'superuser',
- requiresAdminRole: true,
- },
- {
- id: 'mothership',
- label: 'Mothership',
- description: 'Internal Sim operations and license management.',
- icon: Server,
- section: 'superuser',
- requiresAdminRole: true,
- },
-]
-
-/**
- * Title + description for a settings section, the single source of truth used by
- * `SettingsPanel` to render the page header. Falls back to `null` for sections
- * that are gated off (callers render no title in that case).
- */
-export function getSettingsSectionMeta(
- section: SettingsSection
-): { label: string; description: string; docsLink?: string } | null {
- const item = allNavigationItems.find((navItem) => navItem.id === section)
- return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/page.tsx
index 66767871d3a..1f3d2776e9b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/page.tsx
@@ -1,4 +1,5 @@
import { redirect } from 'next/navigation'
+import { getWorkspaceSettingsHref } from '@/components/settings/navigation'
interface SettingsPageProps {
params: Promise<{
@@ -8,5 +9,5 @@ interface SettingsPageProps {
export default async function SettingsPage({ params }: SettingsPageProps) {
const { workspaceId } = await params
- redirect(`/workspace/${workspaceId}/settings/general`)
+ redirect(getWorkspaceSettingsHref(workspaceId, 'teammates'))
}
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx
new file mode 100644
index 00000000000..188e3a6523f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx
@@ -0,0 +1,143 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockHandleUpgrade, mockInvalidateQueries, mockRequestJson, mockToastError } = vi.hoisted(
+ () => ({
+ mockHandleUpgrade: vi.fn(),
+ mockInvalidateQueries: vi.fn(),
+ mockRequestJson: vi.fn(),
+ mockToastError: vi.fn(),
+ })
+)
+
+vi.mock('@sim/emcn', () => ({
+ toast: { error: mockToastError },
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+vi.mock('@/lib/billing/client/upgrade', () => ({
+ useSubscriptionUpgrade: () => ({ handleUpgrade: mockHandleUpgrade }),
+}))
+
+import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
+import {
+ type UpgradeState,
+ useUpgradeState,
+} from '@/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state'
+
+const HOST_CONTEXT: WorkspaceHostContext = {
+ workspace: {
+ id: 'workspace-b',
+ name: 'Workspace B',
+ workspaceMode: 'organization',
+ billedAccountUserId: 'owner-b',
+ },
+ hostOrganizationId: 'org-b',
+ ownerBilling: {
+ plan: 'team_25000',
+ status: 'active',
+ isPaid: true,
+ isPro: false,
+ isTeam: true,
+ isEnterprise: false,
+ isOrgScoped: true,
+ organizationId: 'org-b',
+ billingInterval: 'month',
+ billingBlocked: false,
+ billingBlockedReason: null,
+ },
+ viewer: {
+ permission: 'admin',
+ isHostOrganizationMember: true,
+ isHostOrganizationAdmin: true,
+ },
+}
+
+let currentState: UpgradeState | null = null
+
+function Harness() {
+ currentState = useUpgradeState({
+ hostContext: HOST_CONTEXT,
+ workspaceId: HOST_CONTEXT.workspace.id,
+ })
+ return null
+}
+
+let container: HTMLDivElement
+let root: Root
+
+describe('useUpgradeState', () => {
+ beforeEach(() => {
+ currentState = null
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockHandleUpgrade.mockResolvedValue(undefined)
+ mockInvalidateQueries.mockResolvedValue(undefined)
+ mockRequestJson.mockResolvedValue({ success: true })
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('derives plan state from the routed workspace host', async () => {
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(currentState?.subscription.plan).toBe('team_25000')
+ expect(currentState?.subscription.isOrgScoped).toBe(true)
+ expect(currentState?.isOnMax).toBe(true)
+ })
+
+ it('targets the host organization when upgrading the workspace plan', async () => {
+ await act(async () => {
+ root.render( )
+ })
+
+ await act(async () => {
+ await currentState?.doUpgrade('team', 25000)
+ })
+
+ expect(mockHandleUpgrade).toHaveBeenCalledWith('team', {
+ creditTier: 25000,
+ annual: false,
+ organizationId: 'org-b',
+ })
+ })
+
+ it('includes the routed workspace when switching the host billing interval', async () => {
+ await act(async () => {
+ root.render( )
+ })
+
+ await act(async () => {
+ await currentState?.handleSwitchInterval('year')
+ })
+
+ expect(mockRequestJson).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ body: {
+ targetPlanName: 'team_25000',
+ interval: 'year',
+ workspaceId: 'workspace-b',
+ },
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts
index 548471e86ef..1792360d7d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts
@@ -1,29 +1,26 @@
'use client'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { toast } from '@sim/emcn'
-import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
+import { useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
+import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
import { CREDIT_TIERS } from '@/lib/billing/constants'
-import {
- getPlanTierCredits,
- isEnterprise,
- isFree,
- isPaid,
- isPro,
- isTeam,
-} from '@/lib/billing/plan-helpers'
-import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
-import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/upgrade/subscription-permissions'
-import { useSubscriptionData } from '@/hooks/queries/subscription'
+import { getPlanTierCredits, isEnterprise, isFree, isPro, isTeam } from '@/lib/billing/plan-helpers'
+import { workspaceHostKeys } from '@/hooks/queries/workspace-host'
const PRO_TIER = CREDIT_TIERS[0]
const MAX_TIER = CREDIT_TIERS[1]
type TargetPlan = 'pro' | 'team'
+interface UseUpgradeStateOptions {
+ hostContext: WorkspaceHostContext
+ workspaceId: string
+}
+
export interface UpgradeState {
isLoading: boolean
isAnnual: boolean
@@ -45,7 +42,7 @@ export interface UpgradeState {
isOnMax: boolean
isOnMaxTier: boolean
wantsIntervalSwitch: boolean
- doUpgrade: (targetPlan: 'pro' | 'team', creditTier: number, seats?: number) => Promise
+ doUpgrade: (targetPlan: 'pro' | 'team', creditTier: number) => Promise
handleSwitchInterval: (interval: 'month' | 'year') => Promise
upgradeOrSwitchToMax: () => Promise
onUpgradeToOtherTier: () => Promise
@@ -59,86 +56,62 @@ export interface UpgradeState {
* Plan and billing management (payment method, cancellation, invoices, usage
* limits) lives on the Billing settings page, not here.
*/
-export function useUpgradeState(): UpgradeState {
+export function useUpgradeState({
+ hostContext,
+ workspaceId,
+}: UseUpgradeStateOptions): UpgradeState {
const { handleUpgrade } = useSubscriptionUpgrade()
-
- const {
- data: subscriptionData,
- isLoading,
- refetch: refetchSubscription,
- } = useSubscriptionData({ includeOrg: true })
-
- const [isAnnual, setIsAnnual] = useState(true)
- const hasInitializedInterval = useRef(false)
+ const queryClient = useQueryClient()
+ const { ownerBilling } = hostContext
+ const [isAnnual, setIsAnnual] = useState(
+ !ownerBilling.isPaid || ownerBilling.billingInterval === 'year'
+ )
const subscription = {
- isFree: isFree(subscriptionData?.data?.plan),
- isPro: isPro(subscriptionData?.data?.plan),
- isTeam: isTeam(subscriptionData?.data?.plan),
- isEnterprise: isEnterprise(subscriptionData?.data?.plan),
- isPaid:
- isPaid(subscriptionData?.data?.plan) &&
- hasPaidSubscriptionStatus(subscriptionData?.data?.status),
- /**
- * True when the subscription is attached to an org (regardless of plan
- * name). Feeds the permission resolution below.
- */
- isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped),
- plan: subscriptionData?.data?.plan || 'free',
- status: subscriptionData?.data?.status || 'inactive',
+ isFree: isFree(ownerBilling.plan),
+ isPro: isPro(ownerBilling.plan),
+ isTeam: isTeam(ownerBilling.plan),
+ isEnterprise: isEnterprise(ownerBilling.plan),
+ isPaid: ownerBilling.isPaid,
+ isOrgScoped: Boolean(hostContext.hostOrganizationId),
+ plan: ownerBilling.plan,
+ status: ownerBilling.status ?? 'inactive',
}
const isLegacyPlan = subscription.plan === 'pro' || subscription.plan === 'team'
/**
- * Sync the billing-period toggle to a paid subscriber's actual interval once
- * subscription data loads. Free/unsubscribed users keep the Annual default
- * (the API reports `billingInterval: 'month'` for them, which must not
- * override the default).
+ * Keeps the toggle aligned when the host context refreshes after a plan change.
*/
useEffect(() => {
- if (!hasInitializedInterval.current && subscription.isPaid) {
- hasInitializedInterval.current = true
- setIsAnnual(subscriptionData?.data?.billingInterval === 'year')
+ if (subscription.isPaid) {
+ setIsAnnual(ownerBilling.billingInterval === 'year')
}
- }, [subscription.isPaid, subscriptionData?.data?.billingInterval])
-
- const userRole = subscriptionData?.data?.organization?.role ?? 'member'
- const isTeamAdmin = isOrgAdminRole(userRole)
-
- const permissions = getSubscriptionPermissions(
- {
- isFree: subscription.isFree,
- isPro: subscription.isPro,
- isTeam: subscription.isTeam,
- isEnterprise: subscription.isEnterprise,
- isPaid: subscription.isPaid,
- isOrgScoped: subscription.isOrgScoped,
- plan: subscription.plan || 'free',
- status: subscription.status || 'inactive',
- },
- { isTeamAdmin, userRole: userRole || 'member' }
- )
+ }, [ownerBilling.billingInterval, subscription.isPaid])
- const showUpgradePlans = permissions.showUpgradePlans
+ const refreshHostContext = useCallback(
+ () => queryClient.invalidateQueries({ queryKey: workspaceHostKeys.detail(workspaceId) }),
+ [queryClient, workspaceId]
+ )
const doUpgrade = useCallback(
- async (targetPlan: TargetPlan, creditTier: number, seats?: number) => {
+ async (targetPlan: TargetPlan, creditTier: number) => {
try {
await handleUpgrade(targetPlan, {
creditTier,
annual: isAnnual,
- ...(seats ? { seats } : {}),
+ ...(hostContext.hostOrganizationId
+ ? { organizationId: hostContext.hostOrganizationId }
+ : {}),
})
} catch (error) {
toast.error(getErrorMessage(error, 'Unknown error occurred'))
}
},
- [handleUpgrade, isAnnual]
+ [handleUpgrade, hostContext.hostOrganizationId, isAnnual]
)
- const currentInterval: 'month' | 'year' =
- subscriptionData?.data?.billingInterval === 'year' ? 'year' : 'month'
+ const currentInterval = ownerBilling.billingInterval
const handleSwitchInterval = useCallback(
async (interval: 'month' | 'year') => {
@@ -148,11 +121,11 @@ export function useUpgradeState(): UpgradeState {
)
}
await requestJson(billingSwitchPlanContract, {
- body: { targetPlanName: subscription.plan, interval },
+ body: { targetPlanName: subscription.plan, interval, workspaceId },
})
- await refetchSubscription()
+ await refreshHostContext()
},
- [refetchSubscription, subscription.plan, isLegacyPlan]
+ [isLegacyPlan, refreshHostContext, subscription.plan, workspaceId]
)
const currentCredits = getPlanTierCredits(subscription.plan)
@@ -178,13 +151,14 @@ export function useUpgradeState(): UpgradeState {
body: {
targetPlanName: `${planType}_${MAX_TIER.credits}`,
interval: isAnnual ? 'year' : 'month',
+ workspaceId,
},
})
- await refetchSubscription()
+ await refreshHostContext()
} catch (e) {
toast.error(getErrorMessage(e, 'Failed to upgrade'))
}
- }, [subscription.isTeam, isAnnual, refetchSubscription])
+ }, [subscription.isTeam, isAnnual, refreshHostContext, workspaceId])
const onUpgradeToOtherTier = useCallback(async () => {
const onMax =
@@ -194,20 +168,20 @@ export function useUpgradeState(): UpgradeState {
const targetPlanName = `${planType}_${targetTier.credits}`
try {
await requestJson(billingSwitchPlanContract, {
- body: { targetPlanName },
+ body: { targetPlanName, workspaceId },
})
- await refetchSubscription()
+ await refreshHostContext()
} catch (e) {
toast.error(getErrorMessage(e, 'Failed to switch plan'))
}
- }, [subscription.plan, subscription.isTeam, refetchSubscription])
+ }, [subscription.plan, subscription.isTeam, refreshHostContext, workspaceId])
return {
- isLoading,
+ isLoading: false,
isAnnual,
setIsAnnual,
subscription,
- showUpgradePlans,
+ showUpgradePlans: !subscription.isEnterprise,
proTier: PRO_TIER,
maxTier: MAX_TIER,
isOnPro,
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
index 62261094dc3..8feb6e8e991 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
@@ -5,6 +5,7 @@ import { ArrowLeft, Chip, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
+import { useSession } from '@/lib/auth/auth-client'
import {
getUpgradeCardCta,
type PlanCardCta,
@@ -13,7 +14,9 @@ import {
} from '@/lib/billing/client'
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import {
BillingPeriodToggle,
ComparisonTable,
@@ -50,8 +53,10 @@ export interface UpgradeProps {
* Billing settings page.
*/
export function Upgrade({ workspaceId }: UpgradeProps) {
- const state = useUpgradeState()
const router = useRouter()
+ const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
+ const state = useUpgradeState({ hostContext, workspaceId })
const origin = useFullscreenOriginStore((s) => s.origin)
const [reason] = useQueryState(upgradeReasonParam.key, {
...upgradeReasonParam.parser,
@@ -60,6 +65,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
const [showAllFeatures, setShowAllFeatures] = useState(false)
const header = reason ? UPGRADE_REASON_COPY[reason].header : DEFAULT_UPGRADE_HEADER
+ const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id)
const handleBack = useCallback(() => {
router.replace(origin ?? `/workspace/${workspaceId}/home`)
@@ -72,15 +78,46 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
router.replace(`/workspace/${workspaceId}/home`)
return
}
- if (!state.isLoading && state.subscription.isEnterprise) {
+ if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) {
router.replace(`/workspace/${workspaceId}/home`)
}
- }, [state.isLoading, state.subscription.isEnterprise, router, workspaceId])
+ }, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId])
- if (!isBillingEnabled || state.isLoading || state.subscription.isEnterprise) return null
+ if (
+ !isBillingEnabled ||
+ state.isLoading ||
+ (canManageBilling && state.subscription.isEnterprise)
+ ) {
+ return null
+ }
+
+ if (!canManageBilling) {
+ const description = hostContext.hostOrganizationId
+ ? `Plans for ${hostContext.workspace.name} are managed by its organization administrators. Contact an organization admin to change this workspace’s plan.`
+ : `Only the owner of ${hostContext.workspace.name} can change this workspace’s plan.`
+
+ return (
+
+
+
+ Back
+
+
+
+
+
+ Workspace plans unavailable
+
+
{description}
+
+
+
+ )
+ }
// Enterprise is redirected above, so the current plan is only ever free/pro/max here.
const planTier: PlanTier = state.subscription.isFree ? 'free' : state.isOnMaxTier ? 'max' : 'pro'
+ const checkoutTarget = state.subscription.isOrgScoped ? 'team' : 'pro'
/**
* Resolve a card's CTA from the canonical matrix, then bind it to the matching
@@ -120,9 +157,9 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
case 'upgrade':
if (card === 'max') {
if (state.subscription.isPaid) void state.upgradeOrSwitchToMax()
- else state.doUpgrade('pro', state.maxTier.credits)
+ else state.doUpgrade(checkoutTarget, state.maxTier.credits)
} else {
- state.doUpgrade('pro', state.proTier.credits)
+ state.doUpgrade(checkoutTarget, state.proTier.credits)
}
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx
new file mode 100644
index 00000000000..682e8ddc52b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx
@@ -0,0 +1,381 @@
+/**
+ * @vitest-environment jsdom
+ */
+import {
+ act,
+ type ButtonHTMLAttributes,
+ type InputHTMLAttributes,
+ type ReactNode,
+ type Ref,
+} from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ chatState,
+ mockAddMessage,
+ mockAppendMessageContent,
+ mockCreateObjectURL,
+ mockFileReader,
+ mockFinalizeMessageStream,
+ mockHandleRunWorkflow,
+ mockReadSSEEvents,
+ mockRevokeObjectURL,
+ WorkflowAttachmentUploadErrorMock,
+} = vi.hoisted(() => {
+ class WorkflowAttachmentUploadErrorMock extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'WorkflowAttachmentUploadError'
+ }
+ }
+
+ const mockAddMessage = vi.fn()
+ const mockAppendMessageContent = vi.fn()
+ const mockFinalizeMessageStream = vi.fn()
+
+ return {
+ chatState: {
+ isChatOpen: true,
+ chatPosition: null,
+ chatWidth: 305,
+ chatHeight: 286,
+ setIsChatOpen: vi.fn(),
+ setChatPosition: vi.fn(),
+ setChatDimensions: vi.fn(),
+ messages: [],
+ addMessage: mockAddMessage,
+ selectedWorkflowOutputs: {},
+ setSelectedWorkflowOutput: vi.fn(),
+ appendMessageContent: mockAppendMessageContent,
+ finalizeMessageStream: mockFinalizeMessageStream,
+ getConversationId: vi.fn(() => 'conversation-1'),
+ clearChat: vi.fn(),
+ exportChatCSV: vi.fn(),
+ },
+ mockAddMessage,
+ mockAppendMessageContent,
+ mockCreateObjectURL: vi.fn(),
+ mockFileReader: vi.fn(),
+ mockFinalizeMessageStream,
+ mockHandleRunWorkflow: vi.fn(),
+ mockReadSSEEvents: vi.fn(),
+ mockRevokeObjectURL: vi.fn(),
+ WorkflowAttachmentUploadErrorMock,
+ }
+})
+
+vi.mock('@sim/emcn', () => ({
+ Badge: ({
+ children,
+ className: _className,
+ ...props
+ }: ButtonHTMLAttributes) => {children} ,
+ Button: ({
+ children,
+ className: _className,
+ variant: _variant,
+ ...props
+ }: ButtonHTMLAttributes & { variant?: string }) => (
+ {children}
+ ),
+ cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
+ Input: ({
+ ref,
+ className: _className,
+ ...props
+ }: InputHTMLAttributes & { ref?: Ref }) => (
+
+ ),
+ Popover: ({ children }: { children: ReactNode }) => {children}
,
+ PopoverContent: ({ children }: { children: ReactNode }) => {children}
,
+ PopoverItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+ {children}
+ ),
+ PopoverScrollArea: ({ children }: { children: ReactNode }) => {children}
,
+ PopoverTrigger: ({ children }: { children: ReactNode }) => {children}
,
+ Tooltip: {
+ Root: ({ children }: { children: ReactNode }) => {children}
,
+ Trigger: ({ children }: { children: ReactNode }) => {children}
,
+ Content: ({ children }: { children: ReactNode }) => {children}
,
+ },
+ Trash: () => ,
+}))
+
+vi.mock('@sim/emcn/icons', () => ({
+ Download: () => ,
+}))
+
+vi.mock('lucide-react', () => ({
+ AlertCircle: () => ,
+ ArrowUp: () => ,
+ MoreVertical: () => ,
+ Paperclip: () => ,
+ Square: () => ,
+ X: () => ,
+}))
+
+vi.mock('zustand/react/shallow', () => ({
+ useShallow: (selector: (value: typeof chatState) => unknown) => selector,
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { id: 'user-1' } } }),
+}))
+
+vi.mock('@/lib/core/utils/response-format', () => ({
+ extractBlockIdFromOutputId: () => '',
+ extractPathFromOutputId: () => '',
+ parseOutputContentSafely: (value: unknown) => value,
+}))
+
+vi.mock('@/lib/core/utils/sse', () => ({
+ readSSEEvents: mockReadSSEEvents,
+}))
+
+vi.mock('@/lib/uploads/utils/validation', () => ({
+ CHAT_ACCEPT_ATTRIBUTE: '*/*',
+}))
+
+vi.mock('@/lib/workflows/input-format', () => ({
+ normalizeInputFormatValue: () => [],
+}))
+
+vi.mock('@/lib/workflows/triggers/triggers', () => ({
+ StartBlockPath: { UNIFIED: 'unified' },
+ TriggerUtils: { findStartBlock: () => null },
+}))
+
+vi.mock('@/lib/workflows/types', () => ({
+ START_BLOCK_RESERVED_FIELDS: [],
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components', () => ({
+ ChatMessage: () => null,
+ OutputSelect: () => null,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks', () => ({
+ usePreventZoom: () => ({ current: null }),
+ useScrollManagement: () => ({
+ scrollAreaRef: { current: null },
+ scrollToBottom: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float', () => ({
+ useFloatBoundarySync: vi.fn(),
+ useFloatDrag: () => ({ handleMouseDown: vi.fn() }),
+ useFloatResize: () => ({
+ cursor: null,
+ handleMouseMove: vi.fn(),
+ handleMouseLeave: vi.fn(),
+ handleMouseDown: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution', () => ({
+ isChatWorkflowRunResult: (value: unknown) =>
+ Boolean(
+ value &&
+ typeof value === 'object' &&
+ 'uploadedAttachments' in value &&
+ Array.isArray(value.uploadedAttachments)
+ ),
+ useWorkflowExecution: () => ({
+ handleRunWorkflow: mockHandleRunWorkflow,
+ handleCancelExecution: vi.fn(),
+ }),
+ WorkflowAttachmentUploadError: WorkflowAttachmentUploadErrorMock,
+}))
+
+vi.mock('@/stores/chat/store', () => ({
+ useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState),
+}))
+
+vi.mock('@/stores/execution', () => ({
+ useIsCurrentWorkflowExecuting: () => false,
+}))
+
+vi.mock('@/stores/operation-queue/store', () => ({
+ useOperationQueue: () => ({ addToQueue: vi.fn() }),
+}))
+
+vi.mock('@/stores/terminal', () => ({
+ useTerminalConsoleStore: (selector: (state: { _hasHydrated: boolean }) => unknown) =>
+ selector({ _hasHydrated: false }),
+ useWorkflowConsoleEntries: () => [],
+}))
+
+vi.mock('@/stores/workflows/registry/store', () => ({
+ useWorkflowRegistry: (selector: (state: { activeWorkflowId: string }) => unknown) =>
+ selector({ activeWorkflowId: 'workflow-1' }),
+}))
+
+vi.mock('@/stores/workflows/subblock/store', () => ({
+ useSubBlockStore: (
+ selector: (state: { workflowValues: Record; setValue: () => void }) => unknown
+ ) => selector({ workflowValues: {}, setValue: vi.fn() }),
+}))
+
+vi.mock('@/stores/workflows/workflow/store', () => ({
+ useWorkflowStore: (
+ selector: (state: { blocks: Record; triggerUpdate: () => void }) => unknown
+ ) => selector({ blocks: {}, triggerUpdate: vi.fn() }),
+}))
+
+vi.mock('@/stores/chat/utils', () => ({
+ getChatPosition: () => ({ x: 0, y: 0 }),
+}))
+
+import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
+
+let container: HTMLDivElement
+let root: Root
+let isMounted: boolean
+
+function setInputValue(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ setter?.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+async function addMessageAndAttachment(message: string, file: File) {
+ const messageInput = container.querySelector(
+ 'input[placeholder="Type a message..."]'
+ )
+ const fileInput = container.querySelector('#floating-chat-file-input')
+ if (!messageInput || !fileInput) throw new Error('Expected chat inputs')
+
+ await act(async () => {
+ setInputValue(messageInput, message)
+ Object.defineProperty(fileInput, 'files', {
+ configurable: true,
+ value: [file],
+ })
+ fileInput.dispatchEvent(new Event('change', { bubbles: true }))
+ await Promise.resolve()
+ })
+}
+
+async function sendMessage() {
+ const sendButton = Array.from(container.querySelectorAll('button')).find((button) =>
+ button.querySelector('[data-icon="ArrowUp"]')
+ )
+ if (!sendButton) throw new Error('Expected send button')
+
+ await act(async () => {
+ sendButton.click()
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
+describe('floating chat attachment uploads', () => {
+ beforeEach(async () => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+ mockCreateObjectURL.mockReturnValue('blob:diagram-preview')
+ mockReadSSEEvents.mockResolvedValue(undefined)
+ vi.stubGlobal('FileReader', mockFileReader)
+ class TestURL extends URL {}
+ TestURL.createObjectURL = mockCreateObjectURL
+ TestURL.revokeObjectURL = mockRevokeObjectURL
+ vi.stubGlobal('URL', TestURL)
+
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ isMounted = true
+
+ await act(async () => {
+ root.render( )
+ })
+ })
+
+ afterEach(() => {
+ if (isMounted) {
+ act(() => root.unmount())
+ }
+ container.remove()
+ vi.runOnlyPendingTimers()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ it('uses uploaded URLs for message previews without base64 conversion', async () => {
+ const file = new File(['diagram'], 'diagram.png', { type: 'image/png' })
+ mockHandleRunWorkflow.mockResolvedValue({
+ success: true,
+ stream: new ReadableStream({ start: (controller) => controller.close() }),
+ uploadedAttachments: [
+ {
+ id: 'uploaded-1',
+ name: 'diagram.png',
+ url: '/api/files/serve/execution%2Fdiagram.png',
+ size: file.size,
+ type: file.type,
+ key: 'execution/diagram.png',
+ context: 'execution',
+ },
+ ],
+ })
+
+ await addMessageAndAttachment('Describe this diagram', file)
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(file)
+
+ await sendMessage()
+
+ expect(mockFileReader).not.toHaveBeenCalled()
+ const workflowInput = mockHandleRunWorkflow.mock.calls[0]?.[0] as Record
+ expect(workflowInput).not.toHaveProperty('onUploadError')
+ expect(Object.values(workflowInput).some((value) => typeof value === 'function')).toBe(false)
+
+ const userMessage = mockAddMessage.mock.calls
+ .map(([message]) => message as { type: string; attachments?: unknown[] })
+ .find((message) => message.type === 'user')
+ expect(userMessage?.attachments).toEqual([
+ {
+ id: 'uploaded-1',
+ filename: 'diagram.png',
+ media_type: 'image/png',
+ size: file.size,
+ previewUrl: '/api/files/serve/execution%2Fdiagram.png',
+ },
+ ])
+ expect(container.textContent).not.toContain('diagram.png')
+ expect(
+ container.querySelector('input[placeholder="Type a message..."]')?.value
+ ).toBe('')
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:diagram-preview')
+ })
+
+ it('surfaces the exact error and preserves text and files for retry', async () => {
+ const file = new File(['report'], 'report.png', { type: 'image/png' })
+ mockHandleRunWorkflow.mockRejectedValue(
+ new WorkflowAttachmentUploadErrorMock(
+ 'Failed to upload report.png: Workspace file storage limit exceeded'
+ )
+ )
+
+ await addMessageAndAttachment('Summarize this report', file)
+ await sendMessage()
+
+ expect(mockFileReader).not.toHaveBeenCalled()
+ expect(
+ container.querySelector('input[placeholder="Type a message..."]')?.value
+ ).toBe('Summarize this report')
+ expect(container.querySelector('img[alt="report.png"]')).not.toBeNull()
+ expect(container.textContent).toContain(
+ 'Failed to upload report.png: Workspace file storage limit exceeded'
+ )
+ expect(
+ mockAddMessage.mock.calls.some(([message]) => (message as { type?: string }).type === 'user')
+ ).toBe(false)
+ expect(mockRevokeObjectURL).not.toHaveBeenCalled()
+
+ act(() => root.unmount())
+ isMounted = false
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:diagram-preview')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
index bb5a4180da8..b9a4e815578 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
@@ -35,7 +35,11 @@ import {
ChatMessage,
OutputSelect,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components'
-import { useChatFileUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks'
+import {
+ type ChatFile,
+ MAX_CHAT_FILES,
+ useChatFileUpload,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks'
import {
usePreventZoom,
useScrollManagement,
@@ -45,7 +49,15 @@ import {
useFloatDrag,
useFloatResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
-import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
+import {
+ isChatWorkflowRunResult,
+ useWorkflowExecution,
+ WorkflowAttachmentUploadError,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
+import type {
+ UploadedWorkflowAttachment,
+ WorkflowAttachmentInput,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { useChatStore } from '@/stores/chat/store'
import { getChatPosition } from '@/stores/chat/utils'
@@ -70,78 +82,70 @@ const formatFileSize = (bytes: number): string => {
return `${Math.round((bytes / 1024 ** i) * 10) / 10} ${units[i]}`
}
-/**
- * Represents a chat file attachment before processing
- */
-interface ChatFile {
- id: string
- name: string
- type: string
- size: number
- file: File
+function toChatMessageAttachments(
+ uploadedAttachments: UploadedWorkflowAttachment[]
+): ChatMessageAttachment[] {
+ return uploadedAttachments.map((file) => {
+ const supportsPreview = file.type.startsWith('image/') || file.type.startsWith('video/')
+ return {
+ id: file.id,
+ filename: file.name,
+ media_type: file.type,
+ size: file.size,
+ ...(supportsPreview ? { previewUrl: file.url } : {}),
+ }
+ })
}
-/** Timeout for FileReader operations in milliseconds */
-const FILE_READ_TIMEOUT_MS = 60000
+interface ChatFilePreviewProps {
+ file: ChatFile
+ onRemove: (fileId: string) => void
+}
/**
- * Reads files and converts them to data URLs for image display
- * @param chatFiles - Array of chat files to process
- * @returns Promise resolving to array of files with data URLs for images
+ * Uses a short-lived object URL only while an image remains in the composer.
+ * Successful uploads replace it with the storage-backed URL in message history.
*/
-const processFileAttachments = async (chatFiles: ChatFile[]): Promise => {
- return Promise.all(
- chatFiles.map(async (file) => {
- let previewUrl: string | undefined
- if (file.type.startsWith('image/')) {
- try {
- previewUrl = await new Promise((resolve, reject) => {
- const reader = new FileReader()
- let settled = false
-
- const timeoutId = setTimeout(() => {
- if (!settled) {
- settled = true
- reader.abort()
- reject(new Error(`File read timed out after ${FILE_READ_TIMEOUT_MS}ms`))
- }
- }, FILE_READ_TIMEOUT_MS)
+function ChatFilePreview({ file, onRemove }: ChatFilePreviewProps) {
+ const [previewUrl, setPreviewUrl] = useState(null)
- reader.onload = () => {
- if (!settled) {
- settled = true
- clearTimeout(timeoutId)
- resolve(reader.result as string)
- }
- }
- reader.onerror = () => {
- if (!settled) {
- settled = true
- clearTimeout(timeoutId)
- reject(reader.error)
- }
- }
- reader.onabort = () => {
- if (!settled) {
- settled = true
- clearTimeout(timeoutId)
- reject(new Error('File read aborted'))
- }
- }
- reader.readAsDataURL(file.file)
- })
- } catch (error) {
- logger.error('Error reading file as data URL:', error)
- }
- }
- return {
- id: file.id,
- filename: file.name,
- media_type: file.type,
- size: file.size,
- previewUrl,
- }
- })
+ useEffect(() => {
+ if (!file.type.startsWith('image/')) return
+
+ const objectUrl = URL.createObjectURL(file.file)
+ setPreviewUrl(objectUrl)
+ return () => URL.revokeObjectURL(objectUrl)
+ }, [file.file, file.type])
+
+ return (
+
+ {previewUrl ? (
+
+ ) : (
+
+
{file.name}
+
{formatFileSize(file.size)}
+
+ )}
+
+
{
+ event.stopPropagation()
+ onRemove(file.id)
+ }}
+ className='absolute top-0.5 right-0.5 size-4 p-0 opacity-0 transition-opacity group-hover:opacity-100'
+ >
+
+
+
)
}
@@ -283,6 +287,7 @@ export function Chat() {
uploadErrors,
isDragOver,
removeFile,
+ reportUploadError,
clearFiles,
clearErrors,
handleFileInputChange,
@@ -292,38 +297,6 @@ export function Chat() {
handleDrop,
} = useChatFileUpload()
- const filePreviewUrls = useRef>(new Map())
-
- const getFilePreviewUrl = useCallback((file: ChatFile): string | null => {
- if (!file.type.startsWith('image/')) return null
-
- const existing = filePreviewUrls.current.get(file.id)
- if (existing) return existing
-
- const url = URL.createObjectURL(file.file)
- filePreviewUrls.current.set(file.id, url)
- return url
- }, [])
-
- useEffect(() => {
- const currentFileIds = new Set(chatFiles.map((f) => f.id))
- const urlMap = filePreviewUrls.current
-
- for (const [fileId, url] of urlMap.entries()) {
- if (!currentFileIds.has(fileId)) {
- URL.revokeObjectURL(url)
- urlMap.delete(fileId)
- }
- }
-
- return () => {
- for (const url of urlMap.values()) {
- URL.revokeObjectURL(url)
- }
- urlMap.clear()
- }
- }, [chatFiles])
-
/**
* Resolves the unified start block for chat execution, if available.
*/
@@ -673,31 +646,15 @@ export function Chat() {
if ((!chatMessage.trim() && chatFiles.length === 0) || !activeWorkflowId || isExecuting) return
const sentMessage = chatMessage.trim()
-
- if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
- setPromptHistory((prev) => [...prev, sentMessage])
- }
- setHistoryIndex(-1)
-
const conversationId = getConversationId(activeWorkflowId)
- try {
- const attachmentsWithData = await processFileAttachments(chatFiles)
-
- const messageContent =
- sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '')
- addMessage({
- content: messageContent,
- workflowId: activeWorkflowId,
- type: 'user',
- attachments: attachmentsWithData,
- })
+ clearErrors()
+ try {
const workflowInput: {
input: string
conversationId: string
- files?: Array<{ name: string; size: number; type: string; file: File }>
- onUploadError?: (message: string) => void
+ files?: WorkflowAttachmentInput[]
} = {
input: sentMessage,
conversationId,
@@ -710,20 +667,42 @@ export function Chat() {
type: chatFile.type,
file: chatFile.file,
}))
- workflowInput.onUploadError = (message: string) => {
- logger.error('File upload error:', message)
- }
}
+ const result = await handleRunWorkflow(workflowInput)
+ if (!isChatWorkflowRunResult(result)) {
+ focusInput(10)
+ return
+ }
+ const messageAttachments = toChatMessageAttachments(result.uploadedAttachments)
+
+ if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
+ setPromptHistory((prev) => [...prev, sentMessage])
+ }
+ setHistoryIndex(-1)
+
+ const messageContent =
+ sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '')
+ addMessage({
+ content: messageContent,
+ workflowId: activeWorkflowId,
+ type: 'user',
+ attachments: messageAttachments,
+ })
+
setChatMessage('')
clearFiles()
- clearErrors()
focusInput(10)
- const result = await handleRunWorkflow(workflowInput)
handleWorkflowResponse(result)
} catch (error) {
- logger.error('Error in handleSendMessage:', error)
+ if (error instanceof WorkflowAttachmentUploadError) {
+ reportUploadError(error.message)
+ } else {
+ logger.error('Error in handleSendMessage:', error)
+ }
+ focusInput(10)
+ return
}
focusInput(100)
@@ -738,6 +717,7 @@ export function Chat() {
handleRunWorkflow,
handleWorkflowResponse,
focusInput,
+ reportUploadError,
clearFiles,
clearErrors,
])
@@ -1032,49 +1012,9 @@ export function Chat() {
{/* File thumbnails */}
{chatFiles.length > 0 && (
- {chatFiles.map((file) => {
- const previewUrl = getFilePreviewUrl(file)
-
- return (
-
- {previewUrl ? (
-
- ) : (
-
-
- {file.name}
-
-
- {formatFileSize(file.size)}
-
-
- )}
-
-
{
- e.stopPropagation()
- removeFile(file.id)
- }}
- className='absolute top-0.5 right-0.5 size-4 p-0 opacity-0 transition-opacity group-hover:opacity-100'
- >
-
-
-
- )
- })}
+ {chatFiles.map((file) => (
+
+ ))}
)}
@@ -1101,7 +1041,7 @@ export function Chat() {
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
className={cn(
'!bg-transparent !border-0 cursor-pointer rounded-md p-[0px]',
- (!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
+ (!activeWorkflowId || isExecuting || chatFiles.length >= MAX_CHAT_FILES) &&
'cursor-not-allowed opacity-50'
)}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts
index a1b7370b851..9ef496c5baa 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts
@@ -1 +1,6 @@
-export { type ChatFile, useChatFileUpload } from './use-chat-file-upload'
+export {
+ type ChatFile,
+ MAX_CHAT_FILE_SIZE_BYTES,
+ MAX_CHAT_FILES,
+ useChatFileUpload,
+} from './use-chat-file-upload'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx
new file mode 100644
index 00000000000..fa04bf6a44b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.test.tsx
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ MAX_CHAT_FILE_SIZE_BYTES,
+ MAX_CHAT_FILES,
+ useChatFileUpload,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload'
+
+interface HookHarness {
+ result: () => ReturnType
+ unmount: () => void
+}
+
+function renderChatFileUploadHook(): HookHarness {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const container = document.createElement('div')
+ const root: Root = createRoot(container)
+ let latest: ReturnType
+
+ function Probe() {
+ latest = useChatFileUpload()
+ return null
+ }
+
+ act(() => {
+ root.render( )
+ })
+
+ return {
+ result: () => latest,
+ unmount: () => act(() => root.unmount()),
+ }
+}
+
+describe('useChatFileUpload execution errors', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ it('surfaces the exact upload error while retaining the attachment for retry', () => {
+ const { result, unmount } = renderChatFileUploadHook()
+ const file = new File(['report'], 'report.pdf', { type: 'application/pdf' })
+
+ act(() => {
+ result().addFiles([file])
+ vi.runAllTimers()
+ })
+
+ act(() => {
+ result().reportUploadError(
+ 'Failed to upload report.pdf: Workspace file storage limit exceeded'
+ )
+ })
+
+ expect(result().uploadErrors).toEqual([
+ 'Failed to upload report.pdf: Workspace file storage limit exceeded',
+ ])
+ expect(result().chatFiles).toHaveLength(1)
+ expect(result().chatFiles[0].file).toBe(file)
+
+ unmount()
+ })
+
+ it('retains at most fifteen explicit attachments', () => {
+ const { result, unmount } = renderChatFileUploadHook()
+ const files = Array.from(
+ { length: MAX_CHAT_FILES + 1 },
+ (_, index) => new File([`${index}`], `file-${index}.txt`, { type: 'text/plain' })
+ )
+
+ act(() => {
+ result().addFiles(files)
+ vi.runAllTimers()
+ })
+
+ expect(result().chatFiles).toHaveLength(MAX_CHAT_FILES)
+
+ unmount()
+ })
+
+ it('rejects attachments larger than ten megabytes', () => {
+ const { result, unmount } = renderChatFileUploadHook()
+ const file = new File(['oversized'], 'oversized.pdf', { type: 'application/pdf' })
+ Object.defineProperty(file, 'size', { value: MAX_CHAT_FILE_SIZE_BYTES + 1 })
+
+ act(() => {
+ result().addFiles([file])
+ vi.runAllTimers()
+ })
+
+ expect(result().chatFiles).toHaveLength(0)
+ expect(result().uploadErrors).toEqual(['oversized.pdf is too large (max 10MB)'])
+
+ unmount()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts
index d9c5c45a7ab..e76bf939dc3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts
@@ -9,8 +9,8 @@ export interface ChatFile {
file: File
}
-const MAX_FILES = 15
-const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
+export const MAX_CHAT_FILES = 15
+export const MAX_CHAT_FILE_SIZE_BYTES = 10 * 1024 * 1024
/**
* Hook for handling file uploads in the chat modal
@@ -29,14 +29,14 @@ export function useChatFileUpload() {
*/
const addFiles = useCallback((files: File[]) => {
setChatFiles((currentFiles) => {
- const remainingSlots = Math.max(0, MAX_FILES - currentFiles.length)
+ const remainingSlots = Math.max(0, MAX_CHAT_FILES - currentFiles.length)
const candidateFiles = files.slice(0, remainingSlots)
const errors: string[] = []
const validNewFiles: ChatFile[] = []
for (const file of candidateFiles) {
// Check file size
- if (file.size > MAX_FILE_SIZE) {
+ if (file.size > MAX_CHAT_FILE_SIZE_BYTES) {
errors.push(`${file.name} is too large (max 10MB)`)
continue
}
@@ -84,6 +84,13 @@ export function useChatFileUpload() {
setChatFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
+ /**
+ * Surface an execution-time upload failure without removing the selected files.
+ */
+ const reportUploadError = useCallback((message: string) => {
+ setUploadErrors([message])
+ }, [])
+
/**
* Clear all files
*/
@@ -166,6 +173,7 @@ export function useChatFileUpload() {
isDragOver,
addFiles,
removeFile,
+ reportUploadError,
clearFiles,
clearErrors,
handleFileInputChange,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
index 8bd6cba84e7..8d6d4f46ea2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
@@ -22,11 +22,11 @@ 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'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import {
releaseDeployAction,
tryAcquireDeployAction,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
index e051f58379f..4478ee7eb89 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
@@ -39,7 +39,7 @@ import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { getCanonicalBlocksByCategory } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
-import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
+import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints'
@@ -451,9 +451,8 @@ export const Toolbar = memo(
const workspaceId = params?.workspaceId as string | undefined
const currentWorkflowId = params?.workflowId as string | undefined
const { data: customBlocksData } = useCustomBlocks(workspaceId)
- // No-icon custom blocks fall back to the org's whitelabel logo, then the glyph.
- const { data: whitelabel } = useWhitelabelSettings(customBlocksData?.[0]?.organizationId)
- const fallbackIconUrl = whitelabel?.logoUrl ?? null
+ /** No-icon custom blocks use the access-authorized workspace host logo, then the glyph. */
+ const fallbackIconUrl = useOrgBrandConfig().logoUrl ?? null
const allTriggers = getTriggers()
const allBlocks = getBlocks()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts
index a94ace299c4..8b0ead03c46 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts
@@ -1,24 +1,20 @@
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
-import { useSubscriptionData } from '@/hooks/queries/subscription'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage'
+
+interface UseUsageLimitsOptions {
+ workspaceId: string
+}
/**
- * Simplified hook that uses React Query for usage limits.
- * Provides usage exceeded status from existing subscription data.
+ * Exposes the routed workspace's payer/member execution gate.
*/
-
-export function useUsageLimits(options?: {
- context?: 'user' | 'organization'
- organizationId?: string
- autoRefresh?: boolean
-}) {
- // For now, we only support user context via React Query
- // Organization context should use useOrganizationBilling directly
- const { data: subscriptionData, isLoading } = useSubscriptionData({ enabled: isBillingEnabled })
-
- const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false
+export function useUsageLimits({ workspaceId }: UseUsageLimitsOptions) {
+ const { data, isLoading } = useWorkspaceUsageGate(isBillingEnabled ? workspaceId : undefined)
return {
- usageExceeded,
+ usageExceeded: data?.isExceeded ?? false,
+ message: data?.message ?? null,
+ scope: data?.scope ?? null,
isLoading,
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
index 7871c4e6ec3..0831daffffb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
@@ -39,6 +39,7 @@ import {
} from '@/lib/api/contracts/copilot'
import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows'
import { useSession } from '@/lib/auth/auth-client'
+import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions'
import {
MOTHERSHIP_SEND_MESSAGE_EVENT,
type MothershipSendMessageDetail,
@@ -50,6 +51,7 @@ import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -133,6 +135,7 @@ export const Panel = memo(function Panel() {
focusSearch: () => void
} | null>(null)
const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
// State
const [isMenuOpen, setIsMenuOpen] = useState(false)
@@ -148,13 +151,7 @@ export const Panel = memo(function Panel() {
const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
- const { activeWorkflowId, hydration } = useWorkflowRegistry(
- useShallow((state) => ({
- activeWorkflowId: state.activeWorkflowId,
- hydration: state.hydration,
- }))
- )
- const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading'
+ const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
@@ -181,10 +178,12 @@ export const Panel = memo(function Panel() {
})
// Usage limits hook
- const { usageExceeded } = useUsageLimits({
- context: 'user',
- autoRefresh: !isRegistryLoading,
- })
+ const {
+ usageExceeded,
+ message: usageLimitMessage,
+ scope: usageLimitScope,
+ isLoading: isUsageGateLoading,
+ } = useUsageLimits({ workspaceId })
// Workflow execution hook
const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution()
@@ -210,12 +209,30 @@ export const Panel = memo(function Panel() {
* Runs the workflow with usage limit check
*/
const runWorkflow = useCallback(async () => {
+ if (isUsageGateLoading) return
+
if (usageExceeded) {
- openSubscriptionSettings()
+ const action = getWorkspaceUsageLimitAction(hostContext, session?.user?.id, {
+ message: usageLimitMessage,
+ scope: usageLimitScope,
+ })
+ if (action.type === 'manage-billing') {
+ openSubscriptionSettings()
+ } else {
+ toast.error(action.message)
+ }
return
}
await handleRunWorkflow()
- }, [usageExceeded, handleRunWorkflow])
+ }, [
+ usageExceeded,
+ usageLimitMessage,
+ usageLimitScope,
+ isUsageGateLoading,
+ hostContext,
+ session?.user?.id,
+ handleRunWorkflow,
+ ])
// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore(
@@ -598,7 +615,8 @@ export const Panel = memo(function Panel() {
const isLoadingPermissions = userPermissions.isLoading
const hasValidationErrors = false // TODO: Add validation logic if needed
const isWorkflowBlocked = isExecuting || hasValidationErrors
- const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))
+ const isButtonDisabled =
+ !isExecuting && (isUsageGateLoading || isWorkflowBlocked || (!canRun && !isLoadingPermissions))
/**
* Register global keyboard shortcuts using the central commands registry.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
new file mode 100644
index 00000000000..f62015495af
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
@@ -0,0 +1,473 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ DirectUploadErrorMock,
+ executionStoreState,
+ mockExecute,
+ mockFetch,
+ mockRunUploadStrategy,
+ terminalStoreState,
+ workflowBlocks,
+ workflowStoreState,
+} = vi.hoisted(() => {
+ class DirectUploadErrorMock extends Error {
+ constructor(
+ message: string,
+ public code: string
+ ) {
+ super(message)
+ this.name = 'DirectUploadError'
+ }
+ }
+
+ const workflowBlocks = {
+ start: {
+ id: 'start',
+ type: 'starter',
+ name: 'Start',
+ enabled: true,
+ subBlocks: {},
+ },
+ }
+ const idleExecution = {
+ status: 'idle',
+ isExecuting: false,
+ isDebugging: false,
+ activeBlockIds: new Set(),
+ pendingBlocks: [],
+ executor: null,
+ debugContext: null,
+ lastRunPath: new Map(),
+ lastRunEdges: new Map(),
+ currentExecutionId: null,
+ }
+ const executionStoreState = {
+ workflowExecutions: new Map([['workflow-1', idleExecution]]),
+ getWorkflowExecution: vi.fn(() => idleExecution),
+ getCurrentExecutionId: vi.fn(() => null),
+ getLastExecutionSnapshot: vi.fn(() => null),
+ setCurrentExecutionId: vi.fn(),
+ setIsExecuting: vi.fn(),
+ setIsDebugging: vi.fn(),
+ setPendingBlocks: vi.fn(),
+ setExecutor: vi.fn(),
+ setDebugContext: vi.fn(),
+ setActiveBlocks: vi.fn(),
+ setBlockRunStatus: vi.fn(),
+ setEdgeRunStatus: vi.fn(),
+ setLastExecutionSnapshot: vi.fn(),
+ clearLastExecutionSnapshot: vi.fn(),
+ }
+ const terminalStoreState = {
+ _hasHydrated: false,
+ toggleConsole: vi.fn(),
+ addConsole: vi.fn(),
+ updateConsole: vi.fn(),
+ cancelRunningEntries: vi.fn(),
+ finishRunningEntries: vi.fn(),
+ clearExecutionEntries: vi.fn(),
+ }
+ const workflowStoreState = {
+ blocks: workflowBlocks,
+ edges: [],
+ getWorkflowState: vi.fn(() => ({
+ blocks: workflowBlocks,
+ edges: [],
+ loops: {},
+ parallels: {},
+ })),
+ }
+
+ return {
+ DirectUploadErrorMock,
+ executionStoreState,
+ mockExecute: vi.fn(),
+ mockFetch: vi.fn(),
+ mockRunUploadStrategy: vi.fn(),
+ terminalStoreState,
+ workflowBlocks,
+ workflowStoreState,
+ }
+})
+
+vi.mock('@sim/emcn', () => ({
+ toast: { error: vi.fn() },
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: vi.fn(),
+}))
+
+vi.mock('@/lib/api/contracts/workflows', () => ({
+ cancelWorkflowExecutionContract: {},
+ workflowLogContract: {},
+}))
+
+vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
+ buildTraceSpans: () => ({ traceSpans: [], totalDuration: 0 }),
+}))
+
+vi.mock('@/lib/tokenization', () => ({
+ processStreamingBlockLogs: () => 0,
+}))
+
+vi.mock('@/lib/uploads/client/direct-upload', () => ({
+ DirectUploadError: DirectUploadErrorMock,
+ runUploadStrategy: mockRunUploadStrategy,
+}))
+
+vi.mock('@/lib/workflows/input-format', () => ({
+ collectInputFormatFiles: () => [],
+ isFileFieldType: () => false,
+}))
+
+vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
+ extractTriggerMockPayload: () => ({}),
+ selectBestTrigger: () => [],
+ triggerNeedsMockPayload: () => false,
+}))
+
+vi.mock('@/lib/workflows/triggers/triggers', () => ({
+ resolveStartCandidates: () => [],
+ StartBlockPath: {
+ SPLIT_API: 'split-api',
+ SPLIT_INPUT: 'split-input',
+ UNIFIED: 'unified',
+ LEGACY_STARTER: 'legacy-starter',
+ EXTERNAL_TRIGGER: 'external-trigger',
+ },
+ TriggerUtils: {
+ findStartBlock: () => ({ blockId: 'start' }),
+ getTriggerValidationMessage: () => 'Missing trigger',
+ },
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow', () => ({
+ useCurrentWorkflow: () => ({
+ blocks: workflowBlocks,
+ edges: [],
+ loops: {},
+ parallels: {},
+ isDiffMode: false,
+ }),
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils', () => ({
+ addHttpErrorConsoleEntry: vi.fn(),
+ createBlockEventHandlers: () => ({
+ onBlockStarted: vi.fn(),
+ onBlockCompleted: vi.fn(),
+ onBlockError: vi.fn(),
+ onBlockChildWorkflowStarted: vi.fn(),
+ }),
+ reconcileFinalBlockLogs: vi.fn(),
+ addExecutionErrorConsoleEntry: vi.fn(),
+ handleExecutionCancelledConsole: vi.fn(),
+ handleExecutionErrorConsole: vi.fn(),
+}))
+
+vi.mock('@/blocks', () => ({
+ getBlock: vi.fn(),
+}))
+
+vi.mock('@/executor/utils/errors', () => ({
+ hasExecutionResult: () => false,
+}))
+
+vi.mock('@/executor/utils/start-block', () => ({
+ coerceValue: (_type: string, value: unknown) => value,
+}))
+
+vi.mock('@/hooks/queries/subscription', () => ({
+ subscriptionKeys: { users: () => ['subscription', 'users'] },
+}))
+
+vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
+ getWorkflows: () => [],
+}))
+
+vi.mock('@/hooks/use-execution-stream', () => {
+ class SSEEventHandlerError extends Error {}
+ class SSEStreamInterruptedError extends Error {}
+
+ return {
+ isExecutionStreamHttpError: () => false,
+ SSEEventHandlerError,
+ SSEStreamInterruptedError,
+ useExecutionStream: () => ({
+ execute: mockExecute,
+ executeFromBlock: vi.fn(),
+ reconnect: vi.fn(),
+ cancel: vi.fn(),
+ cancelExecute: vi.fn(),
+ cancelReconnect: vi.fn(),
+ }),
+ }
+})
+
+vi.mock('@/serializer', () => ({
+ WorkflowValidationError: class WorkflowValidationError extends Error {},
+}))
+
+vi.mock('@/stores/chat/store', () => ({
+ useChatStore: {
+ getState: () => ({
+ getSelectedWorkflowOutput: () => [],
+ }),
+ },
+}))
+
+vi.mock('@/stores/execution', () => ({
+ defaultWorkflowExecutionState: executionStoreState.getWorkflowExecution('workflow-1'),
+ useExecutionStore: Object.assign(
+ (selector: (state: typeof executionStoreState) => unknown) => selector(executionStoreState),
+ { getState: () => executionStoreState }
+ ),
+}))
+
+vi.mock('@/stores/terminal', () => ({
+ clearExecutionPointer: vi.fn(),
+ consolePersistence: {
+ executionStarted: vi.fn(),
+ executionEnded: vi.fn(),
+ persist: vi.fn(),
+ },
+ loadExecutionPointer: vi.fn(),
+ saveExecutionPointer: vi.fn(),
+ useTerminalConsoleStore: Object.assign(
+ (selector: (state: typeof terminalStoreState) => unknown) => selector(terminalStoreState),
+ { getState: () => terminalStoreState }
+ ),
+}))
+
+vi.mock('@/stores/variables/store', () => ({
+ useVariablesStore: (selector: (state: Record) => unknown) =>
+ selector({
+ getVariablesByWorkflowId: () => [],
+ variables: [],
+ }),
+}))
+
+vi.mock('@/stores/workflow-diff', () => ({
+ useWorkflowDiffStore: (selector: (state: { isShowingDiff: boolean }) => unknown) =>
+ selector({ isShowingDiff: false }),
+}))
+
+vi.mock('@/stores/workflows/registry/store', () => ({
+ useWorkflowRegistry: (
+ selector: (state: {
+ activeWorkflowId: string
+ hydration: { workspaceId: string; phase: string }
+ }) => unknown
+ ) =>
+ selector({
+ activeWorkflowId: 'workflow-1',
+ hydration: { workspaceId: 'workspace-1', phase: 'ready' },
+ }),
+}))
+
+vi.mock('@/stores/workflows/utils', () => ({
+ mergeSubblockState: () => workflowBlocks,
+}))
+
+vi.mock('@/stores/workflows/workflow/store', () => ({
+ useWorkflowStore: Object.assign(
+ (selector: (state: typeof workflowStoreState) => unknown) => selector(workflowStoreState),
+ { getState: () => workflowStoreState }
+ ),
+}))
+
+import {
+ isChatWorkflowRunResult,
+ useWorkflowExecution,
+ WorkflowAttachmentUploadError,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
+
+interface HookHarness {
+ result: () => ReturnType
+ unmount: () => void
+}
+
+function renderWorkflowExecutionHook(): HookHarness {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ const container = document.createElement('div')
+ const root: Root = createRoot(container)
+ let latest: ReturnType
+
+ function Probe() {
+ latest = useWorkflowExecution()
+ return null
+ }
+
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ return {
+ result: () => latest,
+ unmount: () => act(() => root.unmount()),
+ }
+}
+
+async function drainStream(value: unknown): Promise {
+ if (!value || typeof value !== 'object' || !('stream' in value)) return
+ if (!(value.stream instanceof ReadableStream)) return
+
+ const reader = value.stream.getReader()
+ while (!(await reader.read()).done) {}
+}
+
+describe('useWorkflowExecution attachment uploads', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ mockRunUploadStrategy.mockRejectedValue(
+ new DirectUploadErrorMock('Server signaled fallback to API upload', 'FALLBACK_REQUIRED')
+ )
+ mockFetch.mockResolvedValue(
+ new Response(JSON.stringify({ error: 'Workspace file storage limit exceeded' }), {
+ status: 413,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ )
+ mockExecute.mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('does not execute and reports the exact server error when an explicit attachment fails', async () => {
+ const { result, unmount } = renderWorkflowExecutionHook()
+ const contextFile = new File(['context'], 'context.txt', { type: 'text/plain' })
+ const file = new File(['report'], 'report.pdf', { type: 'application/pdf' })
+ let uploadError: unknown
+
+ mockRunUploadStrategy.mockResolvedValueOnce({
+ key: 'executions/context.txt',
+ path: '/uploads/context.txt',
+ name: contextFile.name,
+ size: contextFile.size,
+ contentType: contextFile.type,
+ })
+
+ await act(async () => {
+ try {
+ await result().handleRunWorkflow({
+ input: 'Summarize this report',
+ conversationId: 'conversation-1',
+ files: [
+ {
+ name: contextFile.name,
+ size: contextFile.size,
+ type: contextFile.type,
+ file: contextFile,
+ },
+ {
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ file,
+ },
+ ],
+ })
+ } catch (error) {
+ uploadError = error
+ }
+ })
+
+ expect(uploadError).toBeInstanceOf(WorkflowAttachmentUploadError)
+ expect((uploadError as Error).message).toBe(
+ 'Failed to upload report.pdf: Workspace file storage limit exceeded'
+ )
+ expect(mockExecute).not.toHaveBeenCalled()
+
+ unmount()
+ })
+
+ it('returns uploaded metadata without mutating or leaking local input into execution', async () => {
+ const { result, unmount } = renderWorkflowExecutionHook()
+ const file = new File(['diagram'], 'diagram.png', { type: 'image/png' })
+ const workflowInput = {
+ input: 'Describe this diagram',
+ conversationId: 'conversation-1',
+ files: [
+ {
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ file,
+ },
+ ],
+ }
+ let runResult: unknown
+
+ mockRunUploadStrategy.mockResolvedValueOnce({
+ key: 'execution/diagram.png',
+ path: '/api/files/serve/execution%2Fdiagram.png',
+ name: file.name,
+ size: file.size,
+ contentType: file.type,
+ })
+
+ await act(async () => {
+ runResult = await result().handleRunWorkflow(workflowInput)
+ await drainStream(runResult)
+ })
+
+ expect(isChatWorkflowRunResult(runResult)).toBe(true)
+ if (!isChatWorkflowRunResult(runResult)) {
+ throw new Error('Expected a chat workflow run result')
+ }
+ expect(runResult.uploadedAttachments).toEqual([
+ expect.objectContaining({
+ name: 'diagram.png',
+ url: '/api/files/serve/execution%2Fdiagram.png',
+ size: file.size,
+ type: 'image/png',
+ key: 'execution/diagram.png',
+ }),
+ ])
+ expect(workflowInput.files[0].file).toBe(file)
+ expect(mockExecute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ input: expect.objectContaining({
+ input: 'Describe this diagram',
+ conversationId: 'conversation-1',
+ files: [
+ expect.objectContaining({
+ name: 'diagram.png',
+ url: '/api/files/serve/execution%2Fdiagram.png',
+ }),
+ ],
+ }),
+ })
+ )
+
+ unmount()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
index 1af3e15add3..e950ae934ed 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
@@ -3,7 +3,7 @@ import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
-import { generateId, generateShortId } from '@sim/utils/id'
+import { generateId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
@@ -11,7 +11,6 @@ import { requestJson } from '@/lib/api/client/request'
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
-import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events'
import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format'
import {
@@ -25,6 +24,11 @@ import {
TriggerUtils,
} from '@/lib/workflows/triggers/triggers'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
+import {
+ type UploadedWorkflowAttachment,
+ uploadWorkflowAttachments,
+ type WorkflowAttachmentInput,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload'
import {
addHttpErrorConsoleEntry,
type BlockEventHandlerConfig,
@@ -139,6 +143,37 @@ function normalizeErrorMessage(error: unknown): string {
return WORKFLOW_EXECUTION_FAILURE_MESSAGE
}
+interface ChatWorkflowInput {
+ input: unknown
+ files?: WorkflowAttachmentInput[]
+}
+
+function isChatWorkflowInput(value: unknown): value is ChatWorkflowInput {
+ return isRecord(value) && 'input' in value
+}
+
+export interface ChatWorkflowRunResult {
+ success: true
+ stream: ReadableStream
+ uploadedAttachments: UploadedWorkflowAttachment[]
+}
+
+export class WorkflowAttachmentUploadError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'WorkflowAttachmentUploadError'
+ }
+}
+
+export function isChatWorkflowRunResult(value: unknown): value is ChatWorkflowRunResult {
+ return (
+ isRecord(value) &&
+ value.success === true &&
+ value.stream instanceof ReadableStream &&
+ Array.isArray(value.uploadedAttachments)
+ )
+}
+
/**
* Builds the manual-run workflow input from a trigger's inputFormat subblock.
* Named fields are coerced by type; file-typed fields are excluded as named
@@ -474,7 +509,7 @@ export function useWorkflowExecution() {
}
const handleRunWorkflow = useCallback(
- async (workflowInput?: any, enableDebug = false) => {
+ async (workflowInput?: unknown, enableDebug = false) => {
if (!activeWorkflowId) return
const scopedWorkspaceId = routeWorkspaceId ?? hydrationWorkspaceId ?? undefined
@@ -498,14 +533,36 @@ export function useWorkflowExecution() {
}
// Determine if this is a chat execution
- const isChatExecution =
- workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput
+ const isChatExecution = isChatWorkflowInput(workflowInput)
// For chat executions, we'll use a streaming approach
if (isChatExecution) {
let isCancelled = false
const executionId = generateId()
let preserveChatExecutionForRecovery = false
+ let preparedWorkflowInput: unknown = workflowInput
+ let uploadedAttachments: UploadedWorkflowAttachment[] = []
+
+ if (workflowInput.files && workflowInput.files.length > 0) {
+ try {
+ uploadedAttachments = await uploadWorkflowAttachments({
+ files: workflowInput.files,
+ workspaceId,
+ workflowId: activeWorkflowId,
+ executionId,
+ })
+ preparedWorkflowInput = { ...workflowInput, files: uploadedAttachments }
+ } catch (error) {
+ const message = getErrorMessage(error, 'Unexpected error uploading files')
+ logger.error('Error uploading workflow attachments', { message })
+ currentChatExecutionIdRef.current = null
+ setIsExecuting(activeWorkflowId, false)
+ setIsDebugging(activeWorkflowId, false)
+ setActiveBlocks(activeWorkflowId, new Set())
+ throw new WorkflowAttachmentUploadError(message)
+ }
+ }
+
currentChatExecutionIdRef.current = executionId
const stream = new ReadableStream({
async start(controller) {
@@ -523,109 +580,6 @@ export function useWorkflowExecution() {
}
}
- // Handle file uploads if present
- const uploadedFiles: any[] = []
- interface UploadErrorCapableInput {
- onUploadError: (message: string) => void
- }
- const isUploadErrorCapable = (value: unknown): value is UploadErrorCapableInput =>
- !!value &&
- typeof value === 'object' &&
- 'onUploadError' in (value as any) &&
- typeof (value as any).onUploadError === 'function'
- if (workflowInput.files && Array.isArray(workflowInput.files)) {
- try {
- const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(activeWorkflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}`
- for (const fileData of workflowInput.files) {
- try {
- const result = await runUploadStrategy({
- file: fileData.file,
- workspaceId,
- context: 'execution',
- workflowId: activeWorkflowId,
- executionId,
- presignedEndpoint,
- })
- uploadedFiles.push({
- id: `file_${Date.now()}_${generateShortId(7)}`,
- name: fileData.file.name,
- url: result.path,
- size: fileData.file.size,
- type: fileData.file.type,
- key: result.key,
- context: 'execution',
- })
- } catch (uploadError) {
- if (
- uploadError instanceof DirectUploadError &&
- uploadError.code === 'FALLBACK_REQUIRED'
- ) {
- const formData = new FormData()
- formData.append('file', fileData.file)
- formData.append('context', 'execution')
- formData.append('workflowId', activeWorkflowId)
- formData.append('executionId', executionId)
- formData.append('workspaceId', workspaceId)
-
- // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
- const response = await fetch('/api/files/upload', {
- method: 'POST',
- body: formData,
- })
- if (!response.ok) {
- const errorData = await response.json().catch(() => null)
- const reason =
- errorData?.message || errorData?.error || `${response.status}`
- const message = `Failed to upload ${fileData.name}: ${reason}`
- logger.error(message)
- if (isUploadErrorCapable(workflowInput)) {
- try {
- workflowInput.onUploadError(message)
- } catch {}
- }
- continue
- }
- const uploadResult = await response.json()
- const processUploadResult = (r: any) => ({
- id: r.id || `file_${Date.now()}_${generateShortId(7)}`,
- name: r.name,
- url: r.url,
- size: r.size,
- type: r.type,
- key: r.key,
- context: r.context || 'execution',
- uploadedAt: r.uploadedAt,
- expiresAt: r.expiresAt,
- })
- if (uploadResult.files && Array.isArray(uploadResult.files)) {
- uploadedFiles.push(...uploadResult.files.map(processUploadResult))
- } else if (uploadResult.path || uploadResult.url) {
- uploadedFiles.push(processUploadResult(uploadResult))
- }
- } else {
- const message = `Failed to upload ${fileData.name}: ${toError(uploadError).message}`
- logger.error(message)
- if (isUploadErrorCapable(workflowInput)) {
- try {
- workflowInput.onUploadError(message)
- } catch {}
- }
- }
- }
- }
- workflowInput.files = uploadedFiles
- } catch (error) {
- logger.error('Error uploading files:', error)
- if (isUploadErrorCapable(workflowInput)) {
- try {
- workflowInput.onUploadError('Unexpected error uploading files')
- } catch {}
- }
- // Continue execution even if file upload fails
- workflowInput.files = []
- }
- }
-
const streamCompletionTimes = new Map()
const processedFirstChunk = new Set()
@@ -724,7 +678,7 @@ export function useWorkflowExecution() {
try {
const result = await executeWorkflow(
- workflowInput,
+ preparedWorkflowInput,
onStream,
executionId,
onBlockComplete,
@@ -843,7 +797,7 @@ export function useWorkflowExecution() {
isCancelled = true
},
})
- return { success: true, stream }
+ return { success: true, stream, uploadedAttachments } satisfies ChatWorkflowRunResult
}
const manualExecutionId = generateId()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts
new file mode 100644
index 00000000000..938366c71bb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts
@@ -0,0 +1,125 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateShortId } from '@sim/utils/id'
+import { isRecordLike } from '@sim/utils/object'
+import {
+ type ApiFallbackUploadMetadata,
+ uploadViaApiFallbackWithMetadata,
+} from '@/lib/uploads/client/api-fallback'
+import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
+
+export interface WorkflowAttachmentInput {
+ name: string
+ size: number
+ type: string
+ file: File
+}
+
+export interface UploadedWorkflowAttachment {
+ id: string
+ name: string
+ url: string
+ size: number
+ type: string
+ key?: string
+ context: 'execution'
+ uploadedAt?: string
+ expiresAt?: string
+}
+
+interface UploadWorkflowAttachmentsParams {
+ files: WorkflowAttachmentInput[]
+ workspaceId: string
+ workflowId: string
+ executionId: string
+}
+
+function getOptionalString(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined
+ const trimmed = value.trim()
+ return trimmed || undefined
+}
+
+function getDirectUploadFailureReason(error: unknown): string {
+ if (error instanceof DirectUploadError && isRecordLike(error.details)) {
+ const message =
+ getOptionalString(error.details.message) ?? getOptionalString(error.details.error)
+ if (message) return message
+ }
+
+ return getErrorMessage(error, 'Unknown upload error')
+}
+
+function normalizeFallbackUpload(
+ value: ApiFallbackUploadMetadata,
+ fallbackFile: WorkflowAttachmentInput
+): UploadedWorkflowAttachment {
+ return {
+ id: value.id ?? `file_${Date.now()}_${generateShortId(7)}`,
+ name: value.name ?? fallbackFile.name,
+ url: value.path,
+ size: typeof value.size === 'number' ? value.size : fallbackFile.size,
+ type: value.type ?? fallbackFile.type,
+ key: value.key,
+ context: 'execution',
+ uploadedAt: value.uploadedAt,
+ expiresAt: value.expiresAt,
+ }
+}
+
+/**
+ * Uploads every explicit workflow attachment before execution may begin.
+ *
+ * @throws An actionable, file-specific error if any attachment fails.
+ */
+export async function uploadWorkflowAttachments({
+ files,
+ workspaceId,
+ workflowId,
+ executionId,
+}: UploadWorkflowAttachmentsParams): Promise {
+ const uploadedFiles: UploadedWorkflowAttachment[] = []
+ const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(workflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}`
+
+ for (const fileData of files) {
+ try {
+ const result = await runUploadStrategy({
+ file: fileData.file,
+ workspaceId,
+ context: 'execution',
+ workflowId,
+ executionId,
+ presignedEndpoint,
+ })
+ uploadedFiles.push({
+ id: `file_${Date.now()}_${generateShortId(7)}`,
+ name: fileData.file.name,
+ url: result.path,
+ size: fileData.file.size,
+ type: fileData.file.type,
+ key: result.key,
+ context: 'execution',
+ })
+ } catch (uploadError) {
+ if (!(uploadError instanceof DirectUploadError) || uploadError.code !== 'FALLBACK_REQUIRED') {
+ throw new Error(
+ `Failed to upload ${fileData.name}: ${getDirectUploadFailureReason(uploadError)}`
+ )
+ }
+
+ try {
+ const fallbackResult = await uploadViaApiFallbackWithMetadata(fileData.file, 'execution', {
+ workflowId,
+ executionId,
+ workspaceId,
+ })
+ uploadedFiles.push(normalizeFallbackUpload(fallbackResult, fileData))
+ } catch (error) {
+ throw new Error(
+ `Failed to upload ${fileData.name}: ${getErrorMessage(error, 'Network error')}`
+ )
+ }
+ }
+ }
+
+ return uploadedFiles
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index f29d1f54637..aab7097a29f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -1,36 +1,23 @@
'use client'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn'
-import { useQueryClient } from '@tanstack/react-query'
-import { useParams, usePathname, useRouter } from 'next/navigation'
-import { useSession } from '@/lib/auth/auth-client'
-import { getSubscriptionAccessState } from '@/lib/billing/client'
-import { isEnterprise } from '@/lib/billing/plan-helpers'
-import { isHosted } from '@/lib/core/config/env-flags'
-import { getUserRole } from '@/lib/workspaces/organization'
-import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
-import {
- allNavigationItems,
- isBillingEnabled,
- sectionConfig,
-} from '@/app/workspace/[workspaceId]/settings/navigation'
+import { useMemo } from 'react'
+import { useParams, usePathname } from 'next/navigation'
import {
- SIDEBAR_ITEM_GAP_CLASS,
- SIDEBAR_SECTION_GAP_CLASS,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
-import { useSSOProviders } from '@/ee/sso/hooks/sso'
+ getWorkspaceSettingsHref,
+ parseSettingsPathSection,
+ resolveWorkspaceNavigation,
+ WORKSPACE_SETTINGS_GROUPS,
+ WORKSPACE_SETTINGS_ITEMS,
+ WORKSPACE_SETTINGS_PATH_ALIASES,
+} from '@/components/settings/navigation'
+import { SettingsSidebar as SharedSettingsSidebar } from '@/components/settings/settings-sidebar'
+import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isHosted } from '@/lib/core/config/env-flags'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useForkingAvailable } from '@/ee/workspace-forking/hooks/use-forking-available'
-import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
-import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
import { useInboxConfig } from '@/hooks/queries/inbox'
-import { useOrganizations } from '@/hooks/queries/organization'
-import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
-import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
interface SettingsSidebarProps {
isCollapsed?: boolean
@@ -41,343 +28,53 @@ export function SettingsSidebar({
isCollapsed = false,
showCollapsedTooltips = false,
}: SettingsSidebarProps) {
- const scrollContainerRef = useRef(null)
- const scrollContentRef = useRef(null)
-
- const params = useParams()
- const workspaceId = params.workspaceId as string
+ const params = useParams<{ workspaceId: string }>()
const pathname = usePathname()
- const router = useRouter()
-
- const queryClient = useQueryClient()
-
- const requestLeave = useSettingsDirtyStore((s) => s.requestLeave)
- const confirmLeave = useSettingsDirtyStore((s) => s.confirmLeave)
- const cancelLeave = useSettingsDirtyStore((s) => s.cancelLeave)
- const pendingLeave = useSettingsDirtyStore((s) => s.pendingLeave)
- const showDiscardDialog = pendingLeave !== null
-
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
-
- const { data: session } = useSession()
- const { data: organizationsData } = useOrganizations()
- const { data: generalSettings } = useGeneralSettings()
- const { data: subscriptionData } = useSubscriptionData({
- enabled: isBillingEnabled,
- staleTime: 5 * 60 * 1000,
- })
- const { data: inboxConfig } = useInboxConfig(workspaceId)
- const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({
- enabled: !isHosted,
- })
-
- const activeOrganization = organizationsData?.activeOrganization
+ const workspaceId = params.workspaceId
+ const hostContext = useWorkspaceHostContext()
const { config: permissionConfig } = usePermissionConfig()
- // Mirrors the fork EE gate: the WORKSPACE's plan (not the viewer's) plus
- // workspace admin - matching the Forks page's own gate and the server check.
+ const { data: inboxConfig } = useInboxConfig(workspaceId)
const forkingAvailable = useForkingAvailable(workspaceId)
- const { canAdmin: canAdminWorkspace } = useUserPermissionsContext()
-
- const userEmail = session?.user?.email
- const userId = session?.user?.id
-
- const userRole = getUserRole(activeOrganization, userEmail)
- const isOwner = userRole === 'owner'
- const isAdmin = userRole === 'admin'
- const isOrgAdminOrOwner = isOwner || isAdmin
- const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
- const inboxEntitled = inboxConfig?.entitled ?? false
- const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
- const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
- const isEnterprisePlan = isEnterprise(subscriptionData?.data?.plan)
-
- const isSuperUser = session?.user?.role === 'admin'
-
- const isSSOProviderOwner = useMemo(() => {
- if (isHosted) return null
- if (!userId || isLoadingSSO) return null
- return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false
- }, [userId, ssoProvidersData?.providers, isLoadingSSO])
-
- const navigationItems = useMemo(() => {
- return allNavigationItems.filter((item) => {
- if (item.hideWhenBillingDisabled && !isBillingEnabled) {
- return false
- }
-
- if (item.hideForEnterprise && isEnterprisePlan) {
- return false
- }
-
- if (item.id === 'secrets' && permissionConfig.hideSecretsTab) {
- return false
- }
- if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
- return false
- }
- if (item.id === 'inbox' && permissionConfig.hideInboxTab) {
- return false
- }
- if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
- return false
- }
- if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
- return false
- }
- if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) {
- return false
- }
-
- if (item.selfHostedOverride && !isHosted) {
- if (item.id === 'sso') {
- const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
- return !hasProviders || isSSOProviderOwner === true
- }
- return true
- }
-
- const orgAdminSatisfied = isOrgAdminOrOwner || item.allowNonOrgAdmin
-
- if (item.requiresTeam && (!hasTeamPlan || !orgAdminSatisfied)) {
- return false
- }
-
- if (
- item.requiresEnterprise &&
- (!hasEnterprisePlan || !orgAdminSatisfied) &&
- !item.showWhenLocked
- ) {
- return false
- }
-
- if (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess && !item.showWhenLocked) {
- return false
- }
-
- if (item.requiresHosted && !isHosted) {
- return false
- }
-
- const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
- const effectiveSuperUser = isSuperUser && superUserModeEnabled
- if (item.requiresSuperUser && !effectiveSuperUser) {
- return false
- }
-
- if (item.requiresAdminRole && !isSuperUser) {
- return false
- }
-
- return true
- })
- }, [
- hasTeamPlan,
- hasEnterprisePlan,
- isEnterprisePlan,
- subscriptionAccess.hasUsableMaxAccess,
- isOrgAdminOrOwner,
- isSSOProviderOwner,
- ssoProvidersData?.providers?.length,
- permissionConfig,
- isSuperUser,
- generalSettings?.superUserModeEnabled,
- forkingAvailable,
- canAdminWorkspace,
- ])
-
- const activeSection = useMemo(() => {
- const segments = pathname?.split('/') ?? []
- const settingsIdx = segments.indexOf('settings')
- if (settingsIdx !== -1 && segments[settingsIdx + 1]) {
- return segments[settingsIdx + 1] as SettingsSection
- }
- return 'general'
- }, [pathname])
-
- const handlePrefetch = useCallback(
- (itemId: string) => {
- switch (itemId) {
- case 'general':
- prefetchGeneralSettings(queryClient)
- void import('@/app/workspace/[workspaceId]/settings/components/general/general')
- break
- case 'secrets':
- prefetchWorkspaceCredentials(queryClient, workspaceId)
- void import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets')
- break
- case 'billing':
- prefetchSubscriptionData(queryClient)
- void import('@/app/workspace/[workspaceId]/settings/components/billing/billing')
- break
- }
- },
- [queryClient, workspaceId]
+ const { popSettingsReturnUrl } = useSettingsNavigation()
+ const customBlocksAvailable = isHosted
+ ? hostContext.ownerBilling.isEnterprise
+ : isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED'))
+
+ const items = useMemo(
+ () =>
+ resolveWorkspaceNavigation({
+ permission: hostContext.viewer.permission,
+ permissionConfig,
+ entitlements: {
+ byok: isHosted,
+ inbox: inboxConfig?.entitled ?? false,
+ customBlocks: customBlocksAvailable,
+ forks: forkingAvailable,
+ },
+ }),
+ [customBlocksAvailable, forkingAvailable, hostContext, inboxConfig?.entitled, permissionConfig]
)
- const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()
-
- const handleBack = useCallback(() => {
- requestLeave(() => {
- router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
- })
- }, [requestLeave, router, popSettingsReturnUrl, workspaceId])
-
- const handleConfirmDiscard = useCallback(() => {
- confirmLeave()
- }, [confirmLeave])
-
- const handleCancelDiscard = useCallback(() => {
- cancelLeave()
- }, [cancelLeave])
-
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
-
- const updateScrollState = () => {
- setHasOverflowTop(container.scrollTop > 1)
- }
-
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) {
- observer.observe(scrollContentRef.current)
- }
-
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [isCollapsed])
+ const activeSection = useMemo(
+ () =>
+ parseSettingsPathSection({
+ path: pathname,
+ items: WORKSPACE_SETTINGS_ITEMS,
+ defaultSection: 'teammates',
+ aliases: WORKSPACE_SETTINGS_PATH_ALIASES,
+ }),
+ [pathname]
+ )
return (
- <>
- {/* Back button */}
-
-
- {/* Settings sections */}
-
-
- {sectionConfig
- .map(({ key, title }) => ({
- key,
- title,
- items: navigationItems.filter((item) => item.section === key),
- }))
- .filter(({ items }) => items.length > 0)
- .map(({ key, title, items: sectionItems }, index) => (
-
0 && SIDEBAR_SECTION_GAP_CLASS,
- 'flex flex-shrink-0 flex-col'
- )}
- >
-
-
- {sectionItems.map((item) => {
- const Icon = item.icon
- const active = activeSection === item.id
- const isLocked =
- item.requiresMax &&
- (item.id === 'inbox'
- ? !inboxEntitled
- : !subscriptionAccess.hasUsableMaxAccess)
- const itemClassName = chipVariants({ active, fullWidth: true })
- const content = (
- <>
-
-
- {item.label}
-
- {isLocked && (
-
- Max
-
- )}
- >
- )
-
- const element = item.externalUrl ? (
-
- {content}
-
- ) : (
-
handlePrefetch(item.id)}
- onFocus={() => handlePrefetch(item.id)}
- onClick={() => {
- const section = item.id as SettingsSection
- if (section === activeSection) return
- requestLeave(() => {
- router.replace(getSettingsHref({ section }), { scroll: false })
- })
- }}
- >
- {content}
-
- )
-
- return (
-
- {element}
-
- )
- })}
-
-
- ))}
-
-
-
- !open && handleCancelDiscard()}
- srTitle='Unsaved changes'
- title='Unsaved changes'
- text='You have unsaved changes. Are you sure you want to discard them?'
- dismissLabel='Keep editing'
- confirm={{
- label: 'Discard changes',
- onClick: handleConfirmDiscard,
- }}
- />
- >
+ getWorkspaceSettingsHref(workspaceId, section)}
+ items={items}
+ isCollapsed={isCollapsed}
+ showCollapsedTooltips={showCollapsedTooltips}
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.test.ts
new file mode 100644
index 00000000000..5761a060f9e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.test.ts
@@ -0,0 +1,26 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { getCreateWorkspaceCopy } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
+
+describe('getCreateWorkspaceCopy', () => {
+ it('labels viewer-account personal creation explicitly', () => {
+ expect(getCreateWorkspaceCopy({ type: 'personal' })).toEqual({
+ title: 'Create personal workspace',
+ description: 'This workspace will belong to your personal account.',
+ })
+ })
+
+ it('names the viewer active organization as the creation target', () => {
+ expect(
+ getCreateWorkspaceCopy({
+ type: 'organization',
+ organizationName: 'Viewer Org A',
+ })
+ ).toEqual({
+ title: 'Create workspace in Viewer Org A',
+ description: 'This workspace will belong to Viewer Org A and use its workspace policy.',
+ })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx
index 9190eefa696..9aa5222c8d2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx
@@ -11,11 +11,30 @@ import {
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
+export type CreateWorkspaceTarget =
+ | { type: 'personal' }
+ | { type: 'organization'; organizationName: string }
+
+export function getCreateWorkspaceCopy(target: CreateWorkspaceTarget) {
+ if (target.type === 'organization') {
+ return {
+ title: `Create workspace in ${target.organizationName}`,
+ description: `This workspace will belong to ${target.organizationName} and use its workspace policy.`,
+ }
+ }
+
+ return {
+ title: 'Create personal workspace',
+ description: 'This workspace will belong to your personal account.',
+ }
+}
+
interface CreateWorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (name: string) => Promise
isCreating: boolean
+ target: CreateWorkspaceTarget
}
/**
@@ -26,6 +45,7 @@ export function CreateWorkspaceModal({
onOpenChange,
onConfirm,
isCreating,
+ target,
}: CreateWorkspaceModalProps) {
const [name, setName] = useState('')
const [error, setError] = useState(null)
@@ -61,10 +81,13 @@ export function CreateWorkspaceModal({
setError(null)
}
+ const copy = getCreateWorkspaceCopy(target)
+
return (
-
- onOpenChange(false)}>Create workspace
+
+ onOpenChange(false)}>{copy.title}
+ {copy.description}
({
+ hostContext: {
+ current: {
+ hostOrganizationId: 'org-host',
+ viewer: { isHostOrganizationAdmin: false },
+ },
+ },
+ mockUseOrganizationBilling: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ ChipModal: ({ children }: { children: ReactNode }) => {children}
,
+ ChipModalBody: ({ children }: { children: ReactNode }) => {children}
,
+ ChipModalField: () =>
,
+ ChipModalFooter: () =>
,
+ ChipModalHeader: ({ children }: { children: ReactNode }) => {children}
,
+ toast: { success: vi.fn() },
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { email: 'viewer@example.com' } } }),
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ isBillingEnabled: true,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
+ useWorkspaceHostContext: () => hostContext.current,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
+ useWorkspacePermissionsContext: () => ({
+ workspacePermissions: { users: [] },
+ userPermissions: { canAdmin: true },
+ }),
+}))
+
+vi.mock('@/hooks/queries/invitations', () => ({
+ useBatchSendWorkspaceInvitations: () => ({
+ isPending: false,
+ mutate: vi.fn(),
+ }),
+}))
+
+vi.mock('@/hooks/queries/organization', () => ({
+ useOrganizationBilling: (...args: unknown[]) => {
+ mockUseOrganizationBilling(...args)
+ return { data: undefined }
+ },
+}))
+
+import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal'
+
+let container: HTMLDivElement
+let root: Root
+
+describe('InviteModal organization billing isolation', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ hostContext.current = {
+ hostOrganizationId: 'org-host',
+ viewer: { isHostOrganizationAdmin: false },
+ }
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('does not fetch admin billing data for a workspace-only administrator', async () => {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+
+ expect(mockUseOrganizationBilling).toHaveBeenCalledWith('org-host', { enabled: false })
+ })
+
+ it('fetches seat data for an administrator of the routed host organization', async () => {
+ hostContext.current = {
+ hostOrganizationId: 'org-host',
+ viewer: { isHostOrganizationAdmin: true },
+ }
+
+ await act(async () => {
+ root.render(
+
+ )
+ })
+
+ expect(mockUseOrganizationBilling).toHaveBeenCalledWith('org-host', { enabled: true })
+ })
+
+ it('does not fetch billing data for a stale organization prop', async () => {
+ hostContext.current = {
+ hostOrganizationId: 'org-host',
+ viewer: { isHostOrganizationAdmin: true },
+ }
+
+ await act(async () => {
+ root.render(
+
+ )
+ })
+
+ expect(mockUseOrganizationBilling).toHaveBeenCalledWith('org-other', { enabled: false })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
index ba0539e5d88..22923d5cfb7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
@@ -13,10 +13,11 @@ import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { useBatchSendWorkspaceInvitations } from '@/hooks/queries/invitations'
import { useOrganizationBilling } from '@/hooks/queries/organization'
@@ -51,10 +52,15 @@ export function InviteModal({
const workspaceId = params.workspaceId as string
const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
const { workspacePermissions, userPermissions: userPerms } = useWorkspacePermissionsContext()
+ const canViewOrganizationBilling =
+ Boolean(organizationId) &&
+ hostContext.hostOrganizationId === organizationId &&
+ hostContext.viewer.isHostOrganizationAdmin
const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
- enabled: open && isBillingEnabled,
+ enabled: open && isBillingEnabled && canViewOrganizationBilling,
})
const batchSendInvitations = useBatchSendWorkspaceInvitations()
@@ -68,7 +74,7 @@ export function InviteModal({
// Only Enterprise plans have a fixed seat cap that gates invites. Team/Pro
// seats are provisioned automatically when an invitee accepts.
const isEnterpriseOrg = isEnterprise(organizationBillingData?.data?.subscriptionPlan)
- const hasSeatData = !!organizationId && isEnterpriseOrg && totalSeats > 0
+ const hasSeatData = canViewOrganizationBilling && isEnterpriseOrg && totalSeats > 0
const exceedsSeatCapacity = hasSeatData && userPerms.canAdmin && emails.length > availableSeats
const seatLimitReason = exceedsSeatCapacity
? `Only ${availableSeats} internal seat${availableSeats === 1 ? '' : 's'} available. External workspace invites do not require seats.`
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index 711d96af8db..de986505d30 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -18,10 +18,14 @@ import {
import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { MoreHorizontal } from 'lucide-react'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { useActiveOrganization } from '@/lib/auth/auth-client'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
-import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
+import {
+ CreateWorkspaceModal,
+ type CreateWorkspaceTarget,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -119,6 +123,7 @@ function WorkspaceHeaderImpl({
setIsMounted(true)
}, [])
+ const { data: viewerActiveOrganization } = useActiveOrganization()
const { navigateToSettings } = useSettingsNavigation()
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
@@ -129,6 +134,17 @@ function WorkspaceHeaderImpl({
const { isInvitationsDisabled: isInvitationsDisabledByConfig } = usePermissionConfig()
const inviteDisabledReason = activeWorkspaceFull?.inviteDisabledReason ?? null
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
+ const createWorkspaceTarget: CreateWorkspaceTarget =
+ workspaceCreationPolicy?.workspaceMode === 'organization' &&
+ workspaceCreationPolicy.organizationId
+ ? {
+ type: 'organization',
+ organizationName:
+ viewerActiveOrganization?.id === workspaceCreationPolicy.organizationId
+ ? viewerActiveOrganization.name
+ : 'your organization',
+ }
+ : { type: 'personal' }
/**
* Save and exit edit mode when popover closes
@@ -664,6 +680,7 @@ function WorkspaceHeaderImpl({
setIsCreateModalOpen(false)
}}
isCreating={isCreatingWorkspace}
+ target={createWorkspaceTarget}
/>
({
+ mockPush: vi.fn(),
+ mockRequestJson: vi.fn(),
+ mockSwitchToWorkspace: vi.fn(),
+ mockUseWorkspacesQuery: vi.fn(),
+ mockUseWorkspaceCreationPolicy: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+vi.mock('@/hooks/queries/invitations', () => ({
+ useLeaveWorkspace: () => ({ isPending: false, mutateAsync: vi.fn() }),
+}))
+
+vi.mock('@/hooks/queries/workspace', () => ({
+ useCreateWorkspace: () => ({ isPending: false, mutateAsync: vi.fn() }),
+ useDeleteWorkspace: () => ({ isPending: false, mutateAsync: vi.fn() }),
+ useUpdateWorkspace: () => ({ mutateAsync: vi.fn() }),
+ useWorkspaceCreationPolicy: mockUseWorkspaceCreationPolicy,
+ useWorkspacesQuery: mockUseWorkspacesQuery,
+}))
+
+vi.mock('@/stores/workflows/registry/store', () => ({
+ useWorkflowRegistry: (
+ selector: (state: { switchToWorkspace: typeof mockSwitchToWorkspace }) => unknown
+ ) => selector({ switchToWorkspace: mockSwitchToWorkspace }),
+}))
+
+import { useWorkspaceManagement } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management'
+
+function Harness() {
+ useWorkspaceManagement({ workspaceId: 'workspace-denied', sessionUserId: 'user-1' })
+ return null
+}
+
+let container: HTMLDivElement
+let root: Root
+
+describe('useWorkspaceManagement direct access guard', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ localStorage.clear()
+ mockUseWorkspacesQuery.mockReturnValue({
+ data: [
+ {
+ id: 'workspace-accessible',
+ name: 'Accessible workspace',
+ ownerId: 'user-1',
+ organizationId: null,
+ workspaceMode: 'personal',
+ },
+ ],
+ isLoading: false,
+ isFetching: false,
+ })
+ mockUseWorkspaceCreationPolicy.mockReturnValue({ data: null })
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('does not silently redirect an unauthorized deep link to another workspace', async () => {
+ await act(async () => {
+ root.render( )
+ await Promise.resolve()
+ })
+
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
index c3d6aeba3e9..e7b865f0a3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
@@ -24,7 +24,9 @@ interface UseWorkspaceManagementProps {
/**
* Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
- * Handles workspace validation, URL synchronization, and recency-based ordering.
+ * Handles URL synchronization and recency-based ordering. Route access is
+ * validated by the workspace layout so a denied deep link is never replaced by
+ * an unrelated fallback workspace.
*
* @param props.workspaceId - The current workspace ID from the URL
* @param props.sessionUserId - The current user's session ID
@@ -37,11 +39,9 @@ export function useWorkspaceManagement({
const router = useRouter()
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
- const {
- data: workspaces = [],
- isLoading: isWorkspacesLoading,
- isFetching: isWorkspacesFetching,
- } = useWorkspacesQuery(Boolean(sessionUserId))
+ const { data: workspaces = [], isLoading: isWorkspacesLoading } = useWorkspacesQuery(
+ Boolean(sessionUserId)
+ )
const { data: workspaceCreationPolicy = null } = useWorkspaceCreationPolicy(
Boolean(sessionUserId)
)
@@ -54,7 +54,6 @@ export function useWorkspaceManagement({
const workspaceIdRef = useRef(workspaceId)
const workspacesRef = useRef(workspaces)
const routerRef = useRef>(router)
- const hasValidatedRef = useRef(false)
const lastTouchedRef = useRef(null)
const syncTimerRef = useRef | null>(null)
@@ -108,28 +107,6 @@ export function useWorkspaceManagement({
const activeWorkspaceRef = useRef(activeWorkspace)
activeWorkspaceRef.current = activeWorkspace
- useEffect(() => {
- if (isWorkspacesLoading || hasValidatedRef.current || !workspaces.length) {
- return
- }
-
- const currentWorkspaceId = workspaceIdRef.current
- const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
-
- if (!matchingWorkspace) {
- if (isWorkspacesFetching) {
- return
- }
- logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
- const sorted = WorkspaceRecencyStorage.sortByRecency(workspaces)
- const fallbackWorkspace = sorted[0]
- logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
- routerRef.current?.push(`/workspace/${fallbackWorkspace.id}/home`)
- }
-
- hasValidatedRef.current = true
- }, [workspaces, isWorkspacesLoading, isWorkspacesFetching])
-
const updateWorkspace = useCallback(
async (
workspaceId: string,
@@ -200,7 +177,6 @@ export function useWorkspaceManagement({
activeWorkspaceRef.current?.id === workspaceToDelete.id
if (isDeletingCurrentWorkspace) {
- hasValidatedRef.current = false
const remainingWorkspaces = WorkspaceRecencyStorage.sortByRecency(
workspacesRef.current.filter((w) => w.id !== workspaceToDelete.id)
)
@@ -239,7 +215,6 @@ export function useWorkspaceManagement({
activeWorkspaceRef.current?.id === workspaceToLeave.id
if (isLeavingCurrentWorkspace) {
- hasValidatedRef.current = false
const remainingWorkspaces = WorkspaceRecencyStorage.sortByRecency(
workspacesRef.current.filter((w) => w.id !== workspaceToLeave.id)
)
diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts
index 50432b83e7d..e5cf597f7b3 100644
--- a/apps/sim/background/async-preprocessing-correlation.test.ts
+++ b/apps/sim/background/async-preprocessing-correlation.test.ts
@@ -14,6 +14,7 @@ import {
workflowsPersistenceUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ADMISSION_ERROR_CODE } from '@/lib/core/admission/transient-failure'
const { mockTask, mockExecuteWorkflowCore, mockGetScheduleTimeValues, mockGetSubBlockValue } =
vi.hoisted(() => ({
@@ -90,6 +91,19 @@ vi.mock('@/executor/utils/errors', () => ({
import { executeScheduleJob } from './schedule-execution'
import { executeWorkflowJob } from './workflow-execution'
+const billingAttribution = {
+ actorUserId: 'actor-1',
+ workspaceId: 'workspace-1',
+ organizationId: null,
+ billedAccountUserId: 'actor-1',
+ billingEntity: { type: 'user' as const, id: 'actor-1' },
+ billingPeriod: {
+ start: '2025-01-01T00:00:00.000Z',
+ end: '2025-02-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
describe('async preprocessing correlation threading', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -128,6 +142,7 @@ describe('async preprocessing correlation threading', () => {
workspaceId: 'workspace-1',
variables: {},
},
+ billingAttribution,
executionTimeout: {},
})
mockExecuteWorkflowCore.mockResolvedValueOnce({
@@ -139,7 +154,9 @@ describe('async preprocessing correlation threading', () => {
await executeWorkflowJob({
workflowId: 'workflow-1',
- userId: 'user-1',
+ userId: 'actor-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
triggerType: 'api',
executionId: 'execution-1',
requestId: 'request-1',
@@ -165,6 +182,7 @@ describe('async preprocessing correlation threading', () => {
workspaceId: 'workspace-1',
variables: {},
},
+ billingAttribution: { ...billingAttribution, actorUserId: 'actor-2' },
executionTimeout: {},
})
mockExecuteWorkflowCore.mockResolvedValueOnce({
@@ -177,6 +195,8 @@ describe('async preprocessing correlation threading', () => {
await executeScheduleJob({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution: { ...billingAttribution, actorUserId: 'actor-2' },
executionId: 'execution-2',
requestId: 'request-2',
now: '2025-01-01T00:00:00.000Z',
@@ -196,21 +216,24 @@ describe('async preprocessing correlation threading', () => {
it('passes workflow correlation into preprocessing', async () => {
mockPreprocessExecution.mockResolvedValueOnce({
success: false,
- error: { message: 'preprocessing failed', statusCode: 500, logCreated: true },
+ error: { message: 'preprocessing failed', statusCode: 500 },
})
await expect(
executeWorkflowJob({
workflowId: 'workflow-1',
- userId: 'user-1',
+ userId: 'actor-1',
+ workspaceId: 'workspace-1',
triggerType: 'api',
executionId: 'execution-1',
requestId: 'request-1',
+ billingAttribution,
})
).rejects.toThrow('preprocessing failed')
expect(mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({
+ billingAttribution,
triggerData: {
correlation: {
executionId: 'execution-1',
@@ -224,23 +247,53 @@ describe('async preprocessing correlation threading', () => {
)
})
+ it('does not repeat admission gates for route-admitted workflow jobs', async () => {
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: false,
+ error: { message: 'preprocessing failed', statusCode: 500 },
+ })
+
+ await expect(
+ executeWorkflowJob({
+ workflowId: 'workflow-1',
+ userId: 'actor-1',
+ workspaceId: 'workspace-1',
+ triggerType: 'api',
+ executionId: 'execution-admitted',
+ requestId: 'request-admitted',
+ billingAttribution,
+ admissionCompleted: true,
+ })
+ ).rejects.toThrow('preprocessing failed')
+
+ expect(mockPreprocessExecution).toHaveBeenCalledWith(
+ expect.objectContaining({
+ checkRateLimit: false,
+ skipUsageLimits: true,
+ })
+ )
+ })
+
it('passes schedule correlation into preprocessing', async () => {
mockPreprocessExecution.mockResolvedValueOnce({
success: false,
- error: { message: 'auth failed', statusCode: 401, logCreated: true },
+ error: { message: 'auth failed', statusCode: 401 },
})
await executeScheduleJob({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
executionId: 'execution-2',
requestId: 'request-2',
now: '2025-01-01T00:00:00.000Z',
scheduledFor: '2025-01-01T00:00:00.000Z',
+ billingAttribution,
})
expect(mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({
+ billingAttribution,
triggerData: {
correlation: {
executionId: 'execution-2',
@@ -262,7 +315,6 @@ describe('async preprocessing correlation threading', () => {
error: {
message: 'database unavailable',
statusCode: 500,
- logCreated: true,
retryable: true,
cause: { code: '53300' },
},
@@ -271,6 +323,8 @@ describe('async preprocessing correlation threading', () => {
await executeScheduleJob({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
executionId: 'execution-retry',
requestId: 'request-retry',
now: '2025-01-01T00:00:00.000Z',
@@ -286,13 +340,76 @@ describe('async preprocessing correlation threading', () => {
)
})
+ it('routes retryable reservation concurrency through bounded infrastructure backoff', async () => {
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: false,
+ error: {
+ message: 'Too many concurrent executions',
+ statusCode: 429,
+ retryable: true,
+ code: ADMISSION_ERROR_CODE.RESERVATION_CONCURRENCY,
+ },
+ })
+
+ await executeScheduleJob({
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
+ executionId: 'execution-concurrency-retry',
+ requestId: 'request-concurrency-retry',
+ now: '2025-01-01T00:00:00.000Z',
+ scheduledFor: '2025-01-01T00:00:00.000Z',
+ infraRetryCount: 2,
+ })
+
+ expect(dbChainMockFns.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ lastQueuedAt: null,
+ infraRetryCount: 3,
+ })
+ )
+ })
+
+ it('keeps retryable non-admission 429 failures on the fixed rate-limit delay', async () => {
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: false,
+ error: {
+ message: 'Rate limit exceeded',
+ statusCode: 429,
+ retryable: true,
+ code: 'RATE_LIMIT_EXCEEDED',
+ },
+ })
+
+ await executeScheduleJob({
+ scheduleId: 'schedule-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
+ executionId: 'execution-rate-limit',
+ requestId: 'request-rate-limit',
+ now: '2025-01-01T00:00:00.000Z',
+ scheduledFor: '2025-01-01T00:00:00.000Z',
+ infraRetryCount: 2,
+ })
+
+ expect(dbChainMockFns.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ lastQueuedAt: null,
+ infraRetryCount: 0,
+ })
+ )
+ const update = dbChainMockFns.set.mock.calls.at(-1)?.[0]
+ expect(update.nextRunAt.getTime() - update.updatedAt.getTime()).toBe(5 * 60 * 1000)
+ })
+
it('moves exhausted infrastructure retries onto the normal failure path', async () => {
mockPreprocessExecution.mockResolvedValueOnce({
success: false,
error: {
message: 'database unavailable',
statusCode: 500,
- logCreated: true,
retryable: true,
cause: { code: '53300' },
},
@@ -301,6 +418,8 @@ describe('async preprocessing correlation threading', () => {
await executeScheduleJob({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
executionId: 'execution-retry-exhausted',
requestId: 'request-retry-exhausted',
now: '2025-01-01T00:00:00.000Z',
diff --git a/apps/sim/background/knowledge-connector-sync.test.ts b/apps/sim/background/knowledge-connector-sync.test.ts
new file mode 100644
index 00000000000..61f2156dddb
--- /dev/null
+++ b/apps/sim/background/knowledge-connector-sync.test.ts
@@ -0,0 +1,77 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockAssertConnectorSyncPayload, mockExecuteSync, mockTask } = vi.hoisted(() => ({
+ mockAssertConnectorSyncPayload: vi.fn(),
+ mockExecuteSync: vi.fn(),
+ mockTask: vi.fn((config) => config),
+}))
+
+vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
+vi.mock('@/lib/knowledge/connectors/queue', () => ({
+ assertConnectorSyncPayload: mockAssertConnectorSyncPayload,
+}))
+vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
+ executeSync: mockExecuteSync,
+}))
+
+import { executeConnectorSyncJob } from '@/background/knowledge-connector-sync'
+
+const BILLING_ATTRIBUTION = {
+ actorUserId: 'external-admin',
+ workspaceId: 'workspace-1',
+ organizationId: null,
+ billedAccountUserId: 'workspace-owner',
+ billingEntity: { type: 'user' as const, id: 'workspace-owner' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
+describe('knowledge connector sync worker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockExecuteSync.mockResolvedValue({
+ docsAdded: 0,
+ docsUpdated: 0,
+ docsDeleted: 0,
+ docsUnchanged: 0,
+ docsFailed: 0,
+ })
+ })
+
+ it('rejects a legacy job before sync execution', async () => {
+ mockAssertConnectorSyncPayload.mockImplementation(() => {
+ throw new Error('Connector sync payload requires billing attribution')
+ })
+
+ await expect(
+ executeConnectorSyncJob({ connectorId: 'connector-1', requestId: 'request-1' })
+ ).rejects.toThrow('Connector sync payload requires billing attribution')
+ expect(mockExecuteSync).not.toHaveBeenCalled()
+ })
+
+ it('forwards the validated actor and payer snapshot to the sync engine', async () => {
+ mockAssertConnectorSyncPayload.mockReturnValue({
+ connectorId: 'connector-1',
+ requestId: 'request-1',
+ fullSync: true,
+ billingAttribution: BILLING_ATTRIBUTION,
+ })
+
+ await executeConnectorSyncJob({
+ connectorId: 'connector-1',
+ requestId: 'request-1',
+ billingAttribution: BILLING_ATTRIBUTION,
+ })
+
+ expect(mockExecuteSync).toHaveBeenCalledWith('connector-1', {
+ billingAttribution: BILLING_ATTRIBUTION,
+ fullSync: true,
+ })
+ })
+})
diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts
index a0c3b53d494..dd29cf11108 100644
--- a/apps/sim/background/knowledge-connector-sync.ts
+++ b/apps/sim/background/knowledge-connector-sync.ts
@@ -1,13 +1,40 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
+import {
+ assertConnectorSyncPayload,
+ type ConnectorSyncPayload,
+} from '@/lib/knowledge/connectors/queue'
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
const logger = createLogger('TriggerKnowledgeConnectorSync')
-export type ConnectorSyncPayload = {
- connectorId: string
- fullSync?: boolean
- requestId: string
+export async function executeConnectorSyncJob(payload: unknown) {
+ const { connectorId, fullSync, requestId, billingAttribution } =
+ assertConnectorSyncPayload(payload)
+
+ logger.info(`[${requestId}] Starting connector sync: ${connectorId}`)
+
+ try {
+ const result = await executeSync(connectorId, { billingAttribution, fullSync })
+
+ logger.info(`[${requestId}] Connector sync completed`, {
+ connectorId,
+ added: result.docsAdded,
+ updated: result.docsUpdated,
+ deleted: result.docsDeleted,
+ unchanged: result.docsUnchanged,
+ failed: result.docsFailed,
+ })
+
+ return {
+ success: !result.error,
+ connectorId,
+ ...result,
+ }
+ } catch (error) {
+ logger.error(`[${requestId}] Connector sync failed: ${connectorId}`, error)
+ throw error
+ }
}
export const knowledgeConnectorSync = task({
@@ -24,31 +51,5 @@ export const knowledgeConnectorSync = task({
concurrencyLimit: 5,
name: 'connector-sync-queue',
},
- run: async (payload: ConnectorSyncPayload) => {
- const { connectorId, fullSync, requestId } = payload
-
- logger.info(`[${requestId}] Starting connector sync: ${connectorId}`)
-
- try {
- const result = await executeSync(connectorId, { fullSync })
-
- logger.info(`[${requestId}] Connector sync completed`, {
- connectorId,
- added: result.docsAdded,
- updated: result.docsUpdated,
- deleted: result.docsDeleted,
- unchanged: result.docsUnchanged,
- failed: result.docsFailed,
- })
-
- return {
- success: !result.error,
- connectorId,
- ...result,
- }
- } catch (error) {
- logger.error(`[${requestId}] Connector sync failed: ${connectorId}`, error)
- throw error
- }
- },
+ run: async (payload: ConnectorSyncPayload) => executeConnectorSyncJob(payload),
})
diff --git a/apps/sim/background/knowledge-processing.test.ts b/apps/sim/background/knowledge-processing.test.ts
new file mode 100644
index 00000000000..39c76a177b1
--- /dev/null
+++ b/apps/sim/background/knowledge-processing.test.ts
@@ -0,0 +1,139 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockAssertBillingAttributionSnapshot, mockProcessDocumentAsync, mockTask } = vi.hoisted(
+ () => ({
+ mockAssertBillingAttributionSnapshot: vi.fn(),
+ mockProcessDocumentAsync: vi.fn(),
+ mockTask: vi.fn((config) => config),
+ })
+)
+
+vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
+}))
+vi.mock('@/lib/knowledge/documents/service', () => ({
+ processDocumentAsync: mockProcessDocumentAsync,
+}))
+
+import { runDocumentProcessing } from '@/background/knowledge-processing'
+
+const BILLING_ATTRIBUTION = {
+ actorUserId: 'external-admin',
+ workspaceId: 'workspace-1',
+ organizationId: null,
+ billedAccountUserId: 'workspace-owner',
+ billingEntity: { type: 'user' as const, id: 'workspace-owner' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+}
+
+const BASE_PAYLOAD = {
+ knowledgeBaseId: 'knowledge-base-1',
+ documentId: 'document-1',
+ docData: {
+ filename: 'document.txt',
+ fileUrl: 'https://example.com/document.txt',
+ fileSize: 128,
+ mimeType: 'text/plain',
+ },
+ processingOptions: {},
+ requestId: 'request-1',
+}
+
+const WORKSPACE_PAYLOAD = {
+ ...BASE_PAYLOAD,
+ billingScope: 'workspace' as const,
+ actorUserId: 'external-admin',
+ workspaceId: 'workspace-1',
+ billingAttribution: BILLING_ATTRIBUTION,
+}
+
+describe('knowledge processing worker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAssertBillingAttributionSnapshot.mockImplementation((value) => {
+ if (!value) {
+ throw new Error('Billing attribution snapshot must be an object')
+ }
+ return value
+ })
+ mockProcessDocumentAsync.mockResolvedValue(undefined)
+ })
+
+ it('rejects workspace work without attribution before document processing starts', async () => {
+ await expect(
+ runDocumentProcessing({
+ ...BASE_PAYLOAD,
+ billingScope: 'workspace',
+ actorUserId: 'external-admin',
+ workspaceId: 'workspace-1',
+ })
+ ).rejects.toThrow('Workspace document processing requires a billing attribution snapshot')
+ expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
+ })
+
+ it('preserves the validated actor and payer snapshot through serialization', async () => {
+ await runDocumentProcessing(JSON.parse(JSON.stringify(WORKSPACE_PAYLOAD)))
+
+ expect(mockProcessDocumentAsync).toHaveBeenCalledWith(
+ 'knowledge-base-1',
+ 'document-1',
+ BASE_PAYLOAD.docData,
+ {},
+ {
+ billingScope: 'workspace',
+ actorUserId: 'external-admin',
+ workspaceId: 'workspace-1',
+ billingAttribution: BILLING_ATTRIBUTION,
+ }
+ )
+ })
+
+ it('rejects an actor mismatch before document processing starts', async () => {
+ await expect(
+ runDocumentProcessing({
+ ...WORKSPACE_PAYLOAD,
+ actorUserId: 'different-actor',
+ })
+ ).rejects.toThrow('Document processing actor does not match billing attribution')
+ expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
+ })
+
+ it('rejects a workspace mismatch before document processing starts', async () => {
+ await expect(
+ runDocumentProcessing({
+ ...WORKSPACE_PAYLOAD,
+ workspaceId: 'workspace-2',
+ })
+ ).rejects.toThrow('Document processing workspace does not match billing attribution')
+ expect(mockProcessDocumentAsync).not.toHaveBeenCalled()
+ })
+
+ it('preserves explicit non-workspace processing without workspace attribution', async () => {
+ await runDocumentProcessing({
+ ...BASE_PAYLOAD,
+ billingScope: 'non-workspace',
+ actorUserId: 'legacy-owner',
+ workspaceId: null,
+ })
+
+ expect(mockProcessDocumentAsync).toHaveBeenCalledWith(
+ 'knowledge-base-1',
+ 'document-1',
+ BASE_PAYLOAD.docData,
+ {},
+ {
+ billingScope: 'non-workspace',
+ actorUserId: 'legacy-owner',
+ workspaceId: null,
+ }
+ )
+ })
+})
diff --git a/apps/sim/background/knowledge-processing.ts b/apps/sim/background/knowledge-processing.ts
index 8441fad1e05..028d5629ec6 100644
--- a/apps/sim/background/knowledge-processing.ts
+++ b/apps/sim/background/knowledge-processing.ts
@@ -1,24 +1,55 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { env, envNumber } from '@/lib/core/config/env'
+import {
+ assertDocumentProcessingPayload,
+ type DocumentProcessingBillingContext,
+ type DocumentProcessingPayload,
+} from '@/lib/knowledge/documents/processing-payload'
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
const logger = createLogger('TriggerKnowledgeProcessing')
-export type DocumentProcessingPayload = {
- knowledgeBaseId: string
- documentId: string
- docData: {
- filename: string
- fileUrl: string
- fileSize: number
- mimeType: string
- }
- processingOptions: {
- recipe?: string
- lang?: string
+export async function runDocumentProcessing(rawPayload: DocumentProcessingPayload) {
+ const payload = assertDocumentProcessingPayload(rawPayload)
+ const { knowledgeBaseId, documentId, docData, processingOptions, requestId } = payload
+ const billingContext: DocumentProcessingBillingContext =
+ payload.billingScope === 'workspace'
+ ? {
+ billingScope: 'workspace',
+ actorUserId: payload.actorUserId,
+ workspaceId: payload.workspaceId,
+ billingAttribution: payload.billingAttribution,
+ }
+ : {
+ billingScope: 'non-workspace',
+ actorUserId: payload.actorUserId,
+ workspaceId: null,
+ }
+
+ logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`)
+
+ try {
+ await processDocumentAsync(
+ knowledgeBaseId,
+ documentId,
+ docData,
+ processingOptions,
+ billingContext
+ )
+
+ logger.info(`[${requestId}] Successfully processed document: ${docData.filename}`)
+
+ return {
+ success: true,
+ documentId,
+ filename: docData.filename,
+ processingTime: Date.now(),
+ }
+ } catch (error) {
+ logger.error(`[${requestId}] Failed to process document: ${docData.filename}`, error)
+ throw error
}
- requestId: string
}
export const processDocument = task({
@@ -35,25 +66,5 @@ export const processDocument = task({
concurrencyLimit: envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20),
name: 'document-processing-queue',
},
- run: async (payload: DocumentProcessingPayload) => {
- const { knowledgeBaseId, documentId, docData, processingOptions, requestId } = payload
-
- logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`)
-
- try {
- await processDocumentAsync(knowledgeBaseId, documentId, docData, processingOptions)
-
- logger.info(`[${requestId}] Successfully processed document: ${docData.filename}`)
-
- return {
- success: true,
- documentId,
- filename: docData.filename,
- processingTime: Date.now(),
- }
- } catch (error) {
- logger.error(`[${requestId}] Failed to process document: ${docData.filename}`, error)
- throw error
- }
- },
+ run: runDocumentProcessing,
})
diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts
index 4300bcdbb01..543296e0d61 100644
--- a/apps/sim/background/resume-execution.ts
+++ b/apps/sim/background/resume-execution.ts
@@ -2,11 +2,17 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { task } from '@trigger.dev/sdk'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+} from '@/lib/billing/core/billing-attribution'
import { withCascadeLock } from '@/lib/table/cascade-lock'
import { isExecCancelled } from '@/lib/table/deps'
-import type { RowData, RowExecutionMetadata } from '@/lib/table/types'
+import type { RowExecutionMetadata } from '@/lib/table/types'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { RESUME_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits'
+import { ExecutionSnapshot } from '@/executor/execution/snapshot'
+import type { SerializedSnapshot } from '@/executor/types'
const logger = createLogger('TriggerResumeExecution')
@@ -37,6 +43,11 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) {
if (!pausedExecution) {
throw new Error(`Paused execution not found: ${pausedExecutionId}`)
}
+ const serializedSnapshot = pausedExecution.executionSnapshot as SerializedSnapshot
+ const persistedSnapshot = ExecutionSnapshot.fromJSON(serializedSnapshot.snapshot)
+ const billingAttribution = assertBillingAttributionSnapshot(
+ persistedSnapshot.metadata.billingAttribution
+ )
// If this paused execution belongs to a table cell, rehydrate the cell
// context so post-resume block outputs land on the same row + group as
@@ -118,7 +129,7 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) {
async () => {
const result = await runResumeAndCellTerminal(payload, pausedExecution, writers)
if (result.status === 'paused') return result
- await continueCascadeAfterResume(cellContext)
+ await continueCascadeAfterResume(cellContext, billingAttribution)
return result
}
)
@@ -178,12 +189,13 @@ async function buildResumeCellWriters(
parentExecutionId: string
): Promise {
const { getTableById } = await import('@/lib/table/service')
- const { writeWorkflowGroupState, buildOutputsByBlockId } = await import('@/lib/table/cell-write')
- const { pluckByPath } = await import('@/lib/table/pluck')
+ const { createWorkflowCellProgressWriter, writeWorkflowGroupState } = await import(
+ '@/lib/table/cell-write'
+ )
const table = await getTableById(cellContext.tableId)
const group = table?.schema.workflowGroups?.find((g) => g.id === cellContext.groupId)
- if (!group) {
+ if (!table || !group) {
logger.warn('Cell context found but table or group missing — falling back to plain resume', {
parentExecutionId,
tableId: cellContext.tableId,
@@ -192,9 +204,6 @@ async function buildResumeCellWriters(
return null
}
- const outputsByBlockId = buildOutputsByBlockId(group)
- const accumulatedData: RowData = {}
- const blockErrors: Record = {}
const writeCtx = {
tableId: cellContext.tableId,
rowId: cellContext.rowId,
@@ -202,67 +211,41 @@ async function buildResumeCellWriters(
groupId: cellContext.groupId,
executionId: parentExecutionId,
requestId: `wfgrp-resume-${parentExecutionId}`,
+ table,
}
- let writeChain: Promise = Promise.resolve()
- let terminalWritten = false
-
- const cellOnBlockComplete = async (blockId: string, output: unknown) => {
- const outputs = outputsByBlockId.get(blockId)
- if (!outputs) return
- const blockResult =
- output && typeof output === 'object' && 'output' in (output as object)
- ? (output as { output: unknown }).output
- : output
- const errorMessage =
- blockResult &&
- typeof blockResult === 'object' &&
- typeof (blockResult as { error?: unknown }).error === 'string'
- ? (blockResult as { error: string }).error
- : null
- if (errorMessage) {
- blockErrors[blockId] = errorMessage
- } else {
- for (const out of outputs) {
- const plucked = pluckByPath(blockResult, out.path)
- if (plucked === undefined) continue
- accumulatedData[out.columnName] = plucked as RowData[string]
+ const progressWriter = createWorkflowCellProgressWriter({
+ group,
+ writeProgress: ({ dataPatch, eventOutputs, blockErrors }) => {
+ const partial: RowExecutionMetadata = {
+ status: 'running',
+ executionId: parentExecutionId,
+ jobId: null,
+ workflowId: cellContext.workflowId,
+ error: null,
+ blockErrors,
}
- }
- const dataSnapshot: RowData = { ...accumulatedData }
- const blockErrorsSnapshot = { ...blockErrors }
- writeChain = writeChain
- .then(async () => {
- if (terminalWritten) return
- const partial: RowExecutionMetadata = {
- status: 'running',
- executionId: parentExecutionId,
- jobId: null,
- workflowId: cellContext.workflowId,
- error: null,
- blockErrors: blockErrorsSnapshot,
- }
- await writeWorkflowGroupState(writeCtx, {
- executionState: partial,
- dataPatch: dataSnapshot,
- })
- })
- .catch((err) => {
- logger.warn(
- `Resume per-block partial write failed (table=${cellContext.tableId} row=${cellContext.rowId} group=${cellContext.groupId}):`,
- err
- )
+ return writeWorkflowGroupState(writeCtx, {
+ executionState: partial,
+ dataPatch,
+ eventOutputs,
})
- }
+ },
+ onWriteError: (err) => {
+ logger.warn(
+ `Resume per-block partial write failed (table=${cellContext.tableId} row=${cellContext.rowId} group=${cellContext.groupId}):`,
+ err
+ )
+ },
+ })
+
+ const cellOnBlockComplete = progressWriter.onBlockComplete
const writeCellTerminal = async (
status: 'completed' | 'error' | 'paused',
error: string | null
) => {
- terminalWritten = true
- await writeChain.catch(() => {})
- // Paused → keep `pending` + sentinel jobId so eligibility predicates
- // continue treating the row as in-flight while we wait on another
- // pause. Mirrors the initial cell-task pause branch.
+ await progressWriter.finish()
+ const blockErrors = progressWriter.getBlockErrors()
const terminal: RowExecutionMetadata =
status === 'paused'
? {
@@ -284,7 +267,8 @@ async function buildResumeCellWriters(
}
await writeWorkflowGroupState(writeCtx, {
executionState: terminal,
- dataPatch: accumulatedData,
+ dataPatch: progressWriter.getPendingDataPatch(),
+ eventOutputs: progressWriter.getEventOutputs(),
})
}
@@ -318,12 +302,15 @@ async function runResumeAndCellTerminal(
return result
}
-async function continueCascadeAfterResume(cellContext: {
- tableId: string
- rowId: string
- workspaceId: string
- groupId: string
-}): Promise {
+async function continueCascadeAfterResume(
+ cellContext: {
+ tableId: string
+ rowId: string
+ workspaceId: string
+ groupId: string
+ },
+ billingAttribution: BillingAttributionSnapshot
+): Promise {
const { getTableById } = await import('@/lib/table/service')
const { getRowById } = await import('@/lib/table/rows/service')
const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns')
@@ -343,6 +330,7 @@ async function continueCascadeAfterResume(cellContext: {
groupId: next.id,
workflowId: next.workflowId,
executionId: generateId(),
+ billingAttribution,
})
}
diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts
index 07cc2f40aa0..68192bf2a04 100644
--- a/apps/sim/background/schedule-execution.ts
+++ b/apps/sim/background/schedule-execution.ts
@@ -9,11 +9,17 @@ import {
import { createLogger, runWithRequestContext } from '@sim/logger'
import { describeError, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { backoffWithJitter } from '@sim/utils/retry'
import { task } from '@trigger.dev/sdk'
import { Cron } from 'croner'
import { and, eq, isNull, type SQL, sql } from 'drizzle-orm'
-import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
+import {
+ assertBillingAttributionSnapshot,
+ BILLING_ATTRIBUTION_HEADER,
+ type BillingAttributionSnapshot,
+ resolveBillingAttribution,
+ serializeBillingAttributionHeader,
+} from '@/lib/billing/core/billing-attribution'
+import { classifyTransientAdmissionFailure } from '@/lib/core/admission/transient-failure'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import {
describeRetryableInfrastructureError,
@@ -37,10 +43,9 @@ import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import {
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
SCHEDULE_EXECUTION_QUEUE_NAME,
- SCHEDULE_INFRA_RETRY_BASE_MS,
SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS,
- SCHEDULE_INFRA_RETRY_MAX_MS,
} from '@/lib/workflows/schedules/execution-limits'
+import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry'
import {
type BlockState,
calculateNextRunTime as calculateNextTime,
@@ -223,15 +228,7 @@ async function retryScheduleAfterInfraFailure({
return
}
- const retryDelayMs = Math.min(
- SCHEDULE_INFRA_RETRY_MAX_MS,
- Math.round(
- backoffWithJitter(retryAttempt, null, {
- baseMs: SCHEDULE_INFRA_RETRY_BASE_MS,
- maxMs: SCHEDULE_INFRA_RETRY_MAX_MS,
- })
- )
- )
+ const retryDelayMs = calculateScheduleInfraRetryDelayMs(retryAttempt)
const nextRetryAt = new Date(now.getTime() + retryDelayMs)
const failureCause = cause ?? describeRetryableInfrastructureError(error)
const errorMessage = message ?? (error ? toError(error).message : undefined)
@@ -340,6 +337,7 @@ async function runWorkflowExecution({
correlation,
workflowRecord,
actorUserId,
+ billingAttribution,
loggingSession,
requestId,
executionId,
@@ -349,6 +347,7 @@ async function runWorkflowExecution({
correlation: AsyncExecutionCorrelation
workflowRecord: WorkflowRecord
actorUserId: string
+ billingAttribution: BillingAttributionSnapshot
loggingSession: LoggingSession
requestId: string
executionId: string
@@ -409,6 +408,7 @@ async function runWorkflowExecution({
workflowId: payload.workflowId,
workspaceId,
userId: actorUserId,
+ billingAttribution,
sessionUserId: undefined,
workflowUserId: workflowRecord.userId,
triggerType: 'schedule',
@@ -562,7 +562,8 @@ async function runWorkflowExecution({
export type ScheduleExecutionPayload = {
scheduleId: string
workflowId: string
- workspaceId?: string
+ workspaceId: string
+ billingAttribution: BillingAttributionSnapshot
executionId?: string
requestId?: string
correlation?: AsyncExecutionCorrelation
@@ -603,6 +604,10 @@ function calculateNextRunTime(
}
export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
+ const payloadBillingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
+ if (payloadBillingAttribution.workspaceId !== payload.workspaceId) {
+ throw new Error('Schedule job billing attribution does not match its workspace')
+ }
const correlation = buildScheduleCorrelation(payload)
const executionId = correlation.executionId
const requestId = correlation.requestId
@@ -727,10 +732,24 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
checkDeployment: true,
loggingSession,
triggerData: { correlation },
+ billingAttribution: payloadBillingAttribution,
})
if (!preprocessResult.success) {
- const statusCode = preprocessResult.error?.statusCode || 500
+ const preprocessingError = preprocessResult.error
+ const statusCode = preprocessingError.statusCode
+ const transientAdmissionFailure = classifyTransientAdmissionFailure(preprocessingError)
+
+ if (transientAdmissionFailure) {
+ await retryScheduleAfterInfraFailure({
+ payload,
+ requestId,
+ claimedAt,
+ message: preprocessingError.message,
+ cause: preprocessingError.cause,
+ })
+ return
+ }
switch (statusCode) {
case 401: {
@@ -752,7 +771,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
case 403: {
logger.warn(
- `[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessResult.error?.message}`
+ `[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessingError.message}`
)
await updateClaimedSchedule(
{
@@ -821,18 +840,18 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
}
default: {
- if (statusCode >= 500 && preprocessResult.error?.retryable) {
+ if (statusCode >= 500 && preprocessingError.retryable) {
await retryScheduleAfterInfraFailure({
payload,
requestId,
claimedAt,
- message: preprocessResult.error.message,
- cause: preprocessResult.error.cause,
+ message: preprocessingError.message,
+ cause: preprocessingError.cause,
})
return
}
- logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
+ logger.error(`[${requestId}] Preprocessing failed: ${preprocessingError.message}`)
const nextRunAt = await determineNextRunAfterError(payload, now, requestId)
await updateClaimedSchedule(
@@ -844,8 +863,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
}
}
- const { actorUserId, workflowRecord } = preprocessResult
- if (!actorUserId || !workflowRecord) {
+ const { actorUserId, billingAttribution, workflowRecord } = preprocessResult
+ if (!actorUserId || !billingAttribution || !workflowRecord) {
logger.error(`[${requestId}] Missing required preprocessing data`)
await releaseClaim(
now,
@@ -866,10 +885,11 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
correlation,
workflowRecord,
actorUserId,
+ billingAttribution,
loggingSession,
requestId,
executionId,
- asyncTimeout: preprocessResult.executionTimeout?.async,
+ asyncTimeout: preprocessResult.executionTimeout.async,
})
if (executionResult.status === 'retryable_setup_failure') {
@@ -1231,10 +1251,17 @@ export async function executeJobInline(payload: JobExecutionPayload) {
const promptText = buildJobPrompt(jobRecord)
try {
- const userSubscription = await getHighestPrioritySubscription(jobRecord.sourceUserId)
- const mothershipJobTimeoutMs = getExecutionTimeout(userSubscription?.plan, 'sync')
+ const billingAttribution = await resolveBillingAttribution({
+ actorUserId: jobRecord.sourceUserId,
+ workspaceId: jobRecord.sourceWorkspaceId,
+ })
+ const mothershipJobTimeoutMs = getExecutionTimeout(
+ billingAttribution.payerSubscription?.plan,
+ 'sync'
+ )
const url = buildAPIUrl('/api/mothership/execute')
const headers = await buildAuthHeaders(jobRecord.sourceUserId)
+ headers[BILLING_ATTRIBUTION_HEADER] = serializeBillingAttributionHeader(billingAttribution)
const body = {
messages: [{ role: 'user', content: promptText }],
diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts
index 7a75dc3e4f9..b307d978f0e 100644
--- a/apps/sim/background/webhook-execution.test.ts
+++ b/apps/sim/background/webhook-execution.test.ts
@@ -18,12 +18,16 @@ const {
mockWasExecutionFinalizedByCore,
mockRecordException,
mockGetActiveSpan,
+ mockExecuteWithIdempotency,
+ mockReleaseExecutionSlot,
} = vi.hoisted(() => ({
mockResolveWebhookRecordProviderConfig: vi.fn(),
mockExecuteWorkflowCore: vi.fn(),
mockWasExecutionFinalizedByCore: vi.fn(),
mockRecordException: vi.fn(),
mockGetActiveSpan: vi.fn(),
+ mockExecuteWithIdempotency: vi.fn(),
+ mockReleaseExecutionSlot: vi.fn(),
}))
vi.mock('@opentelemetry/api', () => ({
@@ -43,12 +47,14 @@ vi.mock('@/lib/workflows/executor/execution-core', () => ({
wasExecutionFinalizedByCore: mockWasExecutionFinalizedByCore,
}))
+vi.mock('@/lib/billing/calculations/usage-reservation', () => ({
+ releaseExecutionSlot: mockReleaseExecutionSlot,
+}))
+
vi.mock('@/lib/core/idempotency', () => ({
IdempotencyService: { createWebhookIdempotencyKey: vi.fn(() => 'idempotency-key') },
webhookIdempotency: {
- executeWithIdempotency: vi.fn(
- (_provider: string, _key: string, operation: () => Promise) => operation()
- ),
+ executeWithIdempotency: mockExecuteWithIdempotency,
},
}))
@@ -105,7 +111,11 @@ vi.mock('@/triggers', () => ({
isTriggerValid: vi.fn(() => false),
}))
-import { executeWebhookJob, resolveWebhookExecutionProviderConfig } from './webhook-execution'
+import {
+ executeWebhookJob,
+ resolveWebhookExecutionProviderConfig,
+ type WebhookExecutionPayload,
+} from './webhook-execution'
describe('resolveWebhookExecutionProviderConfig', () => {
beforeEach(() => {
@@ -161,10 +171,23 @@ describe('resolveWebhookExecutionProviderConfig', () => {
})
describe('executeWebhookJob fault vs error handling', () => {
- const payload = {
+ const billingAttribution = {
+ actorUserId: 'user-1',
+ workspaceId: 'workspace-1',
+ organizationId: null,
+ billedAccountUserId: 'user-1',
+ billingEntity: { type: 'user' as const, id: 'user-1' },
+ billingPeriod: {
+ start: '2026-07-01T00:00:00.000Z',
+ end: '2026-08-01T00:00:00.000Z',
+ },
+ payerSubscription: null,
+ }
+ const payload: WebhookExecutionPayload = {
webhookId: 'webhook-1',
workflowId: 'workflow-1',
userId: 'user-1',
+ billingAttribution,
executionId: 'execution-1',
requestId: 'request-1',
provider: 'gmail',
@@ -176,8 +199,13 @@ describe('executeWebhookJob fault vs error handling', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockExecuteWithIdempotency.mockImplementation(
+ (_provider: string, _key: string, operation: () => Promise) => operation()
+ )
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValue({
success: true,
+ actorUserId: 'user-1',
+ billingAttribution,
workflowRecord: { workspaceId: 'workspace-1', userId: 'user-1', variables: {} },
executionTimeout: { async: 120_000 },
})
@@ -219,4 +247,40 @@ describe('executeWebhookJob fault vs error handling', () => {
// Pipeline/infra errors are recorded here before re-throwing to fault the trigger.dev run.
expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalled()
})
+
+ it('releases the reservation when idempotency returns a cached result', async () => {
+ const cachedResult = {
+ success: true,
+ workflowId: 'workflow-1',
+ executionId: 'original-execution',
+ }
+ mockExecuteWithIdempotency.mockResolvedValueOnce(cachedResult)
+
+ await expect(executeWebhookJob(payload)).resolves.toBe(cachedResult)
+
+ expect(executionPreprocessingMockFns.mockPreprocessExecution).not.toHaveBeenCalled()
+ expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1')
+ })
+
+ it('releases the reservation when background preprocessing fails', async () => {
+ executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({
+ success: false,
+ error: { message: 'workflow archived', statusCode: 404 },
+ })
+
+ await expect(executeWebhookJob(payload)).rejects.toThrow('workflow archived')
+
+ expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-1')
+ })
+
+ it('rejects queued webhook work without an immutable attribution snapshot', async () => {
+ await expect(
+ executeWebhookJob({
+ ...payload,
+ billingAttribution: undefined,
+ } as unknown as WebhookExecutionPayload)
+ ).rejects.toThrow('Billing attribution snapshot must be an object')
+
+ expect(executionPreprocessingMockFns.mockPreprocessExecution).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index 1c18ca82c59..98e5c6ac7b4 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -7,6 +7,11 @@ import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { task } from '@trigger.dev/sdk'
import { eq } from 'drizzle-orm'
+import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+} from '@/lib/billing/core/billing-attribution'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency'
@@ -227,6 +232,7 @@ export type WebhookExecutionPayload = {
webhookId: string
workflowId: string
userId: string
+ billingAttribution: BillingAttributionSnapshot
executionId?: string
requestId?: string
correlation?: AsyncExecutionCorrelation
@@ -235,26 +241,30 @@ export type WebhookExecutionPayload = {
headers: Record
path: string
blockId?: string
- workspaceId?: string
+ workspaceId: string
credentialId?: string
/** Epoch ms when the webhook HTTP request was first received (for dispatch-latency metrics). */
webhookReceivedAt?: number
/** Epoch ms of the originating provider interaction (e.g. Slack x-slack-request-timestamp). */
triggerTimestampMs?: number
- /**
- * Billing actor resolved by the webhook route, set ONLY for in-process inline
- * execution that runs microseconds after resolution. The background pass reuses
- * it to skip the redundant billed-account lookup. Deliberately absent on queued
- * (Trigger.dev) and persisted payloads — a deferred run could outlive a
- * billed-account change, so it re-resolves the current actor instead.
- */
- resolvedActorUserId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
const correlation = buildWebhookCorrelation(payload)
const executionId = correlation.executionId
const requestId = correlation.requestId
+ try {
+ const payloadBillingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
+ if (
+ payloadBillingAttribution.actorUserId !== payload.userId ||
+ payloadBillingAttribution.workspaceId !== payload.workspaceId
+ ) {
+ throw new Error('Webhook job billing attribution does not match its actor and workspace')
+ }
+ } catch (error) {
+ await releaseExecutionSlot(executionId)
+ throw error
+ }
return runWithRequestContext({ requestId }, async () => {
logger.info(`[${requestId}] Starting webhook execution`, {
@@ -272,15 +282,26 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
payload.provider
)
+ let operationStarted = false
const runOperation = async () => {
+ operationStarted = true
return await executeWebhookJobInternal(payload, correlation)
}
- return await webhookIdempotency.executeWithIdempotency(
- payload.provider,
- idempotencyKey,
- runOperation
- )
+ try {
+ const result = await webhookIdempotency.executeWithIdempotency(
+ payload.provider,
+ idempotencyKey,
+ runOperation
+ )
+ if (!operationStarted) {
+ await releaseExecutionSlot(executionId)
+ }
+ return result
+ } catch (error) {
+ await releaseExecutionSlot(executionId)
+ throw error
+ }
})
}
@@ -375,20 +396,14 @@ async function executeWebhookJobInternal(
skipUsageLimits: true,
workspaceId: payload.workspaceId,
loggingSession,
- /**
- * Reuse the route-resolved actor only for inline execution (set on the
- * in-process payload). When absent — queued/Trigger.dev runs — preprocessing
- * re-resolves the current billed account. Either way the ban and
- * archived-workflow gates run fresh against the resolved actor.
- */
- resolvedActorUserId: payload.resolvedActorUserId,
+ billingAttribution: payload.billingAttribution,
})
if (!preprocessResult.success) {
throw new Error(preprocessResult.error?.message || 'Preprocessing failed in background job')
}
- const { workflowRecord, executionTimeout } = preprocessResult
+ const { actorUserId, billingAttribution, workflowRecord, executionTimeout } = preprocessResult
if (!workflowRecord) {
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
}
@@ -471,7 +486,9 @@ async function executeWebhookJobInternal(
if (skipMessage) {
await loggingSession.safeStart({
- userId: payload.userId,
+ userId: actorUserId,
+ actorUserId,
+ billingAttribution,
workspaceId,
variables: {},
triggerData: {
@@ -563,7 +580,8 @@ async function executeWebhookJobInternal(
executionId,
workflowId: payload.workflowId,
workspaceId,
- userId: payload.userId,
+ userId: actorUserId!,
+ billingAttribution,
sessionUserId: undefined,
workflowUserId: workflowRecord.userId,
triggerType: payload.provider || 'webhook',
@@ -677,7 +695,9 @@ async function executeWebhookJobInternal(
try {
await loggingSession.safeStart({
- userId: payload.userId,
+ userId: actorUserId,
+ actorUserId,
+ billingAttribution,
workspaceId,
variables: {},
triggerData: {
diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts
index 350b8e4e17a..032ffd0643a 100644
--- a/apps/sim/background/workflow-column-execution.ts
+++ b/apps/sim/background/workflow-column-execution.ts
@@ -7,11 +7,18 @@ import { generateId } from '@sim/utils/id'
import { backoffWithJitter } from '@sim/utils/retry'
import { task } from '@trigger.dev/sdk'
import { eq } from 'drizzle-orm'
-import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+ checkAttributedUsageLimits,
+ toBillingContext,
+} from '@/lib/billing/core/billing-attribution'
+import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
import { createTimeoutAbortController } from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { preprocessExecution } from '@/lib/execution/preprocessing'
+import { retryTableAdmission } from '@/lib/table/admission-retry'
import { withCascadeLock } from '@/lib/table/cascade-lock'
import { getColumnId } from '@/lib/table/column-keys'
import { isExecCancelled } from '@/lib/table/deps'
@@ -22,13 +29,33 @@ import type {
TableDefinition,
WorkflowGroup,
} from '@/lib/table/types'
-import type { WorkflowGroupCellPayload } from '@/lib/table/workflow-columns'
-import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
+import type {
+ QueuedWorkflowGroupCellPayload,
+ WorkflowGroupCellPayload,
+} from '@/lib/table/workflow-columns'
export type { WorkflowGroupCellPayload }
const logger = createLogger('TriggerWorkflowGroupCell')
+function requirePayloadBillingAttribution(
+ payload: WorkflowGroupCellPayload
+): BillingAttributionSnapshot {
+ if (!payload.billingAttribution) {
+ throw new Error('Billing attribution is required for queued table execution')
+ }
+ const attribution = assertBillingAttributionSnapshot(payload.billingAttribution)
+ if (attribution.workspaceId !== payload.workspaceId) {
+ throw new Error(
+ `Billing attribution workspace mismatch: expected ${payload.workspaceId}, received ${attribution.workspaceId}`
+ )
+ }
+ if (payload.triggeredByUserId && attribution.actorUserId !== payload.triggeredByUserId) {
+ throw new Error('Billing attribution actor does not match the table trigger actor')
+ }
+ return attribution
+}
+
/** Max rate-limit retry attempts per cell before giving up and writing a
* re-runnable error. With `backoffWithJitter` (base 500ms, max 30s) this is
* ~1–2 minutes of pacing — enough to ride out a transient burst without
@@ -47,7 +74,7 @@ const RATE_LIMIT_MAX_ATTEMPTS = 6
* lock, NOT a queue re-enqueue or a timed poll, and it loops only while a
* runnable group exists. */
export async function executeWorkflowGroupCellJob(
- payload: WorkflowGroupCellPayload,
+ payload: QueuedWorkflowGroupCellPayload,
signal?: AbortSignal
) {
const { tableId, rowId, workspaceId } = payload
@@ -102,7 +129,7 @@ export async function executeWorkflowGroupCellJob(
* cascade become visible to the eligibility check. The resume worker must
* already hold the row's cascade lock before calling. */
export async function runRowCascadeLoop(
- payload: WorkflowGroupCellPayload,
+ payload: QueuedWorkflowGroupCellPayload,
signal?: AbortSignal
): Promise<'blocked' | undefined> {
const { tableId, rowId, workspaceId } = payload
@@ -163,13 +190,14 @@ export async function runRowCascadeLoop(
* takes over) and `'blocked'` for a hard stop (usage limit — dispatch halted,
* cell left unmarked). `'completed' | 'error'` keep the loop running. */
async function runWorkflowAndWriteTerminal(
- payload: WorkflowGroupCellPayload,
+ payload: QueuedWorkflowGroupCellPayload,
signal: AbortSignal | undefined,
table: TableDefinition,
group: WorkflowGroup
): Promise<'completed' | 'error' | 'paused' | 'blocked'> {
const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId, dispatchId } =
payload
+ const billingAttribution = requirePayloadBillingAttribution(payload)
// Read from the live `group`, not the payload: in a cascade the payload is the
// first group's snapshot, so a downstream group with a different version must
// use its own setting (same reason `workflowId` is re-derived per iteration).
@@ -182,13 +210,16 @@ async function runWorkflowAndWriteTerminal(
const { loadWorkflowFromNormalizedTables, loadDeployedWorkflowState } = await import(
'@/lib/workflows/persistence/utils'
)
- const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } =
+ const { createWorkflowCellProgressWriter, writeWorkflowGroupState, markWorkflowGroupPickedUp } =
await import('@/lib/table/cell-write')
const { stashCellContextForResume } = await import('@/lib/table/workflow-columns')
- const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId }
- const writeState = (executionState: RowExecutionMetadata, dataPatch?: RowData) =>
- writeWorkflowGroupState(cellCtx, { executionState, dataPatch })
+ const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId, table }
+ const writeState = (
+ executionState: RowExecutionMetadata,
+ dataPatch?: RowData,
+ eventOutputs?: RowData
+ ) => writeWorkflowGroupState(cellCtx, { executionState, dataPatch, eventOutputs })
/** Pre-execution cancellation guard: a cell cancelled while it sat in the
* queue (e.g. trigger.dev concurrency backlog) must not run once it
@@ -232,30 +263,13 @@ async function runWorkflowAndWriteTerminal(
if (cancelledBeforeRun(row.executions?.[groupId])) return 'error'
- // Resolve the billing/enforcement actor: the user who triggered the run,
- // else the workspace billed account (automated/auto-fire). Enrichment must
- // never run unattributed — if neither resolves, fail the cell rather than
- // running free and ungated.
- const enrichmentActorUserId =
- payload.triggeredByUserId ?? (await getWorkspaceBilledAccountUserId(workspaceId))
- if (!enrichmentActorUserId) {
- logger.error(
- `No billing actor for enrichment — failing cell (table=${tableId} row=${rowId} group=${groupId})`
- )
- await writeState({
- status: 'error',
- executionId,
- jobId: null,
- workflowId: statusId,
- error: 'Unable to resolve a billing account for this enrichment.',
- })
- return 'error'
- }
+ const enrichmentBillingAttribution = billingAttribution
- // Gate per-member + pooled usage before incurring hosted-key cost. Mirrors
- // the workflow-group gate below: clear the cell's pre-stamp and signal the
- // client to upgrade rather than marking the cell errored.
- const usage = await checkActorUsageLimits(enrichmentActorUserId, workspaceId)
+ /**
+ * Gate the exact workspace payer and member cap before hosted-key cost.
+ * A denial clears the cell pre-stamp and surfaces the upgrade state.
+ */
+ const usage = await checkAttributedUsageLimits(enrichmentBillingAttribution)
if (usage.isExceeded) {
logger.warn(
`Usage limit reached — halting enrichment (table=${tableId} row=${rowId} group=${groupId})`
@@ -373,16 +387,18 @@ async function runWorkflowAndWriteTerminal(
return 'error'
}
- // Bill the run's actor (triggerer, else workspace billed account) for any
- // hosted-key cost the providers incurred. Billing failures must not error
- // an otherwise-successful cell.
+ /**
+ * Record the triggerer or system fallback as actor while charging the
+ * exact workspace payer. Billing failures do not fail a successful cell.
+ */
if (cost > 0) {
try {
const { recordUsage } = await import('@/lib/billing/core/usage-log')
await recordUsage({
- userId: enrichmentActorUserId,
+ userId: enrichmentBillingAttribution.actorUserId,
workspaceId,
executionId,
+ ...toBillingContext(enrichmentBillingAttribution),
entries: [
{
category: 'fixed',
@@ -394,6 +410,7 @@ async function runWorkflowAndWriteTerminal(
},
],
})
+ await checkAndBillPayerOverageThreshold(enrichmentBillingAttribution.billingEntity)
} catch (billingErr) {
logger.error('Failed to record enrichment usage', {
enrichmentId: enrichment.id,
@@ -437,9 +454,7 @@ async function runWorkflowAndWriteTerminal(
}
}
- const blockErrors: Record = {}
- let writeChain: Promise = Promise.resolve()
- let terminalWritten = false
+ let progressWriter: ReturnType | null = null
try {
const [workflowRecord] = await db
@@ -513,20 +528,33 @@ async function runWorkflowAndWriteTerminal(
// auto-fire from their own write) so the gate + cost land on their per-member
// meter — mirroring the enrichment branch. Falls back to the workspace billed
// account for genuinely actor-less runs.
- const preprocess = await preprocessExecution({
- workflowId,
- executionId,
- requestId,
- workspaceId,
- workflowRecord,
- userId: payload.triggeredByUserId ?? workflowRecord.userId,
- useAuthenticatedUserAsActor: Boolean(payload.triggeredByUserId),
- triggerType: 'workflow',
- checkDeployment: false,
- checkRateLimit: false,
- skipConcurrencyReservation: true,
- logPreprocessingErrors: false,
- })
+ const preprocess = await retryTableAdmission(
+ () =>
+ preprocessExecution({
+ workflowId,
+ executionId,
+ requestId,
+ workspaceId,
+ workflowRecord,
+ userId: payload.triggeredByUserId ?? workflowRecord.userId,
+ useAuthenticatedUserAsActor: Boolean(payload.triggeredByUserId),
+ triggerType: 'workflow',
+ checkDeployment: false,
+ checkRateLimit: false,
+ skipConcurrencyReservation: true,
+ logPreprocessingErrors: false,
+ billingAttribution,
+ }),
+ {
+ signal,
+ onRetry: ({ attempt, failure, nextAttempt, waitMs }) => {
+ logger.warn(
+ `Transient admission failure — waiting ${waitMs}ms before retry ${nextAttempt} (table=${tableId} row=${rowId} group=${groupId})`,
+ { attempt, failure: failure.kind }
+ )
+ },
+ }
+ )
if (!preprocess.success) {
// Usage/quota exhausted: retrying won't help. Halt the dispatch without
// marking any cell, and signal the client to upgrade.
@@ -591,7 +619,7 @@ async function runWorkflowAndWriteTerminal(
if (signal?.aborted) return 'error'
const rl = await rateLimiter.checkRateLimitWithSubscription(
actorUserId,
- preprocess.userSubscription ?? null,
+ preprocess.actorSubscription ?? null,
'workflow',
true
)
@@ -675,76 +703,30 @@ async function runWorkflowAndWriteTerminal(
timestamp: new Date().toISOString(),
}
- const { pluckByPath } = await import('@/lib/table/pluck')
- const outputsByBlockId = buildOutputsByBlockId(group)
-
- const accumulatedData: RowData = {}
- const runningBlockIds = new Set()
-
- const schedulePartialWrite = () => {
- if (terminalWritten) return
- const dataSnapshot: RowData = { ...accumulatedData }
- const blockErrorsSnapshot = { ...blockErrors }
- const runningSnapshot = Array.from(runningBlockIds)
- writeChain = writeChain
- .then(async () => {
- if (signal?.aborted) return
- if (terminalWritten) return
- await writeState(
- {
- status: 'running',
- executionId,
- jobId: null,
- workflowId,
- error: null,
- runningBlockIds: runningSnapshot,
- blockErrors: blockErrorsSnapshot,
- },
- dataSnapshot
- )
- })
- .catch((err) => {
- logger.warn(
- `Per-block partial write failed (table=${tableId} row=${rowId} group=${groupId})`,
- { cause: describeError(err), retryable: isRetryableInfrastructureError(err) }
- )
- })
- }
-
- const onBlockStart = async (blockId: string): Promise => {
- if (!outputsByBlockId.has(blockId)) return
- runningBlockIds.add(blockId)
- schedulePartialWrite()
- }
-
- const onBlockComplete = async (blockId: string, output: unknown): Promise => {
- const outputs = outputsByBlockId.get(blockId)
- if (!outputs) return
-
- const blockResult =
- output && typeof output === 'object' && 'output' in (output as object)
- ? (output as { output: unknown }).output
- : output
-
- const blockErrorMessage =
- blockResult &&
- typeof blockResult === 'object' &&
- typeof (blockResult as { error?: unknown }).error === 'string'
- ? (blockResult as { error: string }).error
- : null
-
- if (blockErrorMessage) {
- blockErrors[blockId] = blockErrorMessage
- } else {
- for (const out of outputs) {
- const plucked = pluckByPath(blockResult, out.path)
- if (plucked === undefined) continue
- accumulatedData[out.columnName] = plucked as RowData[string]
- }
- }
- runningBlockIds.delete(blockId)
- schedulePartialWrite()
- }
+ progressWriter = createWorkflowCellProgressWriter({
+ group,
+ signal,
+ writeProgress: ({ dataPatch, eventOutputs, runningBlockIds, blockErrors }) =>
+ writeState(
+ {
+ status: 'running',
+ executionId,
+ jobId: null,
+ workflowId,
+ error: null,
+ runningBlockIds,
+ blockErrors,
+ },
+ dataPatch,
+ eventOutputs
+ ),
+ onWriteError: (err) => {
+ logger.warn(
+ `Per-block partial write failed (table=${tableId} row=${rowId} group=${groupId})`,
+ { cause: describeError(err), retryable: isRetryableInfrastructureError(err) }
+ )
+ },
+ })
// Enforce the per-plan execution timeout (from preprocessing), combined
// with the existing cancel signal so either a timeout or a Stop aborts.
@@ -777,8 +759,9 @@ async function runWorkflowAndWriteTerminal(
// state loaded above for start-block / output-block resolution.
useDraftState: deploymentMode !== 'deployed',
abortSignal,
- onBlockStart,
- onBlockComplete,
+ onBlockStart: progressWriter.onBlockStart,
+ onBlockComplete: progressWriter.onBlockComplete,
+ billingAttribution: preprocess.billingAttribution,
},
executionId
)
@@ -786,8 +769,10 @@ async function runWorkflowAndWriteTerminal(
timeoutController.cleanup()
}
- terminalWritten = true
- await writeChain.catch(() => {})
+ await progressWriter.finish()
+ const eventOutputs = progressWriter.getEventOutputs()
+ const pendingDataPatch = progressWriter.getPendingDataPatch()
+ const blockErrors = progressWriter.getBlockErrors()
if (result.status === 'paused') {
await writeState(
@@ -800,7 +785,8 @@ async function runWorkflowAndWriteTerminal(
runningBlockIds: [],
blockErrors,
},
- accumulatedData
+ pendingDataPatch,
+ eventOutputs
)
await stashCellContextForResume({
executionId,
@@ -824,7 +810,8 @@ async function runWorkflowAndWriteTerminal(
runningBlockIds: [],
blockErrors,
},
- accumulatedData
+ pendingDataPatch,
+ eventOutputs
)
return result.success ? 'completed' : 'error'
} catch (err) {
@@ -838,8 +825,7 @@ async function runWorkflowAndWriteTerminal(
retryable: isRetryableInfrastructureError(err),
}
)
- terminalWritten = true
- await writeChain.catch(() => {})
+ await progressWriter?.finish()
try {
await writeState({
status: 'error',
@@ -848,7 +834,7 @@ async function runWorkflowAndWriteTerminal(
workflowId,
error: message,
runningBlockIds: [],
- blockErrors,
+ blockErrors: progressWriter?.getBlockErrors() ?? {},
})
} catch (writeErr) {
logger.error('Also failed to write error state', {
@@ -872,6 +858,6 @@ export const workflowGroupCellTask = task({
name: 'workflow-group-cell',
concurrencyLimit: 20,
},
- run: (payload: WorkflowGroupCellPayload, { signal }) =>
+ run: (payload: QueuedWorkflowGroupCellPayload, { signal }) =>
executeWorkflowGroupCellJob(payload, signal),
})
diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts
index 266772c9a68..1be362811d2 100644
--- a/apps/sim/background/workflow-execution.ts
+++ b/apps/sim/background/workflow-execution.ts
@@ -2,6 +2,11 @@ import { createLogger, runWithRequestContext } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { task } from '@trigger.dev/sdk'
+import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
+import {
+ assertBillingAttributionSnapshot,
+ type BillingAttributionSnapshot,
+} from '@/lib/billing/core/billing-attribution'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
import { preprocessExecution } from '@/lib/execution/preprocessing'
@@ -39,7 +44,8 @@ export function buildWorkflowCorrelation(
export type WorkflowExecutionPayload = {
workflowId: string
userId: string
- workspaceId?: string
+ billingAttribution: BillingAttributionSnapshot
+ workspaceId: string
input?: any
triggerType?: CoreTriggerType
executionId?: string
@@ -48,6 +54,8 @@ export type WorkflowExecutionPayload = {
metadata?: Record
callChain?: string[]
executionMode?: 'sync' | 'stream' | 'async'
+ /** Upstream preprocessing already consumed rate-limit quota and owns the usage reservation. */
+ admissionCompleted?: boolean
}
/**
@@ -60,6 +68,19 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
const correlation = buildWorkflowCorrelation(payload)
const executionId = correlation.executionId
const requestId = correlation.requestId
+ let billingAttribution: BillingAttributionSnapshot
+ try {
+ billingAttribution = assertBillingAttributionSnapshot(payload.billingAttribution)
+ if (
+ billingAttribution.actorUserId !== payload.userId ||
+ billingAttribution.workspaceId !== payload.workspaceId
+ ) {
+ throw new Error('Workflow job billing attribution does not match its actor and workspace')
+ }
+ } catch (error) {
+ await releaseExecutionSlot(executionId)
+ throw error
+ }
return runWithRequestContext({ requestId }, async () => {
logger.info(`[${requestId}] Starting workflow execution job: ${workflowId}`, {
@@ -78,10 +99,12 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
triggerType: triggerType,
executionId: executionId,
requestId: requestId,
- checkRateLimit: true,
+ checkRateLimit: payload.admissionCompleted !== true,
checkDeployment: true,
+ skipUsageLimits: payload.admissionCompleted === true,
loggingSession: loggingSession,
triggerData: { correlation },
+ billingAttribution,
})
if (!preprocessResult.success) {
@@ -109,6 +132,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
workflowId,
workspaceId,
userId: actorUserId,
+ billingAttribution: preprocessResult.billingAttribution,
sessionUserId: undefined,
workflowUserId: workflow.userId,
triggerType: payload.triggerType || 'api',
diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx
new file mode 100644
index 00000000000..29e70d8cc4a
--- /dev/null
+++ b/apps/sim/components/settings/account-settings-renderer.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { useEffect } from 'react'
+import dynamic from 'next/dynamic'
+import { usePostHog } from 'posthog-js/react'
+import type { AccountSettingsSection } from '@/components/settings/navigation'
+import { captureEvent } from '@/lib/posthog/client'
+import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
+
+const Billing = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then(
+ (module) => module.Billing
+ )
+)
+const ApiKeys = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
+ (module) => module.ApiKeys
+ )
+)
+const Copilot = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then(
+ (module) => module.Copilot
+ )
+)
+const Admin = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then(
+ (module) => module.Admin
+ )
+)
+const Mothership = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then(
+ (module) => module.Mothership
+ )
+)
+
+interface AccountSettingsRendererProps {
+ section: AccountSettingsSection
+}
+
+export function AccountSettingsRenderer({ section }: AccountSettingsRendererProps) {
+ const posthog = usePostHog()
+
+ useEffect(() => {
+ captureEvent(posthog, 'settings_tab_viewed', { plane: 'account', section })
+ }, [posthog, section])
+
+ if (section === 'general') return
+ if (section === 'billing') return
+ if (section === 'api-keys') return
+ if (section === 'copilot') return
+ if (section === 'admin') return
+ return
+}
diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts
new file mode 100644
index 00000000000..000f9966da5
--- /dev/null
+++ b/apps/sim/components/settings/navigation.test.ts
@@ -0,0 +1,343 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ ACCOUNT_SETTINGS_ITEMS,
+ ACCOUNT_SETTINGS_PATH_ALIASES,
+ canMutateWorkspaceSettingsSection,
+ getAccountSettingsHref,
+ getOrganizationSettingsHref,
+ getWorkspaceSettingsHref,
+ isOrganizationSettingsSectionAvailable,
+ LEGACY_SETTINGS_SECTIONS,
+ ORGANIZATION_SETTINGS_ITEMS,
+ ORGANIZATION_SETTINGS_PATH_ALIASES,
+ parseSettingsPathSection,
+ resolveLegacySettingsHref,
+ resolveOrganizationSectionAccess,
+ resolveWorkspaceNavigation,
+ WORKSPACE_SETTINGS_ITEMS,
+ WORKSPACE_SETTINGS_PATH_ALIASES,
+} from '@/components/settings/navigation'
+
+describe('settings navigation boundaries', () => {
+ it('builds canonical settings hrefs across all three planes', () => {
+ expect(getAccountSettingsHref('general')).toBe('/account/settings/general')
+ expect(getOrganizationSettingsHref('organization-a', 'members')).toBe(
+ '/organization/organization-a/settings/members'
+ )
+ expect(getWorkspaceSettingsHref('workspace-a', 'teammates')).toBe(
+ '/workspace/workspace-a/settings/teammates'
+ )
+ })
+
+ it('preserves encoded query parameters on canonical settings hrefs', () => {
+ const searchParams = new URLSearchParams([
+ ['mcpServerId', 'server/a'],
+ ['view', 'tools and prompts'],
+ ])
+
+ expect(getWorkspaceSettingsHref('workspace-a', 'mcp', searchParams)).toBe(
+ '/workspace/workspace-a/settings/mcp?mcpServerId=server%2Fa&view=tools+and+prompts'
+ )
+ })
+
+ it('parses canonical, nested, and aliased account settings paths', () => {
+ const parseAccountPath = (path: string, defaultSection: 'general' | null) =>
+ parseSettingsPathSection({
+ path,
+ items: ACCOUNT_SETTINGS_ITEMS,
+ defaultSection,
+ aliases: ACCOUNT_SETTINGS_PATH_ALIASES,
+ })
+
+ expect(parseAccountPath('general', null)).toBe('general')
+ expect(parseAccountPath('/account/settings/billing/credit-usage', null)).toBe('billing')
+ expect(parseAccountPath('/account/settings/apikeys', null)).toBe('api-keys')
+ expect(parseAccountPath('/account/settings/not-a-section', null)).toBeNull()
+ expect(parseAccountPath('/account/settings', 'general')).toBe('general')
+ })
+
+ it('parses canonical, aliased, and invalid organization settings paths', () => {
+ const parseOrganizationPath = (path: string) =>
+ parseSettingsPathSection({
+ path,
+ items: ORGANIZATION_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: ORGANIZATION_SETTINGS_PATH_ALIASES,
+ })
+
+ expect(parseOrganizationPath('sso')).toBe('sso')
+ expect(parseOrganizationPath('/organization/org-a/settings/organization')).toBe('members')
+ expect(parseOrganizationPath('/organization/org-a/settings/not-a-section')).toBeNull()
+ })
+
+ it('parses canonical, aliased, and invalid workspace settings paths', () => {
+ const parseWorkspacePath = (path: string) =>
+ parseSettingsPathSection({
+ path,
+ items: WORKSPACE_SETTINGS_ITEMS,
+ defaultSection: null,
+ aliases: WORKSPACE_SETTINGS_PATH_ALIASES,
+ })
+
+ expect(parseWorkspacePath('secrets')).toBe('secrets')
+ expect(parseWorkspacePath('/workspace/workspace-a/settings/apikeys')).toBe('api-keys')
+ expect(parseWorkspacePath('/workspace/workspace-a/settings/not-a-section')).toBeNull()
+ })
+
+ it('classifies every legacy section into exactly one settings plane', () => {
+ const classified = LEGACY_SETTINGS_SECTIONS.map(({ legacySection, plane }) => ({
+ legacySection,
+ plane,
+ }))
+
+ expect(new Set(classified.map(({ legacySection }) => legacySection)).size).toBe(
+ classified.length
+ )
+ expect(classified.map(({ legacySection }) => legacySection).sort()).toEqual(
+ [
+ 'access-control',
+ 'admin',
+ 'apikeys',
+ 'audit-logs',
+ 'billing',
+ 'byok',
+ 'copilot',
+ 'custom-blocks',
+ 'custom-tools',
+ 'data-drains',
+ 'data-retention',
+ 'forks',
+ 'general',
+ 'inbox',
+ 'mcp',
+ 'mothership',
+ 'organization',
+ 'recently-deleted',
+ 'secrets',
+ 'sso',
+ 'subscription',
+ 'team',
+ 'teammates',
+ 'whitelabeling',
+ 'workflow-mcp-servers',
+ ].sort()
+ )
+ expect(
+ classified.every(({ plane }) => ['account', 'organization', 'workspace'].includes(plane))
+ ).toBe(true)
+ })
+
+ it('keeps API keys split between account and workspace settings', () => {
+ expect(ACCOUNT_SETTINGS_ITEMS.some(({ id }) => id === 'api-keys')).toBe(true)
+ expect(WORKSPACE_SETTINGS_ITEMS.some(({ id }) => id === 'api-keys')).toBe(true)
+ expect(ORGANIZATION_SETTINGS_ITEMS.some(({ id }) => String(id) === 'api-keys')).toBe(false)
+ })
+
+ it('resolves organization legacy links from the routed workspace organization', () => {
+ expect(
+ resolveLegacySettingsHref({
+ legacySection: 'sso',
+ workspaceId: 'workspace-b',
+ hostOrganizationId: 'org-b',
+ isTargetOrganizationMember: true,
+ })
+ ).toBe('/organization/org-b/settings/sso')
+
+ expect(
+ resolveLegacySettingsHref({
+ legacySection: 'sso',
+ workspaceId: 'workspace-b',
+ hostOrganizationId: 'org-b',
+ isTargetOrganizationMember: false,
+ })
+ ).toBe('/organization/org-b/settings/unavailable')
+ })
+
+ it('redirects legacy aliases to canonical account, organization, and workspace routes', () => {
+ expect(
+ resolveLegacySettingsHref({
+ legacySection: 'general',
+ workspaceId: 'workspace-b',
+ hostOrganizationId: 'org-b',
+ isTargetOrganizationMember: true,
+ })
+ ).toBe('/account/settings/general')
+ expect(
+ resolveLegacySettingsHref({
+ legacySection: 'team',
+ workspaceId: 'workspace-b',
+ hostOrganizationId: 'org-b',
+ isTargetOrganizationMember: true,
+ })
+ ).toBe('/organization/org-b/settings/members')
+ expect(
+ resolveLegacySettingsHref({
+ legacySection: 'apikeys',
+ workspaceId: 'workspace-b',
+ hostOrganizationId: 'org-b',
+ isTargetOrganizationMember: true,
+ })
+ ).toBe('/workspace/workspace-b/settings/api-keys')
+ })
+
+ it.each([
+ ['integrations', '/workspace/workspace-b/integrations'],
+ ['skills', '/workspace/workspace-b/skills'],
+ ])('preserves the top-level destination for legacy %s links', (legacySection, destination) => {
+ expect(
+ resolveLegacySettingsHref({
+ legacySection,
+ workspaceId: 'workspace-b',
+ hostOrganizationId: 'org-b',
+ isTargetOrganizationMember: true,
+ })
+ ).toBe(destination)
+ })
+
+ it('requires target-organization membership and admin authority', () => {
+ expect(
+ resolveOrganizationSectionAccess({
+ section: 'members',
+ isTargetOrganizationMember: false,
+ isTargetOrganizationAdmin: false,
+ })
+ ).toBe('unavailable')
+ expect(
+ resolveOrganizationSectionAccess({
+ section: 'members',
+ isTargetOrganizationMember: true,
+ isTargetOrganizationAdmin: false,
+ })
+ ).toBe('view')
+ expect(
+ resolveOrganizationSectionAccess({
+ section: 'sso',
+ isTargetOrganizationMember: true,
+ isTargetOrganizationAdmin: false,
+ })
+ ).toBe('unavailable')
+ expect(
+ resolveOrganizationSectionAccess({
+ section: 'sso',
+ isTargetOrganizationMember: true,
+ isTargetOrganizationAdmin: true,
+ })
+ ).toBe('manage')
+ })
+
+ it('gates organization control-plane sections by the target organization plan', () => {
+ const hostedFree = {
+ billingEnabled: true,
+ hasEnterprisePlan: false,
+ hosted: true,
+ selfHosted: {},
+ }
+ expect(isOrganizationSettingsSectionAvailable('members', hostedFree)).toBe(true)
+ expect(isOrganizationSettingsSectionAvailable('billing', hostedFree)).toBe(true)
+ expect(isOrganizationSettingsSectionAvailable('sso', hostedFree)).toBe(false)
+ expect(
+ isOrganizationSettingsSectionAvailable('sso', {
+ ...hostedFree,
+ hasEnterprisePlan: true,
+ })
+ ).toBe(true)
+ })
+
+ it.each([
+ {
+ permission: 'read' as const,
+ visible: [
+ 'teammates',
+ 'secrets',
+ 'byok',
+ 'custom-tools',
+ 'mcp',
+ 'workflow-mcp-servers',
+ 'api-keys',
+ 'inbox',
+ 'recently-deleted',
+ 'custom-blocks',
+ ],
+ mutable: [],
+ },
+ {
+ permission: 'write' as const,
+ visible: [
+ 'teammates',
+ 'secrets',
+ 'byok',
+ 'custom-tools',
+ 'mcp',
+ 'workflow-mcp-servers',
+ 'api-keys',
+ 'inbox',
+ 'recently-deleted',
+ 'custom-blocks',
+ ],
+ mutable: ['secrets', 'custom-tools', 'mcp', 'workflow-mcp-servers', 'recently-deleted'],
+ },
+ {
+ permission: 'admin' as const,
+ visible: WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id),
+ mutable: WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id),
+ },
+ ])(
+ 'makes workspace $permission navigation and mutation chrome explicit',
+ ({ permission, visible, mutable }) => {
+ const items = resolveWorkspaceNavigation({
+ permission,
+ permissionConfig: {},
+ entitlements: {
+ byok: true,
+ customBlocks: true,
+ forks: true,
+ inbox: true,
+ },
+ })
+
+ expect(items.map(({ id }) => id)).toEqual(visible)
+ expect(items.filter(({ canMutate }) => canMutate).map(({ id }) => id)).toEqual(mutable)
+ }
+ )
+
+ it('applies permission-group hiding as an independent axis', () => {
+ const items = resolveWorkspaceNavigation({
+ permission: 'admin',
+ permissionConfig: {
+ hideSecretsTab: true,
+ hideApiKeysTab: true,
+ hideInboxTab: true,
+ disableMcpTools: true,
+ disableCustomTools: true,
+ },
+ entitlements: {
+ byok: true,
+ customBlocks: true,
+ forks: true,
+ inbox: true,
+ },
+ })
+
+ expect(items.map(({ id }) => id)).toEqual([
+ 'teammates',
+ 'byok',
+ 'workflow-mcp-servers',
+ 'recently-deleted',
+ 'forks',
+ 'custom-blocks',
+ ])
+ })
+
+ it('uses server-aligned mutation permissions for workspace settings', () => {
+ const writer = { canEdit: true, canAdmin: false }
+ expect(canMutateWorkspaceSettingsSection('custom-tools', writer)).toBe(true)
+ expect(canMutateWorkspaceSettingsSection('mcp', writer)).toBe(true)
+ expect(canMutateWorkspaceSettingsSection('recently-deleted', writer)).toBe(true)
+ expect(canMutateWorkspaceSettingsSection('workflow-mcp-servers', writer)).toBe(true)
+ expect(canMutateWorkspaceSettingsSection('api-keys', writer)).toBe(false)
+ expect(canMutateWorkspaceSettingsSection('inbox', writer)).toBe(false)
+ })
+})
diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts
new file mode 100644
index 00000000000..0390d11b39c
--- /dev/null
+++ b/apps/sim/components/settings/navigation.ts
@@ -0,0 +1,634 @@
+import type { ComponentType } from 'react'
+import {
+ ClipboardList,
+ Database,
+ HexSimple,
+ Key,
+ KeySquare,
+ Lock,
+ LogIn,
+ Palette,
+ Send,
+ Server,
+ Settings,
+ ShieldCheck,
+ Shuffle,
+ TerminalWindow,
+ TrashOutline,
+ Upload,
+ User,
+ Users,
+ Wrench,
+} from '@sim/emcn/icons'
+import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
+import { McpIcon } from '@/components/icons'
+import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
+
+export type SettingsPlane = 'account' | 'organization' | 'workspace'
+
+export type AccountSettingsSection =
+ | 'general'
+ | 'billing'
+ | 'api-keys'
+ | 'copilot'
+ | 'admin'
+ | 'mothership'
+
+export type OrganizationSettingsSection =
+ | 'members'
+ | 'billing'
+ | 'access-control'
+ | 'audit-logs'
+ | 'sso'
+ | 'data-retention'
+ | 'data-drains'
+ | 'whitelabeling'
+
+export type WorkspaceSettingsSection =
+ | 'teammates'
+ | 'secrets'
+ | 'byok'
+ | 'custom-tools'
+ | 'mcp'
+ | 'workflow-mcp-servers'
+ | 'api-keys'
+ | 'inbox'
+ | 'recently-deleted'
+ | 'forks'
+ | 'custom-blocks'
+
+export type SettingsSection =
+ | AccountSettingsSection
+ | OrganizationSettingsSection
+ | WorkspaceSettingsSection
+
+export type OrganizationSettingsRouteSection = OrganizationSettingsSection | 'unavailable'
+
+export interface SettingsNavigationItem {
+ id: Section
+ label: string
+ description: string
+ icon: ComponentType<{ className?: string }>
+ group: string
+ docsLink?: string
+}
+
+type SettingsHrefSearchParams = Pick
+
+function withSettingsSearchParams(
+ pathname: string,
+ searchParams?: SettingsHrefSearchParams
+): string {
+ const query = searchParams?.toString()
+ return query ? `${pathname}?${query}` : pathname
+}
+
+export function getAccountSettingsHref(
+ section: AccountSettingsSection,
+ searchParams?: SettingsHrefSearchParams
+): string {
+ return withSettingsSearchParams(`/account/settings/${section}`, searchParams)
+}
+
+export function getOrganizationSettingsHref(
+ organizationId: string,
+ section: OrganizationSettingsRouteSection,
+ searchParams?: SettingsHrefSearchParams
+): string {
+ return withSettingsSearchParams(
+ `/organization/${organizationId}/settings/${section}`,
+ searchParams
+ )
+}
+
+export function getWorkspaceSettingsHref(
+ workspaceId: string,
+ section: WorkspaceSettingsSection,
+ searchParams?: SettingsHrefSearchParams
+): string {
+ return withSettingsSearchParams(`/workspace/${workspaceId}/settings/${section}`, searchParams)
+}
+
+export const ACCOUNT_SETTINGS_PATH_ALIASES = {
+ apikeys: 'api-keys',
+} as const satisfies Readonly>
+
+export const ORGANIZATION_SETTINGS_PATH_ALIASES = {
+ organization: 'members',
+} as const satisfies Readonly>
+
+export const WORKSPACE_SETTINGS_PATH_ALIASES = {
+ apikeys: 'api-keys',
+} as const satisfies Readonly>
+
+interface ParseSettingsPathSectionOptions<
+ Section extends string,
+ DefaultSection extends Section | null,
+> {
+ path: string | null | undefined
+ items: readonly SettingsNavigationItem[]
+ defaultSection: DefaultSection
+ aliases?: Readonly>>
+}
+
+/**
+ * Resolves the first segment after `settings`, or a route-provided section
+ * segment, against a typed settings catalog.
+ */
+export function parseSettingsPathSection<
+ Section extends string,
+ const DefaultSection extends Section | null,
+>({
+ path,
+ items,
+ defaultSection,
+ aliases,
+}: ParseSettingsPathSectionOptions): Section | DefaultSection {
+ if (!path) return defaultSection
+
+ const pathname = path.split(/[?#]/, 1)[0]
+ const segments = pathname.split('/').filter(Boolean)
+ const settingsIndex = segments.lastIndexOf('settings')
+ let pathSection: string | undefined
+ if (settingsIndex === -1) {
+ pathSection = segments.length === 1 ? segments[0] : undefined
+ } else {
+ pathSection = segments[settingsIndex + 1]
+ }
+ if (!pathSection) return defaultSection
+
+ const normalized = aliases?.[pathSection] ?? pathSection
+ return items.find((item) => item.id === normalized)?.id ?? defaultSection
+}
+
+export const ACCOUNT_SETTINGS_GROUPS = [
+ { key: 'account', title: 'Account' },
+ { key: 'developer', title: 'Developer' },
+ { key: 'platform', title: 'Platform' },
+] as const
+
+export const ORGANIZATION_SETTINGS_GROUPS = [
+ { key: 'organization', title: 'Organization' },
+ { key: 'security', title: 'Security' },
+ { key: 'enterprise', title: 'Enterprise' },
+] as const
+
+export const WORKSPACE_SETTINGS_GROUPS = [
+ { key: 'workspace', title: 'Workspace' },
+ { key: 'tools', title: 'Tools' },
+ { key: 'system', title: 'System' },
+ { key: 'enterprise', title: 'Enterprise' },
+] as const
+
+export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem[] = [
+ {
+ id: 'general',
+ label: 'General',
+ description: 'Manage your profile, appearance, and preferences.',
+ icon: Settings,
+ group: 'account',
+ },
+ {
+ id: 'billing',
+ label: 'Billing',
+ description: 'Manage your personal plan, usage, and invoices.',
+ icon: ClipboardList,
+ group: 'account',
+ },
+ {
+ id: 'api-keys',
+ label: 'Sim API keys',
+ description: 'Create and manage your personal Sim API keys.',
+ icon: TerminalWindow,
+ group: 'developer',
+ },
+ {
+ id: 'copilot',
+ label: 'Chat keys',
+ description: 'Manage the model-provider keys that power Chat.',
+ icon: HexSimple,
+ group: 'developer',
+ },
+ {
+ id: 'admin',
+ label: 'Admin',
+ description: 'Manage platform users and superuser preferences.',
+ icon: Lock,
+ group: 'platform',
+ },
+ {
+ id: 'mothership',
+ label: 'Mothership',
+ description: 'Manage internal operations and license settings.',
+ icon: Server,
+ group: 'platform',
+ },
+]
+
+export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem[] = [
+ {
+ id: 'members',
+ label: 'Members',
+ description: 'Manage organization members, roles, and seats.',
+ icon: Users,
+ group: 'organization',
+ },
+ {
+ id: 'billing',
+ label: 'Billing',
+ description: 'Manage the organization plan, usage, and invoices.',
+ icon: ClipboardList,
+ group: 'organization',
+ },
+ {
+ id: 'access-control',
+ label: 'Access control',
+ description: 'Manage permission groups across the organization.',
+ icon: ShieldCheck,
+ group: 'security',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/access-control',
+ },
+ {
+ id: 'audit-logs',
+ label: 'Audit logs',
+ description: 'Review activity and changes across the organization.',
+ icon: ClipboardList,
+ group: 'security',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/audit-logs',
+ },
+ {
+ id: 'sso',
+ label: 'Single sign-on',
+ description: 'Configure single sign-on for the organization.',
+ icon: LogIn,
+ group: 'security',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/sso',
+ },
+ {
+ id: 'data-retention',
+ label: 'Data retention',
+ description: 'Control retention windows and PII redaction.',
+ icon: Database,
+ group: 'enterprise',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/data-retention',
+ },
+ {
+ id: 'data-drains',
+ label: 'Data drains',
+ description: 'Stream organization logs to external destinations.',
+ icon: Upload,
+ group: 'enterprise',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/data-drains',
+ },
+ {
+ id: 'whitelabeling',
+ label: 'Whitelabeling',
+ description: 'Customize organization branding and appearance.',
+ icon: Palette,
+ group: 'enterprise',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling',
+ },
+]
+
+export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem[] = [
+ {
+ id: 'teammates',
+ label: 'Teammates',
+ description: 'View and manage teammates in this workspace.',
+ icon: User,
+ group: 'workspace',
+ },
+ {
+ id: 'secrets',
+ label: 'Secrets',
+ description: 'Store environment variables for your workflows.',
+ icon: Key,
+ group: 'workspace',
+ },
+ {
+ id: 'byok',
+ label: 'BYOK',
+ description: 'Manage workspace model-provider API keys.',
+ icon: KeySquare,
+ group: 'workspace',
+ },
+ {
+ id: 'custom-tools',
+ label: 'Custom tools',
+ description: 'Create and manage custom tools for your agents.',
+ icon: Wrench,
+ group: 'tools',
+ },
+ {
+ id: 'mcp',
+ label: 'MCP tools',
+ description: 'Connect MCP servers and use tools in workflows.',
+ icon: McpIcon,
+ group: 'tools',
+ },
+ {
+ id: 'workflow-mcp-servers',
+ label: 'MCP servers',
+ description: 'Expose workflows as tools on an MCP server.',
+ icon: Server,
+ group: 'tools',
+ },
+ {
+ id: 'api-keys',
+ label: 'Sim API keys',
+ description: 'Manage workspace API keys and personal-key policy.',
+ icon: TerminalWindow,
+ group: 'system',
+ },
+ {
+ id: 'inbox',
+ label: 'Sim mailer',
+ description: 'Configure incoming email for this workspace.',
+ icon: Send,
+ group: 'system',
+ },
+ {
+ id: 'recently-deleted',
+ label: 'Recently deleted',
+ description: 'Restore workspace items deleted in the last 30 days.',
+ icon: TrashOutline,
+ group: 'system',
+ },
+ {
+ id: 'forks',
+ label: 'Workspace forks',
+ description: 'Fork this workspace and synchronize changes.',
+ icon: Shuffle,
+ group: 'enterprise',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/forks',
+ },
+ {
+ id: 'custom-blocks',
+ label: 'Custom blocks',
+ description: 'Publish workspace workflows as reusable blocks.',
+ icon: HexSimple,
+ group: 'enterprise',
+ docsLink: 'https://docs.sim.ai/platform/enterprise/custom-blocks',
+ },
+]
+
+interface LegacyAccountSettingsSection {
+ legacySection: string
+ plane: 'account'
+ section: AccountSettingsSection
+}
+
+interface LegacyOrganizationSettingsSection {
+ legacySection: string
+ plane: 'organization'
+ section: OrganizationSettingsSection
+}
+
+interface LegacyWorkspaceSettingsSection {
+ legacySection: string
+ plane: 'workspace'
+ section: WorkspaceSettingsSection
+}
+
+export type LegacySettingsSection =
+ | LegacyAccountSettingsSection
+ | LegacyOrganizationSettingsSection
+ | LegacyWorkspaceSettingsSection
+
+const LEGACY_TOP_LEVEL_WORKSPACE_SECTIONS = ['integrations', 'skills'] as const
+
+export function getLegacyTopLevelWorkspaceHref(
+ workspaceId: string,
+ legacySection: string
+): string | null {
+ const section = LEGACY_TOP_LEVEL_WORKSPACE_SECTIONS.find(
+ (candidate) => candidate === legacySection
+ )
+ return section ? `/workspace/${workspaceId}/${section}` : null
+}
+
+export const LEGACY_SETTINGS_SECTIONS: LegacySettingsSection[] = [
+ { legacySection: 'general', plane: 'account', section: 'general' },
+ { legacySection: 'billing', plane: 'organization', section: 'billing' },
+ { legacySection: 'subscription', plane: 'organization', section: 'billing' },
+ { legacySection: 'copilot', plane: 'account', section: 'copilot' },
+ { legacySection: 'admin', plane: 'account', section: 'admin' },
+ { legacySection: 'mothership', plane: 'account', section: 'mothership' },
+ { legacySection: 'organization', plane: 'organization', section: 'members' },
+ { legacySection: 'team', plane: 'organization', section: 'members' },
+ { legacySection: 'access-control', plane: 'organization', section: 'access-control' },
+ { legacySection: 'audit-logs', plane: 'organization', section: 'audit-logs' },
+ { legacySection: 'sso', plane: 'organization', section: 'sso' },
+ { legacySection: 'data-retention', plane: 'organization', section: 'data-retention' },
+ { legacySection: 'data-drains', plane: 'organization', section: 'data-drains' },
+ { legacySection: 'whitelabeling', plane: 'organization', section: 'whitelabeling' },
+ { legacySection: 'teammates', plane: 'workspace', section: 'teammates' },
+ { legacySection: 'secrets', plane: 'workspace', section: 'secrets' },
+ { legacySection: 'byok', plane: 'workspace', section: 'byok' },
+ { legacySection: 'custom-tools', plane: 'workspace', section: 'custom-tools' },
+ { legacySection: 'mcp', plane: 'workspace', section: 'mcp' },
+ {
+ legacySection: 'workflow-mcp-servers',
+ plane: 'workspace',
+ section: 'workflow-mcp-servers',
+ },
+ { legacySection: 'apikeys', plane: 'workspace', section: 'api-keys' },
+ { legacySection: 'inbox', plane: 'workspace', section: 'inbox' },
+ { legacySection: 'recently-deleted', plane: 'workspace', section: 'recently-deleted' },
+ { legacySection: 'forks', plane: 'workspace', section: 'forks' },
+ { legacySection: 'custom-blocks', plane: 'workspace', section: 'custom-blocks' },
+]
+
+interface ResolveLegacySettingsHrefOptions {
+ legacySection: string
+ workspaceId: string
+ hostOrganizationId: string | null
+ isTargetOrganizationMember: boolean
+}
+
+export function resolveLegacySettingsHref({
+ legacySection,
+ workspaceId,
+ hostOrganizationId,
+ isTargetOrganizationMember,
+}: ResolveLegacySettingsHrefOptions): string {
+ const topLevelHref = getLegacyTopLevelWorkspaceHref(workspaceId, legacySection)
+ if (topLevelHref) return topLevelHref
+
+ const match = LEGACY_SETTINGS_SECTIONS.find((item) => item.legacySection === legacySection)
+ if (!match) return getWorkspaceSettingsHref(workspaceId, 'teammates')
+
+ if (match.plane === 'account') {
+ return getAccountSettingsHref(match.section)
+ }
+
+ if (match.plane === 'workspace') {
+ return getWorkspaceSettingsHref(workspaceId, match.section)
+ }
+
+ if (!hostOrganizationId) {
+ return match.section === 'billing'
+ ? getAccountSettingsHref('billing')
+ : getWorkspaceSettingsHref(workspaceId, 'teammates')
+ }
+
+ if (!isTargetOrganizationMember) {
+ return getOrganizationSettingsHref(hostOrganizationId, 'unavailable')
+ }
+
+ return getOrganizationSettingsHref(hostOrganizationId, match.section)
+}
+
+export type OrganizationSectionAccess = 'unavailable' | 'view' | 'manage'
+
+interface ResolveOrganizationSectionAccessOptions {
+ section: OrganizationSettingsSection
+ isTargetOrganizationMember: boolean
+ isTargetOrganizationAdmin: boolean
+}
+
+export function resolveOrganizationSectionAccess({
+ section,
+ isTargetOrganizationMember,
+ isTargetOrganizationAdmin,
+}: ResolveOrganizationSectionAccessOptions): OrganizationSectionAccess {
+ if (!isTargetOrganizationMember) return 'unavailable'
+ if (section === 'members') return isTargetOrganizationAdmin ? 'manage' : 'view'
+ return isTargetOrganizationAdmin ? 'manage' : 'unavailable'
+}
+
+export interface OrganizationSettingsFeatures {
+ billingEnabled: boolean
+ hasEnterprisePlan: boolean
+ hosted: boolean
+ selfHosted: Partial>
+}
+
+export function getOrganizationSettingsFeatures(
+ hasEnterprisePlan: boolean
+): OrganizationSettingsFeatures {
+ return {
+ billingEnabled: isBillingEnabled,
+ hasEnterprisePlan,
+ hosted: isHosted,
+ selfHosted: {
+ 'access-control': isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')),
+ 'audit-logs': isTruthy(getEnv('NEXT_PUBLIC_AUDIT_LOGS_ENABLED')),
+ sso: isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')),
+ 'data-retention': isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED')),
+ 'data-drains': isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')),
+ whitelabeling: isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')),
+ },
+ }
+}
+
+/**
+ * Applies deployment and target-organization plan gates without consulting the
+ * viewer's active organization.
+ */
+export function isOrganizationSettingsSectionAvailable(
+ section: OrganizationSettingsSection,
+ features: OrganizationSettingsFeatures
+): boolean {
+ if (section === 'members') return true
+ if (section === 'billing') return features.billingEnabled
+ if (features.hosted) return features.hasEnterprisePlan
+ return features.selfHosted[section] ?? false
+}
+
+export interface WorkspacePermissionConfig {
+ hideSecretsTab?: boolean
+ hideApiKeysTab?: boolean
+ hideInboxTab?: boolean
+ disableMcpTools?: boolean
+ disableCustomTools?: boolean
+}
+
+export interface WorkspaceSettingsEntitlements {
+ byok: boolean
+ customBlocks: boolean
+ forks: boolean
+ inbox: boolean
+}
+
+interface ResolveWorkspaceNavigationOptions {
+ permission: PermissionType
+ permissionConfig: WorkspacePermissionConfig
+ entitlements: WorkspaceSettingsEntitlements
+}
+
+export interface ResolvedWorkspaceNavigationItem
+ extends SettingsNavigationItem {
+ canMutate: boolean
+ locked: boolean
+}
+
+const WORKSPACE_MUTATION_PERMISSION: Record = {
+ teammates: 'admin',
+ secrets: 'write',
+ byok: 'admin',
+ 'custom-tools': 'write',
+ mcp: 'write',
+ 'workflow-mcp-servers': 'write',
+ 'api-keys': 'admin',
+ inbox: 'admin',
+ 'recently-deleted': 'write',
+ forks: 'admin',
+ 'custom-blocks': 'admin',
+}
+
+export interface WorkspaceMutationCapabilities {
+ canAdmin: boolean
+ canEdit: boolean
+}
+
+export function canMutateWorkspaceSettingsSection(
+ section: WorkspaceSettingsSection,
+ capabilities: WorkspaceMutationCapabilities
+): boolean {
+ return WORKSPACE_MUTATION_PERMISSION[section] === 'admin'
+ ? capabilities.canAdmin
+ : capabilities.canEdit
+}
+
+export function resolveWorkspaceNavigation({
+ permission,
+ permissionConfig,
+ entitlements,
+}: ResolveWorkspaceNavigationOptions): ResolvedWorkspaceNavigationItem[] {
+ return WORKSPACE_SETTINGS_ITEMS.flatMap((item) => {
+ if (item.id === 'secrets' && permissionConfig.hideSecretsTab) return []
+ if (item.id === 'api-keys' && permissionConfig.hideApiKeysTab) return []
+ if (item.id === 'inbox' && permissionConfig.hideInboxTab) return []
+ if (item.id === 'mcp' && permissionConfig.disableMcpTools) return []
+ if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) return []
+ if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return []
+ if (item.id === 'byok' && !entitlements.byok) return []
+ if (item.id === 'custom-blocks' && !entitlements.customBlocks) return []
+
+ const locked = item.id === 'inbox' && !entitlements.inbox
+ const canMutate =
+ !locked &&
+ canMutateWorkspaceSettingsSection(item.id, {
+ canEdit: permissionSatisfies(permission, 'write'),
+ canAdmin: permissionSatisfies(permission, 'admin'),
+ })
+
+ return [{ ...item, canMutate, locked }]
+ })
+}
+
+export function getSettingsSectionMeta(
+ plane: SettingsPlane,
+ section: string
+): Pick | null {
+ const catalog =
+ plane === 'account'
+ ? ACCOUNT_SETTINGS_ITEMS
+ : plane === 'organization'
+ ? ORGANIZATION_SETTINGS_ITEMS
+ : WORKSPACE_SETTINGS_ITEMS
+ const item = catalog.find((candidate) => candidate.id === section)
+ return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null
+}
diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx
new file mode 100644
index 00000000000..e14714772e8
--- /dev/null
+++ b/apps/sim/components/settings/organization-settings-renderer.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+import { useEffect } from 'react'
+import dynamic from 'next/dynamic'
+import { usePostHog } from 'posthog-js/react'
+import type { OrganizationSettingsSection } from '@/components/settings/navigation'
+import { captureEvent } from '@/lib/posthog/client'
+
+const TeamManagement = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
+ (module) => module.TeamManagement
+ )
+)
+const Billing = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then(
+ (module) => module.Billing
+ )
+)
+const AccessControl = dynamic(() =>
+ import('@/ee/access-control/components/access-control').then((module) => module.AccessControl)
+)
+const AuditLogs = dynamic(() =>
+ import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs)
+)
+const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO))
+const DataRetentionSettings = dynamic(() =>
+ import('@/ee/data-retention/components/data-retention-settings').then(
+ (module) => module.DataRetentionSettings
+ )
+)
+const DataDrainsSettings = dynamic(() =>
+ import('@/ee/data-drains/components/data-drains-settings').then(
+ (module) => module.DataDrainsSettings
+ )
+)
+const WhitelabelingSettings = dynamic(
+ () =>
+ import('@/ee/whitelabeling/components/whitelabeling-settings').then(
+ (module) => module.WhitelabelingSettings
+ ),
+ { ssr: false }
+)
+
+interface OrganizationSettingsRendererProps {
+ organizationId: string
+ section: OrganizationSettingsSection
+}
+
+export function OrganizationSettingsRenderer({
+ organizationId,
+ section,
+}: OrganizationSettingsRendererProps) {
+ const posthog = usePostHog()
+
+ useEffect(() => {
+ captureEvent(posthog, 'settings_tab_viewed', { plane: 'organization', section })
+ }, [posthog, section])
+
+ if (section === 'members') return
+ if (section === 'billing') return
+ if (section === 'access-control') {
+ return
+ }
+ if (section === 'audit-logs') return
+ if (section === 'sso') return
+ if (section === 'data-retention') {
+ return
+ }
+ if (section === 'data-drains') return
+ return
+}
diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx
new file mode 100644
index 00000000000..523558b863a
--- /dev/null
+++ b/apps/sim/components/settings/settings-header.tsx
@@ -0,0 +1,201 @@
+'use client'
+
+import {
+ type ComponentType,
+ createContext,
+ type ReactNode,
+ type Ref,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn'
+
+const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
+
+export interface SettingsAction {
+ text: string
+ icon?: ComponentType<{ className?: string }>
+ variant?: 'primary' | 'destructive'
+ active?: boolean
+ onSelect: () => void
+ disabled?: boolean
+ tooltip?: string
+ onPrefetch?: () => void
+}
+
+export interface SettingsHeaderSearch {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+}
+
+export interface SettingsBackAction {
+ text: string
+ icon?: ComponentType<{ className?: string }>
+ onSelect: () => void
+}
+
+export interface SettingsHeaderConfig {
+ title?: string
+ description?: string
+ docsLink?: string
+ back?: SettingsBackAction
+ actions?: SettingsAction[]
+ search?: SettingsHeaderSearch
+ scrollContainerRef?: Ref
+}
+
+const EMPTY_CONFIG: SettingsHeaderConfig = {}
+const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null)
+
+interface ReadContextValue {
+ configRef: { current: SettingsHeaderConfig }
+ signature: string
+}
+
+const ReadContext = createContext(null)
+
+function computeSignature(config: SettingsHeaderConfig): string {
+ return JSON.stringify({
+ title: config.title ?? '',
+ description: config.description ?? '',
+ docsLink: config.docsLink ?? '',
+ back: config.back ? [config.back.text, config.back.icon ? 1 : 0] : null,
+ actions: config.actions?.map((action) => [
+ action.text,
+ action.variant ?? '',
+ action.active ?? false,
+ action.disabled ?? false,
+ action.icon ? 1 : 0,
+ action.tooltip ?? '',
+ action.onPrefetch ? 1 : 0,
+ ]),
+ search: config.search
+ ? [config.search.value, config.search.placeholder ?? '', config.search.disabled ?? false]
+ : null,
+ })
+}
+
+export function SettingsHeaderProvider({ children }: { children: ReactNode }) {
+ const configRef = useRef(EMPTY_CONFIG)
+ const [signature, setSignature] = useState('')
+
+ const register = useCallback((config: SettingsHeaderConfig) => {
+ configRef.current = config
+ const next = computeSignature(config)
+ setSignature((previous) => (previous === next ? previous : next))
+ }, [])
+
+ const readValue = useMemo(() => ({ configRef, signature }), [signature])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useSettingsHeader(config: SettingsHeaderConfig) {
+ const register = useContext(RegisterContext)
+
+ useIsomorphicLayoutEffect(() => {
+ register?.(config)
+ })
+
+ useIsomorphicLayoutEffect(() => {
+ return () => register?.(EMPTY_CONFIG)
+ }, [register])
+}
+
+export function SettingsHeaderShell({ children }: { children: ReactNode }) {
+ const read = useContext(ReadContext)
+ const configRef = read?.configRef
+ const config = configRef?.current ?? EMPTY_CONFIG
+ const { title, description, docsLink, back, actions, search, scrollContainerRef } = config
+
+ return (
+
+
+ {back ? (
+
configRef?.current.back?.onSelect()}>
+ {back.text}
+
+ ) : (
+
+ )}
+
+ {docsLink && (
+
+ Docs
+
+ )}
+ {actions?.map((action, index) => {
+ const chip = (
+ configRef?.current.actions?.[index]?.onSelect()}
+ onMouseEnter={
+ action.onPrefetch
+ ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
+ : undefined
+ }
+ onFocus={
+ action.onPrefetch
+ ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
+ : undefined
+ }
+ disabled={action.disabled}
+ >
+ {action.text}
+
+ )
+ return action.tooltip ? (
+
+
+ {chip}
+
+ {action.tooltip}
+
+ ) : (
+ chip
+ )
+ })}
+
+
+
+
+ {(title || description) && (
+
+ {title &&
{title} }
+ {description &&
{description}
}
+
+ )}
+ {search && (
+
configRef?.current.search?.onChange(event.target.value)}
+ disabled={search.disabled}
+ autoComplete='off'
+ className='w-full'
+ />
+ )}
+ {children}
+
+
+
+ )
+}
diff --git a/apps/sim/components/settings/settings-panel.tsx b/apps/sim/components/settings/settings-panel.tsx
new file mode 100644
index 00000000000..267fdd529ee
--- /dev/null
+++ b/apps/sim/components/settings/settings-panel.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+import { createContext, type ReactNode, type Ref, useContext } from 'react'
+import { getSettingsSectionMeta, type SettingsPlane } from '@/components/settings/navigation'
+import {
+ type SettingsAction,
+ type SettingsBackAction,
+ type SettingsHeaderSearch,
+ useSettingsHeader,
+} from '@/components/settings/settings-header'
+
+interface SettingsSectionContextValue {
+ plane: SettingsPlane
+ section: string
+}
+
+const SettingsSectionContext = createContext(null)
+
+interface SettingsSectionProviderProps extends SettingsSectionContextValue {
+ children: ReactNode
+}
+
+export function SettingsSectionProvider({
+ plane,
+ section,
+ children,
+}: SettingsSectionProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+interface SettingsPanelProps {
+ children?: ReactNode
+ actions?: SettingsAction[]
+ back?: SettingsBackAction
+ search?: SettingsHeaderSearch
+ title?: string
+ description?: string
+ docsLink?: string
+ scrollContainerRef?: Ref
+}
+
+export function SettingsPanel({
+ children,
+ actions,
+ back,
+ search,
+ title,
+ description,
+ docsLink,
+ scrollContainerRef,
+}: SettingsPanelProps) {
+ const context = useContext(SettingsSectionContext)
+ const meta = context ? getSettingsSectionMeta(context.plane, context.section) : null
+
+ useSettingsHeader({
+ title: title ?? meta?.label,
+ description: title !== undefined ? description : (description ?? meta?.description),
+ docsLink: docsLink ?? meta?.docsLink,
+ back,
+ actions,
+ search,
+ scrollContainerRef,
+ })
+
+ return <>{children}>
+}
diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx
new file mode 100644
index 00000000000..97df881cb56
--- /dev/null
+++ b/apps/sim/components/settings/settings-sidebar.tsx
@@ -0,0 +1,171 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { ChipConfirmModal, chipVariants, cn, Tooltip } from '@sim/emcn'
+import { ChevronDown } from '@sim/emcn/icons'
+import { useRouter } from 'next/navigation'
+import type { SettingsNavigationItem, SettingsSection } from '@/components/settings/navigation'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+interface SettingsNavigationGroup {
+ key: string
+ title: string
+}
+
+interface SidebarSettingsItem
+ extends SettingsNavigationItem {
+ locked?: boolean
+}
+
+interface SettingsSidebarProps {
+ activeSection: string
+ backHref: string
+ groups: readonly SettingsNavigationGroup[]
+ hrefForSection: (section: Section) => string
+ items: readonly SidebarSettingsItem[]
+ isCollapsed?: boolean
+ showCollapsedTooltips?: boolean
+}
+
+function SidebarTooltip({
+ children,
+ label,
+ enabled,
+}: {
+ children: React.ReactElement
+ label: string
+ enabled: boolean
+}) {
+ if (!enabled) return children
+ return (
+
+ {children}
+ {label}
+
+ )
+}
+
+export function SettingsSidebar({
+ activeSection,
+ backHref,
+ groups,
+ hrefForSection,
+ items,
+ isCollapsed = false,
+ showCollapsedTooltips = false,
+}: SettingsSidebarProps) {
+ const scrollContainerRef = useRef(null)
+ const scrollContentRef = useRef(null)
+ const router = useRouter()
+
+ const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
+ const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave)
+ const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave)
+ const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave)
+ const [hasOverflowTop, setHasOverflowTop] = useState(false)
+
+ useEffect(() => {
+ const container = scrollContainerRef.current
+ if (!container) return
+ const updateScrollState = () => setHasOverflowTop(container.scrollTop > 1)
+ updateScrollState()
+ container.addEventListener('scroll', updateScrollState, { passive: true })
+ const observer = new ResizeObserver(updateScrollState)
+ observer.observe(container)
+ if (scrollContentRef.current) observer.observe(scrollContentRef.current)
+ return () => {
+ container.removeEventListener('scroll', updateScrollState)
+ observer.disconnect()
+ }
+ }, [isCollapsed])
+
+ return (
+ <>
+
+
+ requestLeave(() => router.push(backHref))}
+ className={chipVariants({ fullWidth: true })}
+ >
+
+
+
+ Back
+
+
+
+
+
+
+ {groups
+ .map((group) => ({
+ ...group,
+ items: items.filter((item) => item.group === group.key),
+ }))
+ .filter((group) => group.items.length > 0)
+ .map((group, index) => (
+
0 && 'mt-6', 'flex flex-shrink-0 flex-col')}
+ >
+
+
+ {group.items.map((item) => {
+ const Icon = item.icon
+ const active = activeSection === item.id
+ return (
+
+ {
+ if (active) return
+ requestLeave(() => {
+ router.replace(hrefForSection(item.id), { scroll: false })
+ })
+ }}
+ >
+
+
+ {item.label}
+
+ {item.locked && (
+
+ Plan
+
+ )}
+
+
+ )
+ })}
+
+
+ ))}
+
+
+
+ !open && cancelLeave()}
+ srTitle='Unsaved changes'
+ title='Unsaved changes'
+ text='You have unsaved changes. Are you sure you want to discard them?'
+ dismissLabel='Keep editing'
+ confirm={{ label: 'Discard changes', onClick: confirmLeave }}
+ />
+ >
+ )
+}
diff --git a/apps/sim/components/settings/settings-unavailable.tsx b/apps/sim/components/settings/settings-unavailable.tsx
new file mode 100644
index 00000000000..bdf939aff39
--- /dev/null
+++ b/apps/sim/components/settings/settings-unavailable.tsx
@@ -0,0 +1,28 @@
+import { ChipLink, cn } from '@sim/emcn'
+
+interface SettingsUnavailableProps {
+ title?: string
+ description?: string
+ embedded?: boolean
+}
+
+export function SettingsUnavailable({
+ title = 'Settings unavailable',
+ description = 'You do not have access to manage this organization. Contact an organization owner or admin for help.',
+ embedded = false,
+}: SettingsUnavailableProps) {
+ return (
+
+
+
{title}
+
{description}
+
Back to workspaces
+
+
+ )
+}
diff --git a/apps/sim/components/settings/standalone-settings-shell.test.ts b/apps/sim/components/settings/standalone-settings-shell.test.ts
new file mode 100644
index 00000000000..6927d523def
--- /dev/null
+++ b/apps/sim/components/settings/standalone-settings-shell.test.ts
@@ -0,0 +1,35 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ ACCOUNT_SETTINGS_ITEMS,
+ ACCOUNT_SETTINGS_PATH_ALIASES,
+ ORGANIZATION_SETTINGS_ITEMS,
+ ORGANIZATION_SETTINGS_PATH_ALIASES,
+ parseSettingsPathSection,
+} from '@/components/settings/navigation'
+
+describe('standalone settings section resolution', () => {
+ it('keeps Billing active for the static account credit-usage route', () => {
+ expect(
+ parseSettingsPathSection({
+ path: '/account/settings/billing/credit-usage',
+ items: ACCOUNT_SETTINGS_ITEMS,
+ defaultSection: 'general',
+ aliases: ACCOUNT_SETTINGS_PATH_ALIASES,
+ })
+ ).toBe('billing')
+ })
+
+ it('resolves the organization section from its pathname', () => {
+ expect(
+ parseSettingsPathSection({
+ path: '/organization/org-1/settings/audit-logs',
+ items: ORGANIZATION_SETTINGS_ITEMS,
+ defaultSection: 'members',
+ aliases: ORGANIZATION_SETTINGS_PATH_ALIASES,
+ })
+ ).toBe('audit-logs')
+ })
+})
diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx
new file mode 100644
index 00000000000..70eead4cb3e
--- /dev/null
+++ b/apps/sim/components/settings/standalone-settings-shell.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { ToastProvider } from '@sim/emcn'
+import { usePathname } from 'next/navigation'
+import {
+ ACCOUNT_SETTINGS_GROUPS,
+ ACCOUNT_SETTINGS_ITEMS,
+ ACCOUNT_SETTINGS_PATH_ALIASES,
+ getAccountSettingsHref,
+ getOrganizationSettingsFeatures,
+ getOrganizationSettingsHref,
+ isOrganizationSettingsSectionAvailable,
+ ORGANIZATION_SETTINGS_GROUPS,
+ ORGANIZATION_SETTINGS_ITEMS,
+ ORGANIZATION_SETTINGS_PATH_ALIASES,
+ parseSettingsPathSection,
+ resolveOrganizationSectionAccess,
+} from '@/components/settings/navigation'
+import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
+import { SettingsSectionProvider } from '@/components/settings/settings-panel'
+import { SettingsSidebar } from '@/components/settings/settings-sidebar'
+import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+
+interface StandaloneSettingsShellBaseProps {
+ children: ReactNode
+}
+
+interface AccountSettingsShellProps extends StandaloneSettingsShellBaseProps {
+ plane: 'account'
+ isSuperUser?: boolean
+}
+
+interface OrganizationSettingsShellProps extends StandaloneSettingsShellBaseProps {
+ plane: 'organization'
+ organizationId: string
+ hasEnterprisePlan: boolean
+ isOrganizationAdmin: boolean
+}
+
+type StandaloneSettingsShellProps = AccountSettingsShellProps | OrganizationSettingsShellProps
+
+export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
+ const { children, plane } = props
+ useSettingsBeforeUnload()
+ const pathname = usePathname()
+ const hasEnterprisePlan = plane === 'organization' ? props.hasEnterprisePlan : false
+ const isOrganizationAdmin = plane === 'organization' ? props.isOrganizationAdmin : false
+ const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false
+
+ const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan)
+ const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => {
+ if (item.id === 'billing' && !isBillingEnabled) return false
+ if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false
+ return true
+ })
+ const organizationItems = ORGANIZATION_SETTINGS_ITEMS.filter(
+ (item) =>
+ resolveOrganizationSectionAccess({
+ section: item.id,
+ isTargetOrganizationMember: true,
+ isTargetOrganizationAdmin: isOrganizationAdmin,
+ }) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, organizationFeatures)
+ )
+ const accountSection = parseSettingsPathSection({
+ path: pathname,
+ items: ACCOUNT_SETTINGS_ITEMS,
+ defaultSection: 'general',
+ aliases: ACCOUNT_SETTINGS_PATH_ALIASES,
+ })
+ const organizationSection = parseSettingsPathSection({
+ path: pathname,
+ items: ORGANIZATION_SETTINGS_ITEMS,
+ defaultSection: 'members',
+ aliases: ORGANIZATION_SETTINGS_PATH_ALIASES,
+ })
+ const activeSection = plane === 'account' ? accountSection : organizationSection
+ const sidebar =
+ plane === 'account' ? (
+
+ ) : (
+ getOrganizationSettingsHref(props.organizationId, section)}
+ items={organizationItems}
+ />
+ )
+
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/components/settings/use-settings-before-unload.ts b/apps/sim/components/settings/use-settings-before-unload.ts
new file mode 100644
index 00000000000..8de59314fe7
--- /dev/null
+++ b/apps/sim/components/settings/use-settings-before-unload.ts
@@ -0,0 +1,18 @@
+import { useEffect } from 'react'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+/**
+ * Registers the settings-wide browser unload guard while a section is dirty.
+ */
+export function useSettingsBeforeUnload() {
+ const isDirty = useSettingsDirtyStore((state) => state.isDirty)
+
+ useEffect(() => {
+ if (!isDirty) return
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault()
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [isDirty])
+}
diff --git a/apps/sim/components/settings/use-settings-unsaved-guard.ts b/apps/sim/components/settings/use-settings-unsaved-guard.ts
new file mode 100644
index 00000000000..0fbf43786d4
--- /dev/null
+++ b/apps/sim/components/settings/use-settings-unsaved-guard.ts
@@ -0,0 +1,56 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
+
+interface UseSettingsUnsavedGuardParams {
+ isDirty: boolean
+}
+
+interface SettingsUnsavedGuard {
+ showUnsavedModal: boolean
+ setShowUnsavedModal: (open: boolean) => void
+ guardBack: (onLeave: () => void) => void
+ confirmDiscard: () => void
+}
+
+/**
+ * Connects section-local dirty state to shared settings navigation guards.
+ */
+export function useSettingsUnsavedGuard({
+ isDirty,
+}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
+ const setDirty = useSettingsDirtyStore((state) => state.setDirty)
+ const reset = useSettingsDirtyStore((state) => state.reset)
+ const isDirtyRef = useRef(isDirty)
+ const pendingLeaveRef = useRef<(() => void) | null>(null)
+ const [showUnsavedModal, setShowUnsavedModal] = useState(false)
+
+ useEffect(() => {
+ isDirtyRef.current = isDirty
+ setDirty(isDirty)
+ if (!isDirty) {
+ pendingLeaveRef.current = null
+ setShowUnsavedModal(false)
+ }
+ }, [isDirty, setDirty])
+
+ useEffect(() => {
+ return () => reset()
+ }, [reset])
+
+ const guardBack = useCallback((onLeave: () => void) => {
+ if (isDirtyRef.current) {
+ pendingLeaveRef.current = onLeave
+ setShowUnsavedModal(true)
+ return
+ }
+ onLeave()
+ }, [])
+
+ const confirmDiscard = useCallback(() => {
+ setShowUnsavedModal(false)
+ pendingLeaveRef.current?.()
+ pendingLeaveRef.current = null
+ }, [])
+
+ return { showUnsavedModal, setShowUnsavedModal, guardBack, confirmDiscard }
+}
diff --git a/apps/sim/components/settings/workspace-settings-renderer.tsx b/apps/sim/components/settings/workspace-settings-renderer.tsx
new file mode 100644
index 00000000000..933a436610e
--- /dev/null
+++ b/apps/sim/components/settings/workspace-settings-renderer.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import { useEffect } from 'react'
+import dynamic from 'next/dynamic'
+import { usePostHog } from 'posthog-js/react'
+import type { WorkspaceSettingsSection } from '@/components/settings/navigation'
+import { SettingsSectionProvider } from '@/components/settings/settings-panel'
+import { captureEvent } from '@/lib/posthog/client'
+
+const Teammates = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then(
+ (module) => module.Teammates
+ )
+)
+const Secrets = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then(
+ (module) => module.Secrets
+ )
+)
+const BYOK = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then(
+ (module) => module.BYOK
+ )
+)
+const CustomTools = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then(
+ (module) => module.CustomTools
+ )
+)
+const MCP = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((module) => module.MCP)
+)
+const WorkflowMcpServers = dynamic(() =>
+ import(
+ '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers'
+ ).then((module) => module.WorkflowMcpServers)
+)
+const ApiKeys = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
+ (module) => module.ApiKeys
+ )
+)
+const Inbox = dynamic(() =>
+ import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then(
+ (module) => module.Inbox
+ )
+)
+const RecentlyDeleted = dynamic(() =>
+ import(
+ '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
+ ).then((module) => module.RecentlyDeleted)
+)
+const CustomBlocks = dynamic(() =>
+ import('@/ee/custom-blocks/components/custom-blocks').then((module) => module.CustomBlocks)
+)
+const Forks = dynamic(() =>
+ import('@/ee/workspace-forking/components/forks').then((module) => module.Forks)
+)
+
+interface WorkspaceSettingsRendererProps {
+ section: WorkspaceSettingsSection
+}
+
+export function WorkspaceSettingsRenderer({ section }: WorkspaceSettingsRendererProps) {
+ const posthog = usePostHog()
+
+ useEffect(() => {
+ captureEvent(posthog, 'settings_tab_viewed', { plane: 'workspace', section })
+ }, [posthog, section])
+
+ let content
+ if (section === 'teammates') content =
+ else if (section === 'secrets') content =
+ else if (section === 'byok') content =
+ else if (section === 'custom-tools') content =
+ else if (section === 'mcp') content =
+ else if (section === 'workflow-mcp-servers') content =
+ else if (section === 'api-keys') content =
+ else if (section === 'inbox') content =
+ else if (section === 'recently-deleted') content =
+ else if (section === 'custom-blocks') content =
+ else content =
+
+ return (
+
+ {content}
+
+ )
+}
diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
index bdaff217f63..11c3341d9cc 100644
--- a/apps/sim/ee/access-control/components/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { isEnterprise } from '@/lib/billing/plan-helpers'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -28,10 +29,16 @@ import {
usePermissionGroups,
useUserPermissionConfig,
} from '@/ee/access-control/hooks/permission-groups'
+import { useOrganizationBilling } from '@/hooks/queries/organization'
const logger = createLogger('AccessControl')
-export function AccessControl() {
+interface AccessControlProps {
+ isOrganizationAdmin: boolean
+ organizationId: string
+}
+
+export function AccessControl({ isOrganizationAdmin, organizationId }: AccessControlProps) {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
@@ -43,8 +50,9 @@ export function AccessControl() {
*/
const { data: userPermissionConfig, isPending: entitlementLoading } =
useUserPermissionConfig(workspaceId)
- const organizationId = userPermissionConfig?.organizationId ?? undefined
- const currentUserIsOrgAdmin = userPermissionConfig?.isOrgAdmin ?? false
+ const { data: organizationBillingData, isPending: organizationBillingLoading } =
+ useOrganizationBilling(organizationId)
+ const currentUserIsOrgAdmin = isOrganizationAdmin
const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups(
organizationId,
@@ -54,12 +62,14 @@ export function AccessControl() {
useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
- const isEntitled = accessControlEnabledLocally || !!userPermissionConfig?.entitled
+ const isEntitled =
+ accessControlEnabledLocally ||
+ !!userPermissionConfig?.entitled ||
+ isEnterprise(organizationBillingData?.data?.subscriptionPlan)
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
const isLoading =
- !workspaceId ||
- entitlementLoading ||
+ (workspaceId ? entitlementLoading : organizationBillingLoading) ||
(!!organizationId && currentUserIsOrgAdmin && groupsLoading)
const createPermissionGroup = useCreatePermissionGroup()
diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx
index 4ffe09705b5..12c5a218284 100644
--- a/apps/sim/ee/audit-logs/components/audit-logs.tsx
+++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx
@@ -223,7 +223,11 @@ function toActivityEntry(entry: EnterpriseAuditLogEntry): ActivityLogEntry {
}
}
-export function AuditLogs() {
+interface AuditLogsProps {
+ organizationId: string
+}
+
+export function AuditLogs({ organizationId }: AuditLogsProps) {
const [selectedTypes, setSelectedTypes] = useState([])
const [timeRange, setTimeRange] = useState('Past 30 days')
const [customStartDate, setCustomStartDate] = useState('')
@@ -273,7 +277,7 @@ export function AuditLogs() {
hasNextPage,
fetchNextPage,
refetch,
- } = useAuditLogs(filters)
+ } = useAuditLogs(organizationId, filters)
const allEntries = useMemo(() => {
if (!data?.pages) return []
@@ -342,6 +346,7 @@ export function AuditLogs() {
setIsExporting(true)
try {
const params = new URLSearchParams()
+ params.set('organizationId', organizationId)
if (filters.search) params.set('search', filters.search)
if (filters.resourceType) params.set('resourceType', filters.resourceType)
if (filters.startDate) params.set('startDate', filters.startDate)
diff --git a/apps/sim/ee/audit-logs/hooks/audit-logs.test.tsx b/apps/sim/ee/audit-logs/hooks/audit-logs.test.tsx
new file mode 100644
index 00000000000..9fd71da3ada
--- /dev/null
+++ b/apps/sim/ee/audit-logs/hooks/audit-logs.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { sleep } from '@sim/utils/helpers'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
+import { useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs'
+
+function createDeferred() {
+ let resolvePromise: (value: T) => void = () => undefined
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ return { promise, resolve: resolvePromise }
+}
+
+const AUDIT_PAGE_A: AuditLogPage = {
+ success: true,
+ data: [
+ {
+ id: 'audit-a',
+ workspaceId: null,
+ actorId: 'user-a',
+ actorName: 'Actor A',
+ actorEmail: 'actor-a@example.com',
+ action: 'organization.updated',
+ resourceType: 'organization',
+ resourceId: 'org-a',
+ resourceName: 'Organization A',
+ description: 'Updated Organization A',
+ metadata: null,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ },
+ ],
+}
+
+let container: HTMLDivElement
+let root: Root
+let queryClient: QueryClient
+
+function AuditProbe({ organizationId }: { organizationId: string }) {
+ const auditLogs = useAuditLogs(organizationId, {})
+ const entries = auditLogs.data?.pages.flatMap((page) => page.data) ?? []
+
+ return (
+
+ {entries[0]?.description ?? ''}
+ {entries.length > 0 && Export audit logs }
+
+ )
+}
+
+function renderAuditLogs(organizationId: string) {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+}
+
+async function flushQueries() {
+ await act(async () => {
+ for (let index = 0; index < 5; index++) {
+ await Promise.resolve()
+ await sleep(0)
+ }
+ })
+}
+
+describe('useAuditLogs identity transitions', () => {
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ })
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ queryClient.clear()
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('clears org A audit entries and export actions while org B loads', async () => {
+ const auditPageB = createDeferred()
+ mockRequestJson.mockImplementation(
+ (contract: unknown, input: { query?: { organizationId?: string } }) => {
+ if (contract !== listAuditLogsContract) throw new Error('Unexpected contract')
+ return input.query?.organizationId === 'org-a'
+ ? Promise.resolve(AUDIT_PAGE_A)
+ : auditPageB.promise
+ }
+ )
+
+ renderAuditLogs('org-a')
+ await flushQueries()
+
+ expect(container).toHaveTextContent('Updated Organization A')
+ expect(container.querySelector('button')).toHaveTextContent('Export audit logs')
+
+ renderAuditLogs('org-b')
+ await flushQueries()
+
+ expect(container).not.toHaveTextContent('Updated Organization A')
+ expect(container.querySelector('button')).toBeNull()
+ expect(mockRequestJson).toHaveBeenCalledWith(
+ listAuditLogsContract,
+ expect.objectContaining({
+ query: expect.objectContaining({ organizationId: 'org-b' }),
+ })
+ )
+ })
+})
diff --git a/apps/sim/ee/audit-logs/hooks/audit-logs.ts b/apps/sim/ee/audit-logs/hooks/audit-logs.ts
index 7adbf1319fb..04f5c08bdb6 100644
--- a/apps/sim/ee/audit-logs/hooks/audit-logs.ts
+++ b/apps/sim/ee/audit-logs/hooks/audit-logs.ts
@@ -1,11 +1,12 @@
-import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
+import { useInfiniteQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
export const auditLogKeys = {
all: ['audit-logs'] as const,
lists: () => [...auditLogKeys.all, 'list'] as const,
- list: (filters: AuditLogFilters) => [...auditLogKeys.lists(), filters] as const,
+ list: (organizationId: string, filters: AuditLogFilters) =>
+ [...auditLogKeys.lists(), organizationId, filters] as const,
}
export interface AuditLogFilters {
@@ -18,12 +19,14 @@ export interface AuditLogFilters {
}
async function fetchAuditLogs(
+ organizationId: string,
filters: AuditLogFilters,
cursor?: string,
signal?: AbortSignal
): Promise {
return requestJson(listAuditLogsContract, {
query: {
+ organizationId,
limit: '50',
search: filters.search,
action: filters.action,
@@ -37,14 +40,13 @@ async function fetchAuditLogs(
})
}
-export function useAuditLogs(filters: AuditLogFilters, enabled = true) {
+export function useAuditLogs(organizationId: string, filters: AuditLogFilters, enabled = true) {
return useInfiniteQuery({
- queryKey: auditLogKeys.list(filters),
- queryFn: ({ pageParam, signal }) => fetchAuditLogs(filters, pageParam, signal),
+ queryKey: auditLogKeys.list(organizationId, filters),
+ queryFn: ({ pageParam, signal }) => fetchAuditLogs(organizationId, filters, pageParam, signal),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
- enabled,
+ enabled: Boolean(organizationId) && enabled,
staleTime: 30 * 1000,
- placeholderData: keepPreviousData,
})
}
diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
index f7534c9bf3f..3785ccbee83 100644
--- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
@@ -4,26 +4,29 @@ import { useMemo, useState } from 'react'
import { ChipTag } from '@sim/emcn'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
import { CustomBlockDetail } from '@/ee/custom-blocks/components/custom-block-detail'
-import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
+import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
import { useCanPublishCustomBlock, useCustomBlocks } from '@/hooks/queries/custom-blocks'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
export function CustomBlocks() {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
+ const workspacePermissions = useUserPermissionsContext()
+ const canAdmin = canMutateWorkspaceSettingsSection('custom-blocks', workspacePermissions)
const { data: canManage = false, isLoading } = useCanPublishCustomBlock(workspaceId)
const { data: blocks = [] } = useCustomBlocks(workspaceId)
const { data: workspaces = [] } = useWorkspacesQuery()
- // Any org member can view the org's blocks, but publishing requires admin on a
- // source workspace — the publish route enforces admin on the picked workspace.
+ /** Publishing requires admin on a source workspace; viewing remains workspace-scoped. */
const currentOrgId = useMemo(
() => workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null,
[workspaces, workspaceId]
@@ -31,11 +34,11 @@ export function CustomBlocks() {
const canCreate = useMemo(
() =>
!!currentOrgId &&
+ canAdmin &&
workspaces.some((w) => w.organizationId === currentOrgId && w.permissions === 'admin'),
- [workspaces, currentOrgId]
+ [canAdmin, workspaces, currentOrgId]
)
- const { data: whitelabel } = useWhitelabelSettings(blocks[0]?.organizationId)
- const fallbackIconUrl = whitelabel?.logoUrl ?? null
+ const fallbackIconUrl = useOrgBrandConfig().logoUrl ?? null
const [searchTerm, setSearchTerm] = useState('')
const [selected, setSelected] = useState(null)
@@ -106,8 +109,9 @@ export function CustomBlocks() {
setSelected(cb.id)}
+ onClick={() => canAdmin && setSelected(cb.id)}
className='w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
+ disabled={!canAdmin}
>
}
@@ -117,7 +121,7 @@ export function CustomBlocks() {
trailing={
{!cb.enabled &&
Disabled }
-
+ {canAdmin &&
}
}
/>
diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx
index 82593a5d910..e42031aec9f 100644
--- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx
+++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx
@@ -22,13 +22,10 @@ import {
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { toError } from '@sim/utils/errors'
import { ChevronDown, Plus } from 'lucide-react'
import type { CreateDataDrainBody, DataDrain, DataDrainRun } from '@/lib/api/contracts/data-drains'
-import { useSession } from '@/lib/auth/auth-client'
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
-import { getUserRole } from '@/lib/workspaces/organization/utils'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -42,7 +39,6 @@ import {
useTestDataDrain,
useUpdateDataDrain,
} from '@/ee/data-drains/hooks/data-drains'
-import { useOrganizations } from '@/hooks/queries/organization'
const logger = createLogger('DataDrainsSettings')
@@ -77,17 +73,16 @@ const DESTINATION_OPTIONS = DESTINATION_TYPES.map((t) => ({
label: DESTINATION_LABELS[t],
}))
-export function DataDrainsSettings() {
- const { data: session, isPending: sessionPending } = useSession()
- const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
- const activeOrganization = orgsData?.activeOrganization
- const orgId = activeOrganization?.id
-
- const userEmail = session?.user?.email
- const userRole = getUserRole(activeOrganization, userEmail)
- const canManage = isOrgAdminRole(userRole)
+interface DataDrainsSettingsProps {
+ organizationId: string
+}
- const { data: drains, isLoading: drainsLoading, error: drainsError } = useDataDrains(orgId)
+export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) {
+ const {
+ data: drains,
+ isLoading: drainsLoading,
+ error: drainsError,
+ } = useDataDrains(organizationId)
const [createOpen, setCreateOpen] = useState(false)
const [expandedDrainId, setExpandedDrainId] = useState(null)
@@ -105,25 +100,7 @@ export function DataDrainsSettings() {
].some((value) => value.toLowerCase().includes(query))
)
- if (sessionPending || orgsLoading || drainsLoading) {
- return null
- }
-
- if (!orgId) {
- return (
-
- Data drains are configured per organization. Join or create one to continue.
-
- )
- }
-
- if (!canManage) {
- return (
-
- Only organization owners and admins can configure data drains.
-
- )
- }
+ if (drainsLoading) return null
return (
<>
@@ -169,7 +146,7 @@ export function DataDrainsSettings() {
setExpandedDrainId(expandedDrainId === drain.id ? null : drain.id)
@@ -191,7 +168,7 @@ export function DataDrainsSettings() {
{createOpen && (
- setCreateOpen(false)} />
+ setCreateOpen(false)} />
)}
>
)
diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
index 22257645f59..d115bfc33e0 100644
--- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx
+++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
@@ -16,13 +16,11 @@ import {
} from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
-import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { ArrowRight, Plus } from 'lucide-react'
import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization'
import type { RetentionOverride } from '@/lib/api/contracts/primitives'
-import { useSession } from '@/lib/auth/auth-client'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import {
emptyPiiStages,
@@ -38,7 +36,6 @@ import {
type PiiStagePolicy,
type PiiStages,
} from '@/lib/guardrails/pii-entities'
-import { getUserRole } from '@/lib/workspaces/organization/utils'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
@@ -50,7 +47,6 @@ import {
useOrganizationRetention,
useUpdateOrganizationRetention,
} from '@/ee/data-retention/hooks/data-retention'
-import { useOrganizations } from '@/hooks/queries/organization'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
const logger = createLogger('DataRetentionSettings')
@@ -616,13 +612,11 @@ function PolicyDetail({
)
}
-export function DataRetentionSettings() {
- const { data: session, isPending: sessionPending } = useSession()
- const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
-
- const activeOrganization = orgsData?.activeOrganization
- const orgId = activeOrganization?.id
+interface DataRetentionSettingsProps {
+ organizationId: string
+}
+export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSettingsProps) {
const { data, isLoading: retentionLoading } = useOrganizationRetention(orgId)
const updateMutation = useUpdateOrganizationRetention()
const { data: workspaces } = useWorkspacesQuery(Boolean(orgId))
@@ -632,9 +626,6 @@ export function DataRetentionSettings() {
const workspaceName = (id: string) =>
workspaceOptions.find((w) => w.value === id)?.label ?? 'Unknown workspace'
- const userEmail = session?.user?.email
- const userRole = getUserRole(activeOrganization, userEmail)
- const canManage = isOrgAdminRole(userRole)
const piiEnabled = Boolean(data?.piiRedactionEnabled)
const piiGranularEnabled = Boolean(data?.piiGranularRedactionEnabled)
@@ -891,17 +882,7 @@ export function DataRetentionSettings() {
}
}
- if (sessionPending || orgsLoading || (orgId && retentionLoading)) {
- return null
- }
-
- if (!orgId) {
- return (
-
- Data retention is configured per organization. Join or create an organization to continue.
-
- )
- }
+ if (retentionLoading) return null
if (!data) {
return Failed to load data retention settings.
@@ -913,14 +894,6 @@ export function DataRetentionSettings() {
)
}
- if (!canManage) {
- return (
-
- Only organization owners and admins can configure data retention settings.
-
- )
- }
-
return (
<>
{editing ? (
diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx
new file mode 100644
index 00000000000..d9f4217280d
--- /dev/null
+++ b/apps/sim/ee/sso/components/sso-settings.test.tsx
@@ -0,0 +1,177 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ChangeEventHandler, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockUseConfigureSSO, mockUseOrganizationBilling, mockUseSession, mockUseSSOProviders } =
+ vi.hoisted(() => ({
+ mockUseConfigureSSO: vi.fn(),
+ mockUseOrganizationBilling: vi.fn(),
+ mockUseSession: vi.fn(),
+ mockUseSSOProviders: vi.fn(),
+ }))
+
+vi.mock('@sim/emcn', () => ({
+ Button: ({ children, ...props }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ ChipCombobox: () =>
,
+ ChipInput: ({
+ value,
+ onChange,
+ }: {
+ value?: string
+ onChange?: ChangeEventHandler
+ }) => ,
+ ChipSelect: () =>
,
+ ChipTextarea: ({
+ value,
+ onChange,
+ }: {
+ value?: string
+ onChange?: ChangeEventHandler
+ }) => ,
+ Expandable: ({ children }: { children?: ReactNode }) => {children}
,
+ ExpandableContent: ({ children }: { children?: ReactNode }) => {children}
,
+ Label: ({ children }: { children?: ReactNode }) => {children} ,
+ Switch: () => Switch ,
+ cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
+ toast: {
+ error: vi.fn(),
+ success: vi.fn(),
+ },
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: mockUseSession,
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ isBillingEnabled: true,
+}))
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions',
+ () => ({
+ saveDiscardActions: () => [],
+ })
+)
+
+vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
+ SettingsEmptyState: ({ children }: { children?: ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
+ SettingsPanel: ({
+ actions = [],
+ children,
+ }: {
+ actions?: Array<{ text: string; onSelect?: () => void; disabled?: boolean }>
+ children?: ReactNode
+ }) => (
+
+ {actions.map((action) => (
+
+ {action.text}
+
+ ))}
+ {children}
+
+ ),
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard', () => ({
+ useSettingsUnsavedGuard: vi.fn(),
+}))
+
+vi.mock('@/ee/sso/hooks/sso', () => ({
+ useConfigureSSO: mockUseConfigureSSO,
+ useSSOProviders: mockUseSSOProviders,
+}))
+
+vi.mock('@/hooks/queries/organization', () => ({
+ useOrganizationBilling: mockUseOrganizationBilling,
+}))
+
+import { SSO } from '@/ee/sso/components/sso-settings'
+
+function provider(organizationId: string) {
+ const suffix = organizationId === 'org-a' ? 'a' : 'b'
+ return {
+ id: `sso-${suffix}`,
+ providerId: `provider-${suffix}`,
+ domain: `org-${suffix}.example.com`,
+ issuer: `https://issuer-${suffix}.example.com`,
+ organizationId,
+ providerType: 'oidc',
+ oidcConfig: JSON.stringify({
+ clientId: `client-${suffix}`,
+ clientSecret: `secret-${suffix}`,
+ scopes: ['openid'],
+ }),
+ }
+}
+
+let container: HTMLDivElement
+let root: Root
+
+function renderSso(organizationId: string) {
+ act(() => {
+ root.render( )
+ })
+}
+
+describe('SSO organization transitions', () => {
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockUseSession.mockReturnValue({ data: { user: { id: 'user-1' } } })
+ mockUseOrganizationBilling.mockReturnValue({
+ data: { data: { subscriptionPlan: 'enterprise' } },
+ isLoading: false,
+ })
+ mockUseConfigureSSO.mockReturnValue({
+ isPending: false,
+ mutateAsync: vi.fn(),
+ })
+ mockUseSSOProviders.mockImplementation(({ organizationId }: { organizationId: string }) => ({
+ data: { providers: [provider(organizationId)] },
+ isLoading: false,
+ }))
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('discards org A edit state before rendering org B settings', () => {
+ renderSso('org-a')
+ expect(container).toHaveTextContent('org-a.example.com')
+
+ const editButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Edit'
+ )
+ expect(editButton).toBeDefined()
+ act(() => editButton?.click())
+ expect(container.querySelector('input[value="client-a"]')).not.toBeNull()
+
+ renderSso('org-b')
+
+ expect(container).toHaveTextContent('org-b.example.com')
+ expect(container).not.toHaveTextContent('org-a.example.com')
+ expect(container.querySelector('input[value="client-a"]')).toBeNull()
+ })
+})
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index 4a6f8e4a4c4..df9e1a586f8 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -19,10 +19,9 @@ import { getErrorMessage } from '@sim/utils/errors'
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
import type { SsoRegistrationBody } from '@/lib/api/contracts/auth'
import { useSession } from '@/lib/auth/auth-client'
-import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
+import { isEnterprise } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
-import { getUserRole } from '@/lib/workspaces/organization/utils'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
@@ -30,8 +29,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
-import { useOrganizations } from '@/hooks/queries/organization'
-import { useSubscriptionData } from '@/hooks/queries/subscription'
+import { useOrganizationBilling } from '@/hooks/queries/organization'
const logger = createLogger('SSO')
@@ -101,28 +99,28 @@ const DEFAULT_ERRORS = {
audience: [],
}
-export function SSO() {
- const { data: session } = useSession()
- const { data: orgsData } = useOrganizations()
- const { data: subscriptionData } = useSubscriptionData()
+interface SSOProps {
+ organizationId: string
+}
+
+export function SSO({ organizationId }: SSOProps) {
+ return
+}
- const activeOrganization = orgsData?.activeOrganization
+function OrganizationSsoSettings({ organizationId }: SSOProps) {
+ const { data: session } = useSession()
+ const { data: organizationBillingData, isLoading: isLoadingOrganizationBilling } =
+ useOrganizationBilling(organizationId)
const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders({
- organizationId: activeOrganization?.id,
+ organizationId,
})
const providers = providersData?.providers || []
const existingProvider = providers[0] as SSOProvider | undefined
- const userEmail = session?.user?.email
const userId = session?.user?.id
- const userRole = getUserRole(orgsData?.activeOrganization, userEmail)
- const isOwner = userRole === 'owner'
- const isAdmin = userRole === 'admin'
- const canManageSSO = isOwner || isAdmin
- const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
- const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
+ const hasEnterprisePlan = isEnterprise(organizationBillingData?.data?.subscriptionPlan)
const isSSOProviderOwner =
!isBillingEnabled && userId ? providers.some((p) => p.userId === userId) : null
@@ -145,15 +143,11 @@ export function SSO() {
useSettingsUnsavedGuard({ isDirty: hasChanges })
- if (isBillingEnabled) {
- if (!activeOrganization) {
- return (
-
- You must be part of an organization to configure Single Sign-On.
-
- )
- }
+ if (isLoadingProviders || (isBillingEnabled && isLoadingOrganizationBilling)) {
+ return null
+ }
+ if (isBillingEnabled) {
if (!hasEnterprisePlan) {
return (
@@ -161,28 +155,8 @@ export function SSO() {
)
}
-
- if (!canManageSSO) {
- return (
-
- Only organization owners and admins can configure Single Sign-On settings.
-
- )
- }
} else {
- if (activeOrganization && !canManageSSO) {
- return (
-
- Only organization owners and admins can configure Single Sign-On settings.
-
- )
- }
- if (
- !activeOrganization &&
- !isLoadingProviders &&
- isSSOProviderOwner === false &&
- providers.length > 0
- ) {
+ if (!isLoadingProviders && isSSOProviderOwner === false && providers.length > 0) {
return (
Only the user who configured SSO can manage these settings.
@@ -317,7 +291,7 @@ export function SSO() {
providerId: formData.providerId,
issuer: formData.issuerUrl,
domain: formData.domain,
- orgId: activeOrganization?.id,
+ orgId: organizationId,
mapping: {
id: 'sub',
email: 'email',
@@ -333,7 +307,7 @@ export function SSO() {
providerId: formData.providerId,
issuer: formData.issuerUrl,
domain: formData.domain,
- orgId: activeOrganization?.id,
+ orgId: organizationId,
mapping: {
id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
@@ -441,10 +415,6 @@ export function SSO() {
}
}
- if (isLoadingProviders) {
- return null
- }
-
if (existingProvider && !isEditing) {
const providerCallbackUrl = `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}`
diff --git a/apps/sim/ee/sso/hooks/sso.test.tsx b/apps/sim/ee/sso/hooks/sso.test.tsx
new file mode 100644
index 00000000000..c610993b8b5
--- /dev/null
+++ b/apps/sim/ee/sso/hooks/sso.test.tsx
@@ -0,0 +1,122 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { sleep } from '@sim/utils/helpers'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
+import { useSSOProviders } from '@/ee/sso/hooks/sso'
+
+interface SsoProvidersResponse {
+ providers: Array<{
+ providerId: string
+ domain: string
+ }>
+}
+
+function createDeferred() {
+ let resolvePromise: (value: T) => void = () => undefined
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ return { promise, resolve: resolvePromise }
+}
+
+const PROVIDERS_A: SsoProvidersResponse = {
+ providers: [{ providerId: 'provider-a', domain: 'org-a.example.com' }],
+}
+
+let container: HTMLDivElement
+let root: Root
+let queryClient: QueryClient
+
+function SsoProbe({ organizationId }: { organizationId: string }) {
+ const providers = useSSOProviders({ organizationId })
+ const provider = providers.data?.providers[0]
+
+ return (
+
+ {provider?.domain ?? ''}
+ {provider && Edit SSO }
+
+ )
+}
+
+function renderSso(organizationId: string) {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+}
+
+async function flushQueries() {
+ await act(async () => {
+ for (let index = 0; index < 5; index++) {
+ await Promise.resolve()
+ await sleep(0)
+ }
+ })
+}
+
+describe('useSSOProviders identity transitions', () => {
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ })
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ queryClient.clear()
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('clears org A provider data and edit actions while org B loads', async () => {
+ const providersB = createDeferred()
+ mockRequestJson.mockImplementation(
+ (contract: unknown, input: { query?: { organizationId?: string } }) => {
+ if (contract !== listSsoProvidersContract) throw new Error('Unexpected contract')
+ return input.query?.organizationId === 'org-a'
+ ? Promise.resolve(PROVIDERS_A)
+ : providersB.promise
+ }
+ )
+
+ renderSso('org-a')
+ await flushQueries()
+
+ expect(container).toHaveTextContent('org-a.example.com')
+ expect(container.querySelector('button')).toHaveTextContent('Edit SSO')
+
+ renderSso('org-b')
+ await flushQueries()
+
+ expect(container).not.toHaveTextContent('org-a.example.com')
+ expect(container.querySelector('button')).toBeNull()
+ expect(mockRequestJson).toHaveBeenCalledWith(
+ listSsoProvidersContract,
+ expect.objectContaining({ query: { organizationId: 'org-b' } })
+ )
+ })
+})
diff --git a/apps/sim/ee/sso/hooks/sso.ts b/apps/sim/ee/sso/hooks/sso.ts
index 1f4e1bb7891..725efcf1cc1 100644
--- a/apps/sim/ee/sso/hooks/sso.ts
+++ b/apps/sim/ee/sso/hooks/sso.ts
@@ -1,6 +1,6 @@
'use client'
-import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
listSsoProvidersContract,
@@ -42,7 +42,6 @@ export function useSSOProviders({ enabled = true, organizationId }: UseSSOProvid
queryKey: ssoKeys.providerList(organizationId),
queryFn: ({ signal }) => fetchSSOProviders(signal, organizationId),
staleTime: 5 * 60 * 1000,
- placeholderData: keepPreviousData,
enabled,
})
}
diff --git a/apps/sim/ee/whitelabeling/components/branding-provider.test.tsx b/apps/sim/ee/whitelabeling/components/branding-provider.test.tsx
new file mode 100644
index 00000000000..4b2c80d48a9
--- /dev/null
+++ b/apps/sim/ee/whitelabeling/components/branding-provider.test.tsx
@@ -0,0 +1,101 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockUseWhitelabelSettings } = vi.hoisted(() => ({
+ mockUseWhitelabelSettings: vi.fn(),
+}))
+
+vi.mock('@/ee/whitelabeling/hooks/whitelabel', () => ({
+ useWhitelabelSettings: mockUseWhitelabelSettings,
+}))
+
+import {
+ BrandingProvider,
+ useOrgBrandConfig,
+} from '@/ee/whitelabeling/components/branding-provider'
+
+function BrandIdentity() {
+ const config = useOrgBrandConfig()
+ return (
+ <>
+ {config.name}
+
+ >
+ )
+}
+
+let container: HTMLDivElement
+let root: Root
+
+function mount(ui: ReactNode) {
+ act(() => {
+ root.render(ui)
+ })
+}
+
+describe('BrandingProvider', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mockUseWhitelabelSettings.mockImplementation((organizationId: string | undefined) => ({
+ data:
+ organizationId === 'org-b'
+ ? { brandName: 'Host B', logoUrl: 'https://host-b.example/logo.png' }
+ : organizationId === 'org-a'
+ ? { brandName: 'Viewer A', logoUrl: 'https://viewer-a.example/logo.png' }
+ : undefined,
+ }))
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('uses routed host organization branding when the viewer active organization differs', () => {
+ mount(
+
+
+
+ )
+
+ expect(mockUseWhitelabelSettings).toHaveBeenCalledWith('org-b')
+ expect(container).toHaveTextContent('Host B')
+ expect(container).not.toHaveTextContent('Viewer A')
+ expect(container.querySelector('img')).toHaveAttribute('src', 'https://host-b.example/logo.png')
+ })
+
+ it('uses hydrated host branding without an organization-member fetch for collaborators', () => {
+ mount(
+
+
+
+ )
+
+ expect(mockUseWhitelabelSettings).toHaveBeenCalledWith(undefined)
+ expect(mockUseWhitelabelSettings).not.toHaveBeenCalledWith('org-a')
+ expect(mockUseWhitelabelSettings).not.toHaveBeenCalledWith('org-b')
+ expect(container).toHaveTextContent('Host B')
+ expect(container.querySelector('img')).toHaveAttribute('src', 'https://host-b.example/logo.png')
+ })
+})
diff --git a/apps/sim/ee/whitelabeling/components/branding-provider.tsx b/apps/sim/ee/whitelabeling/components/branding-provider.tsx
index 08436397089..e079cbd4609 100644
--- a/apps/sim/ee/whitelabeling/components/branding-provider.tsx
+++ b/apps/sim/ee/whitelabeling/components/branding-provider.tsx
@@ -5,7 +5,6 @@ import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding
import { getBrandConfig } from '@/ee/whitelabeling/branding'
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
import { generateOrgThemeCSS, mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
-import { useOrganizations } from '@/hooks/queries/organization'
interface BrandingContextValue {
config: BrandConfig
@@ -17,6 +16,10 @@ const BrandingContext = createContext({
interface BrandingProviderProps {
children: React.ReactNode
+ /** Organization that owns the routed workspace, never the viewer's active organization. */
+ hostOrganizationId: string | null
+ /** External collaborators use the access-authorized server snapshot instead of the member-only org API. */
+ viewerIsHostOrganizationMember: boolean
/**
* Org whitelabel settings fetched server-side from the DB by the workspace layout.
* Used as the source of truth until the React Query result becomes available,
@@ -29,10 +32,15 @@ interface BrandingProviderProps {
* Provides merged branding (instance env vars + org DB settings) to the workspace.
* Injects a `