From 8183314a8877f6bfb08f8df14942338ca2c7d8c1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 9 Jul 2026 14:32:42 -0700 Subject: [PATCH 1/5] 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) => ( + + ))} + + ), +})) + +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/5] 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 && ( +
+ +

+ {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} /> 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 + }) => ( + + ), + ChipLink: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), + Credit: () => , + Switch: ({ + checked, + disabled, + onCheckedChange, + }: { + checked: boolean + disabled?: boolean + onCheckedChange?: (checked: boolean) => void + }) => ( + + Replace $SIM_API_KEY with your API key + {canManage && ( + <> + , or{' '} + + + )}

)} @@ -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'} + +
-
-
-
- - - setEditingParameterDescriptions((prev) => ({ - ...prev, - [name]: e.target.value, - })) - } - placeholder={`Enter description for ${name}`} - /> +
+
+ + + 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) => , + Button: ({ + children, + className: _className, + variant: _variant, + ...props + }: ButtonHTMLAttributes & { variant?: string }) => ( + + ), + 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 }) => ( + + ), + 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} + ) : ( +
+
{file.name}
+
{formatFileSize(file.size)}
+
+ )} + + +
) } @@ -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} - ) : ( -
-
- {file.name} -
-
- {formatFileSize(file.size)} -
-
- )} - - -
- ) - })} + {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' - )} - > -
-
{title}
-
-
- {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} - - ) : ( - - ) - - 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 ( + <> +
+ + + +
+ +
+
+ {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.title}
+
+
+ {group.items.map((item) => { + const Icon = item.icon + const active = activeSection === item.id + return ( + + + + ) + })} +
+
+ ))} +
+
+ + !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 && } +
+ ) +} + +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() { + ), + ChipCombobox: () =>
, + ChipInput: ({ + value, + onChange, + }: { + value?: string + onChange?: ChangeEventHandler + }) => , + ChipSelect: () =>
, + ChipTextarea: ({ + value, + onChange, + }: { + value?: string + onChange?: ChangeEventHandler + }) =>