From e9801fbdec74c05560f53faf27b25f25f7b71c3c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 08:05:42 -0700 Subject: [PATCH 01/18] feat(credentials): add managed credential groups --- apps/sim/.env.example | 1 + .../api/auth/oauth/credentials/route.test.ts | 38 +- .../app/api/auth/oauth/token/route.test.ts | 156 +- apps/sim/app/api/auth/oauth/token/route.ts | 141 +- .../enroll/[token]/complete/route.test.ts | 69 + .../enroll/[token]/complete/route.ts | 29 + .../[token]/oauth/[optionId]/route.test.ts | 82 + .../enroll/[token]/oauth/[optionId]/route.ts | 64 + .../credential-groups/enrollment-redirect.ts | 17 + .../oauth/[provider]/callback/route.test.ts | 91 + .../oauth/[provider]/callback/route.ts | 87 + .../slack-managed-users/callback/route.ts | 123 + .../app/api/credentials/[id]/members/route.ts | 20 +- apps/sim/app/api/credentials/[id]/route.ts | 19 +- apps/sim/app/api/credentials/draft/route.ts | 7 +- .../app/api/credentials/memberships/route.ts | 6 +- apps/sim/app/api/credentials/route.ts | 13 +- .../[enrollmentId]/resend/route.ts | 60 + .../enrollments/[enrollmentId]/route.ts | 51 + .../[groupId]/enrollments/route.test.ts | 143 + .../[groupId]/enrollments/route.ts | 60 + .../[id]/credential-groups/[groupId]/route.ts | 126 + .../[groupId]/slack-managed-users/route.ts | 36 + .../[id]/credential-groups/route.test.ts | 120 + .../[id]/credential-groups/route.ts | 78 + .../enroll/[token]/oauth-reconnect-link.tsx | 15 + .../enroll/[token]/oauth-toast.test.tsx | 74 + .../enroll/[token]/oauth-toast.tsx | 38 + .../credential-groups/enroll/[token]/page.tsx | 180 + .../enroll/[token]/search-params.ts | 13 + .../[workspaceId]/settings/[section]/page.tsx | 21 +- .../settings/[section]/search-params.ts | 24 + .../settings/[section]/settings.tsx | 6 + .../[workspaceId]/settings/navigation.test.ts | 2 + .../credential-selector.tsx | 8 +- .../settings-sidebar/settings-sidebar.tsx | 6 + apps/sim/blocks/blocks/credential-group.ts | 365 + apps/sim/blocks/registry-maps.ts | 2 + .../credential-group-invitation-email.tsx | 50 + .../emails/credential-groups/index.ts | 1 + apps/sim/components/emails/index.ts | 2 + apps/sim/components/emails/render.ts | 11 + .../components/settings/navigation.test.ts | 8 + apps/sim/components/settings/navigation.ts | 27 + .../credential-group-create-modal.tsx | 104 + .../components/credential-group-detail.tsx | 299 + .../components/credential-group-details.tsx | 299 + .../credential-group-invite-modal.tsx | 117 + .../components/credential-groups-settings.tsx | 161 + .../ee/credential-groups/components/index.ts | 1 + .../components/slack-managed-users-modal.tsx | 298 + apps/sim/executor/constants.ts | 1 + .../credential-group-handler.test.ts | 189 + .../credential-group-handler.ts | 207 + .../handlers/credential/credential-handler.ts | 11 +- apps/sim/executor/handlers/registry.ts | 2 + apps/sim/hooks/queries/credential-groups.ts | 196 + .../queries/utils/credential-group-queries.ts | 26 + apps/sim/hooks/use-permission-config.ts | 7 +- .../api/contracts/credential-groups.test.ts | 199 + .../lib/api/contracts/credential-groups.ts | 425 + apps/sim/lib/api/contracts/credentials.ts | 9 +- apps/sim/lib/api/contracts/index.ts | 1 + .../lib/api/contracts/oauth-connections.ts | 8 + apps/sim/lib/api/contracts/workspaces.ts | 5 + apps/sim/lib/auth/connectors/managed-oauth.ts | 118 + apps/sim/lib/auth/connectors/providers.ts | 16 +- apps/sim/lib/auth/credential-access.ts | 9 +- apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 18 + apps/sim/lib/core/config/feature-flags.ts | 6 + apps/sim/lib/credential-groups/access.ts | 29 + .../application/authorization.ts | 26 + .../credential-groups/application/context.ts | 27 + .../application/delegation.ts | 41 + .../application/list-credentials.test.ts | 254 + .../application/list-credentials.ts | 111 + .../application/list-groups.ts | 68 + .../application/list-people.ts | 79 + .../application/operations.ts | 44 + .../application/send-invite.ts | 72 + .../application/slack-managed-users.ts | 138 + .../sim/lib/credential-groups/availability.ts | 11 + .../lib/credential-groups/credentials.test.ts | 48 + apps/sim/lib/credential-groups/credentials.ts | 173 + .../lib/credential-groups/enrollments.test.ts | 111 + apps/sim/lib/credential-groups/enrollments.ts | 687 + apps/sim/lib/credential-groups/groups.ts | 110 + .../lib/credential-groups/oauth-state.test.ts | 129 + apps/sim/lib/credential-groups/oauth-state.ts | 180 + apps/sim/lib/credential-groups/oauth.ts | 220 + .../lib/credential-groups/provider-adapter.ts | 86 + .../provider-configuration.ts | 144 + .../provider-registry.test.ts | 72 + .../credential-groups/provider-registry.ts | 28 + apps/sim/lib/credential-groups/providers.ts | 84 + apps/sim/lib/credential-groups/rate-limit.ts | 110 + .../sim/lib/credential-groups/service.test.ts | 89 + apps/sim/lib/credential-groups/service.ts | 268 + .../slack-managed-user-scopes.ts | 25 + .../slack-managed-users.test.ts | 399 + .../credential-groups/slack-managed-users.ts | 759 + .../lib/credential-groups/slack-provider.ts | 239 + .../standard-oauth-provider.test.ts | 198 + .../standard-oauth-provider.ts | 347 + .../credentials/application/authorization.ts | 13 + .../application/managed-oauth-delegation.ts | 41 + .../lib/credentials/application/operations.ts | 7 + .../resolve-managed-oauth-token.test.ts | 111 + .../resolve-managed-oauth-token.ts | 47 + .../sim/lib/credentials/managed-oauth.test.ts | 83 + apps/sim/lib/credentials/managed-oauth.ts | 441 + .../lib/credentials/orchestration/index.ts | 79 +- apps/sim/lib/credentials/queries.test.ts | 57 +- apps/sim/lib/credentials/queries.ts | 7 +- apps/sim/lib/oauth/credential-service.ts | 11 + apps/sim/lib/oauth/oauth.ts | 1 - apps/sim/lib/oauth/token-resolution.ts | 7 +- apps/sim/lib/posthog/events.ts | 35 +- apps/sim/lib/workspaces/host-context.ts | 5 + apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/gmail/add_label.ts | 1 + apps/sim/tools/gmail/archive.ts | 1 + apps/sim/tools/gmail/create_label.ts | 1 + apps/sim/tools/gmail/delete.ts | 1 + apps/sim/tools/gmail/delete_draft.ts | 1 + apps/sim/tools/gmail/delete_label.ts | 1 + apps/sim/tools/gmail/draft.ts | 1 + apps/sim/tools/gmail/edit_draft.ts | 1 + apps/sim/tools/gmail/get_draft.ts | 1 + apps/sim/tools/gmail/get_thread.ts | 1 + apps/sim/tools/gmail/list_drafts.ts | 1 + apps/sim/tools/gmail/list_labels.ts | 1 + apps/sim/tools/gmail/list_threads.ts | 1 + apps/sim/tools/gmail/mark_read.ts | 1 + apps/sim/tools/gmail/mark_unread.ts | 1 + apps/sim/tools/gmail/move.ts | 1 + apps/sim/tools/gmail/read.ts | 1 + apps/sim/tools/gmail/remove_label.ts | 1 + apps/sim/tools/gmail/search.ts | 1 + apps/sim/tools/gmail/send.ts | 1 + apps/sim/tools/gmail/trash_thread.ts | 1 + apps/sim/tools/gmail/unarchive.ts | 1 + apps/sim/tools/gmail/untrash_thread.ts | 1 + apps/sim/tools/gmail/update_label.ts | 1 + apps/sim/tools/index.test.ts | 64 +- apps/sim/tools/index.ts | 26 +- apps/sim/tools/types.ts | 1 + apps/sim/triggers/slack/capabilities.test.ts | 23 + apps/sim/triggers/slack/capabilities.ts | 32 +- packages/audit/src/types.ts | 2 + packages/auth/src/principal.ts | 2 + packages/db/migrations/0291_fuzzy_wong.sql | 86 + .../db/migrations/meta/0291_snapshot.json | 19634 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 164 + packages/emcn/src/icons/grid-offset.tsx | 24 + packages/emcn/src/icons/index.ts | 1 + packages/testing/src/mocks/schema.mock.ts | 57 +- scripts/check-api-validation-contracts.ts | 4 +- 160 files changed, 31663 insertions(+), 64 deletions(-) create mode 100644 apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts create mode 100644 apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts create mode 100644 apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts create mode 100644 apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts create mode 100644 apps/sim/app/api/credential-groups/enrollment-redirect.ts create mode 100644 apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts create mode 100644 apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts create mode 100644 apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/credential-groups/route.ts create mode 100644 apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx create mode 100644 apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx create mode 100644 apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx create mode 100644 apps/sim/app/credential-groups/enroll/[token]/page.tsx create mode 100644 apps/sim/app/credential-groups/enroll/[token]/search-params.ts create mode 100644 apps/sim/blocks/blocks/credential-group.ts create mode 100644 apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx create mode 100644 apps/sim/components/emails/credential-groups/index.ts create mode 100644 apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx create mode 100644 apps/sim/ee/credential-groups/components/credential-group-detail.tsx create mode 100644 apps/sim/ee/credential-groups/components/credential-group-details.tsx create mode 100644 apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx create mode 100644 apps/sim/ee/credential-groups/components/credential-groups-settings.tsx create mode 100644 apps/sim/ee/credential-groups/components/index.ts create mode 100644 apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx create mode 100644 apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts create mode 100644 apps/sim/executor/handlers/credential-group/credential-group-handler.ts create mode 100644 apps/sim/hooks/queries/credential-groups.ts create mode 100644 apps/sim/hooks/queries/utils/credential-group-queries.ts create mode 100644 apps/sim/lib/api/contracts/credential-groups.test.ts create mode 100644 apps/sim/lib/api/contracts/credential-groups.ts create mode 100644 apps/sim/lib/auth/connectors/managed-oauth.ts create mode 100644 apps/sim/lib/credential-groups/access.ts create mode 100644 apps/sim/lib/credential-groups/application/authorization.ts create mode 100644 apps/sim/lib/credential-groups/application/context.ts create mode 100644 apps/sim/lib/credential-groups/application/delegation.ts create mode 100644 apps/sim/lib/credential-groups/application/list-credentials.test.ts create mode 100644 apps/sim/lib/credential-groups/application/list-credentials.ts create mode 100644 apps/sim/lib/credential-groups/application/list-groups.ts create mode 100644 apps/sim/lib/credential-groups/application/list-people.ts create mode 100644 apps/sim/lib/credential-groups/application/operations.ts create mode 100644 apps/sim/lib/credential-groups/application/send-invite.ts create mode 100644 apps/sim/lib/credential-groups/application/slack-managed-users.ts create mode 100644 apps/sim/lib/credential-groups/availability.ts create mode 100644 apps/sim/lib/credential-groups/credentials.test.ts create mode 100644 apps/sim/lib/credential-groups/credentials.ts create mode 100644 apps/sim/lib/credential-groups/enrollments.test.ts create mode 100644 apps/sim/lib/credential-groups/enrollments.ts create mode 100644 apps/sim/lib/credential-groups/groups.ts create mode 100644 apps/sim/lib/credential-groups/oauth-state.test.ts create mode 100644 apps/sim/lib/credential-groups/oauth-state.ts create mode 100644 apps/sim/lib/credential-groups/oauth.ts create mode 100644 apps/sim/lib/credential-groups/provider-adapter.ts create mode 100644 apps/sim/lib/credential-groups/provider-configuration.ts create mode 100644 apps/sim/lib/credential-groups/provider-registry.test.ts create mode 100644 apps/sim/lib/credential-groups/provider-registry.ts create mode 100644 apps/sim/lib/credential-groups/providers.ts create mode 100644 apps/sim/lib/credential-groups/rate-limit.ts create mode 100644 apps/sim/lib/credential-groups/service.test.ts create mode 100644 apps/sim/lib/credential-groups/service.ts create mode 100644 apps/sim/lib/credential-groups/slack-managed-user-scopes.ts create mode 100644 apps/sim/lib/credential-groups/slack-managed-users.test.ts create mode 100644 apps/sim/lib/credential-groups/slack-managed-users.ts create mode 100644 apps/sim/lib/credential-groups/slack-provider.ts create mode 100644 apps/sim/lib/credential-groups/standard-oauth-provider.test.ts create mode 100644 apps/sim/lib/credential-groups/standard-oauth-provider.ts create mode 100644 apps/sim/lib/credentials/application/authorization.ts create mode 100644 apps/sim/lib/credentials/application/managed-oauth-delegation.ts create mode 100644 apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts create mode 100644 apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts create mode 100644 apps/sim/lib/credentials/managed-oauth.test.ts create mode 100644 apps/sim/lib/credentials/managed-oauth.ts create mode 100644 packages/db/migrations/0291_fuzzy_wong.sql create mode 100644 packages/db/migrations/meta/0291_snapshot.json create mode 100644 packages/emcn/src/icons/grid-offset.tsx diff --git a/apps/sim/.env.example b/apps/sim/.env.example index de5ae82cafa..927e437bad4 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default # DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams # FORKING_ENABLED= # Workspace forks +# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only # Instance organization (Optional). Most enterprise features read their settings from the diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index fd87f08f5e9..66f28ccef82 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -4,7 +4,13 @@ * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing' +import { + dbChainMockFns, + hybridAuthMockFns, + permissionsMock, + resetDbChainMock, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('should handle unauthenticated user', async () => { @@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => { expect(response.status).toBe(200) expect(data.credentials).toHaveLength(0) }) + + it('does not expose a managed credential requested by exact ID', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'managed-credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + displayName: 'Managed Gmail', + providerId: 'google-email', + accountId: null, + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: null, + accountScope: null, + accountUpdatedAt: null, + }, + ]) + + const response = await GET( + createMockRequestWithQuery('GET', '?credentialId=managed-credential-1') + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ credentials: [] }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index c149d1909b0..dab37676ad1 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -12,8 +12,17 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({ +const { + mockAuthenticateManagedOAuthDelegation, + mockAuthorizeCredentialUse, + mockGetToolMetadata, + mockResolveManagedOAuthCredentialToken, + mockResolveServiceAccountToken, +} = vi.hoisted(() => ({ + mockAuthenticateManagedOAuthDelegation: vi.fn(), mockAuthorizeCredentialUse: vi.fn(), + mockGetToolMetadata: vi.fn(), + mockResolveManagedOAuthCredentialToken: vi.fn(), mockResolveServiceAccountToken: vi.fn(), })) @@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mockAuthorizeCredentialUse, })) +vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({ + authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation, + InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {}, +})) + +vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({ + resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken }, +})) + +vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata })) + import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { GET, POST } from '@/app/api/auth/oauth/token/route' @@ -332,6 +352,140 @@ describe('OAuth Token API Routes', () => { ) }) + describe('managed OAuth path', () => { + const managedCredential = { + accountId: '', + credentialId: 'managed-credential-id', + credentialType: 'managed_oauth', + providerId: 'google-email', + workspaceId: 'workspace-id', + usedCredentialTable: true, + } + + beforeEach(() => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, + }) + }) + + it('fails closed when workflow delegation is missing', async () => { + const response = await POST( + createMockRequest('POST', { + credentialId: 'managed-credential-id', + toolId: 'gmail_read', + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + }) + expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled() + }) + + it('resolves a manually supplied managed credential ID with scoped delegation', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-id', + workspaceId: 'workspace-id', + delegationId: 'delegation-id', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: 'managed-credential-id' }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-id', + }, + } + mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal) + mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-access-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'managed-credential-id', toolId: 'gmail_read' }, + { 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' } + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' }) + expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({ + principal, + input: { + credentialId: 'managed-credential-id', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + toolId: 'gmail_read', + }, + request: expect.any(NextRequest), + }) + }) + + it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => { + mockGetToolMetadata.mockReturnValueOnce({ + oauth: { + required: true, + provider: 'slack', + }, + }) + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-id', + workspaceId: 'workspace-id', + delegationId: 'delegation-id', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: 'managed-credential-id' }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-id', + }, + } + mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal) + mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-slack-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'managed-credential-id', toolId: 'slack_message' }, + { 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' } + ) + ) + + expect(response.status).toBe(200) + expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({ + principal, + input: { + credentialId: 'managed-credential-id', + expectedProviderId: 'slack', + requiredScopes: expect.arrayContaining([ + 'channels:read', + 'channels:history', + 'chat:write', + ]), + toolId: 'slack_message', + }, + request: expect.any(NextRequest), + }) + }) + }) + describe('credentialAccountUserId + providerId path', () => { it('should reject unauthenticated requests', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index c3e1744dc1f..e900d8da5ec 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -1,19 +1,30 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { + MANAGED_OAUTH_DELEGATION_HEADER, oauthTokenGetContract, oauthTokenPostContract, } from '@/lib/api/contracts/oauth-connections' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service' +import { + authenticateManagedOAuthDelegation, + InvalidManagedOAuthDelegationError, +} from '@/lib/credentials/application/managed-oauth-delegation' +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' +import { getToolMetadata } from '@/tools/metadata' export const dynamic = 'force-dynamic' @@ -50,6 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { credentialId, credentialAccountUserId, providerId, + toolId, workflowId, scopes, impersonateEmail, @@ -115,6 +127,132 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal | null = null + const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER] + if (managedOAuthDelegation && credentialId) { + try { + managedOAuthPrincipal = await authenticateManagedOAuthDelegation( + managedOAuthDelegation, + credentialId + ) + } catch (error) { + if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID', + error: error.message, + }, + { status: 401 } + ) + } + } + + const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null + if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) { + if (!toolId) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED', + error: 'A tool ID is required to use a managed credential', + }, + { status: 400 } + ) + } + + const toolMetadata = getToolMetadata(toolId) + if (!toolMetadata?.oauth?.required) { + logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }, + { status: 500 } + ) + } + const requiredScopes = + toolMetadata.oauth.requiredScopes ?? + getCanonicalScopesForProvider(toolMetadata.oauth.provider) + if (requiredScopes.length === 0) { + logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, { + toolId, + providerId: toolMetadata.oauth.provider, + }) + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }, + { status: 500 } + ) + } + + if (!managedOAuthPrincipal) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + error: 'Managed credentials can only be used by an authenticated workflow execution', + }, + { status: 403 } + ) + } + + try { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal: managedOAuthPrincipal, + input: { + credentialId: resolved.credentialId, + expectedProviderId: toolMetadata.oauth.provider, + requiredScopes, + toolId, + }, + request, + }) + + captureServerEvent( + managedOAuthPrincipal.subjectUserId, + 'credential_used', + { + credential_type: 'managed_oauth', + provider_id: toolMetadata.oauth.provider, + workspace_id: managedOAuthPrincipal.workspaceId, + }, + { groups: { workspace: managedOAuthPrincipal.workspaceId } } + ) + + return NextResponse.json( + { + accessToken: result.accessToken, + ...(result.idToken ? { idToken: result.idToken } : {}), + }, + { status: 200 } + ) + } catch (error) { + if (error instanceof ManagedOAuthCredentialError) { + logger.warn(`[${requestId}] Managed OAuth credential rejected`, { + credentialId: resolved.credentialId, + code: error.code, + }) + return NextResponse.json( + { code: error.code, error: error.message }, + { status: error.statusCode } + ) + } + + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_UNAUTHORIZED', + error: orchestrationError.message, + }, + { status: statusForOrchestrationError(orchestrationError.code) } + ) + } + throw error + } + } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) const result = await resolveCredentialToken(auth, { requestId, @@ -124,6 +262,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { impersonateEmail, callerUserId, auditRequest: request, + resolvedCredential: resolved, }) if (!result.ok) { diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts new file mode 100644 index 00000000000..a54e67e5f5b --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteEnrollment, mockIpRateLimit } = vi.hoisted(() => ({ + mockCompleteEnrollment: vi.fn(), + mockIpRateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + completeCredentialGroupEnrollment: mockCompleteEnrollment, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mockIpRateLimit, +})) + +import { POST } from '@/app/api/credential-groups/enroll/[token]/complete/route' + +const context = { params: Promise.resolve({ token: 'invitation-token' }) } + +function request() { + return new NextRequest( + 'http://localhost:3000/api/credential-groups/enroll/invitation-token/complete', + { method: 'POST' } + ) +} + +describe('credential group enrollment completion route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIpRateLimit.mockResolvedValue(null) + mockCompleteEnrollment.mockResolvedValue(true) + }) + + it('submits a fully connected enrollment and redirects to its checklist', async () => { + const response = await POST(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?submitted=1' + ) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mockCompleteEnrollment).toHaveBeenCalledWith('invitation-token') + }) + + it('redirects an incomplete enrollment without marking it complete', async () => { + mockCompleteEnrollment.mockResolvedValue(false) + + const response = await POST(request(), context) + + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=incomplete' + ) + }) + + it('stops before token lookup when the public IP budget is exhausted', async () => { + mockIpRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await POST(request(), context) + + expect(response.status).toBe(429) + expect(mockCompleteEnrollment).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts new file mode 100644 index 00000000000..0ef68e920cc --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts @@ -0,0 +1,29 @@ +import type { NextRequest } from 'next/server' +import { completeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'complete') + if (limited) return limited + + const parsed = await parseRequest(completeCredentialGroupEnrollmentContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const completed = await completeCredentialGroupEnrollment(token) + if (completed === null) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + return createCredentialGroupEnrollmentRedirect( + token, + completed ? { submitted: '1' } : { oauth: 'incomplete' } + ) + } +) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts new file mode 100644 index 00000000000..600c85a9128 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetContext, mockStartOAuth, mockIpRateLimit, mockEnrollmentRateLimit } = vi.hoisted( + () => ({ + mockGetContext: vi.fn(), + mockStartOAuth: vi.fn(), + mockIpRateLimit: vi.fn(), + mockEnrollmentRateLimit: vi.fn(), + }) +) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + getCredentialGroupOAuthContext: mockGetContext, +})) + +vi.mock('@/lib/credential-groups/oauth', () => ({ + startCredentialGroupOAuth: mockStartOAuth, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mockIpRateLimit, + enforceCredentialGroupEnrollmentOAuthRateLimit: mockEnrollmentRateLimit, +})) + +import { GET } from '@/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route' + +const context = { + params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }), +} + +function request() { + return new NextRequest( + 'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1' + ) +} + +describe('credential group OAuth start route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIpRateLimit.mockResolvedValue(null) + mockEnrollmentRateLimit.mockResolvedValue(null) + mockGetContext.mockResolvedValue({ enrollmentId: 'enrollment-1' }) + mockStartOAuth.mockResolvedValue('https://accounts.google.com/o/oauth2/v2/auth?state=state-1') + }) + + it('redirects a valid enrollment to Google', async () => { + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toContain('https://accounts.google.com/') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mockStartOAuth).toHaveBeenCalledWith( + { enrollmentId: 'enrollment-1' }, + 'invitation-token' + ) + }) + + it('returns the same 404 for an unavailable enrollment or option', async () => { + mockGetContext.mockResolvedValue(null) + + const response = await GET(request(), context) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Not found' }) + expect(mockStartOAuth).not.toHaveBeenCalled() + }) + + it('stops before token lookup when the public IP budget is exhausted', async () => { + mockIpRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(429) + expect(mockGetContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts new file mode 100644 index 00000000000..9033bb7b3b9 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { startCredentialGroupOAuthContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { + enforceCredentialGroupEnrollmentOAuthRateLimit, + enforcePublicCredentialGroupIpRateLimit, +} from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupOAuthStartAPI') + +export const GET = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ token: string; optionId: string }> } + ) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start') + if (limited) return limited + + const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context) + if (!parsed.success) return parsed.response + const { token, optionId } = parsed.data.params + const enrollment = await getCredentialGroupOAuthContext(token, optionId) + if (!enrollment) { + return NextResponse.json( + { error: 'Not found' }, + { status: 404, headers: { 'Cache-Control': 'no-store' } } + ) + } + + const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit( + enrollment.enrollmentId + ) + if (enrollmentLimited) return enrollmentLimited + + try { + const authorizationUrl = await startCredentialGroupOAuth(enrollment, token) + const response = NextResponse.redirect(authorizationUrl) + response.headers.set('Cache-Control', 'no-store') + response.headers.set('Referrer-Policy', 'no-referrer') + return response + } catch (error) { + logger.error('Failed to start managed OAuth authorization', { + error: getErrorMessage(error), + }) + return createCredentialGroupEnrollmentRedirect(token, { + oauth: + error instanceof CredentialGroupOAuthError && error.statusCode === 409 + ? 'configuration_changed' + : 'unavailable', + }) + } + } +) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts new file mode 100644 index 00000000000..a72ec009906 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export function createCredentialGroupEnrollmentRedirect( + token: string, + params: Record +): NextResponse { + const query = new URLSearchParams(params).toString() + const location = `/credential-groups/enroll/${encodeURIComponent(token)}${query ? `?${query}` : ''}` + return new NextResponse(null, { + status: 307, + headers: { + Location: location, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }, + }) +} diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts new file mode 100644 index 00000000000..2d38464722f --- /dev/null +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockConsumeAttempt, mockGetContext, mockCompleteOAuth, mockRateLimit } = vi.hoisted(() => ({ + mockConsumeAttempt: vi.fn(), + mockGetContext: vi.fn(), + mockCompleteOAuth: vi.fn(), + mockRateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/oauth-state', () => ({ + consumeCredentialGroupOAuthAttempt: mockConsumeAttempt, +})) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + getCredentialGroupOAuthContext: mockGetContext, +})) + +vi.mock('@/lib/credential-groups/oauth', () => ({ + completeCredentialGroupOAuth: mockCompleteOAuth, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mockRateLimit, +})) + +import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route' + +const attempt = { + provider: 'gmail', + invitationToken: 'invitation-token', + optionId: 'option-1', +} +const context = { params: Promise.resolve({ provider: 'gmail' }) } + +function request(query: string) { + return new NextRequest( + `http://localhost:3000/api/credential-groups/oauth/gmail/callback?${query}` + ) +} + +describe('credential group OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRateLimit.mockResolvedValue(null) + mockConsumeAttempt.mockResolvedValue(attempt) + mockGetContext.mockResolvedValue({ enrollmentId: 'enrollment-1' }) + mockCompleteOAuth.mockResolvedValue(undefined) + }) + + it('consumes provider-bound state and returns after a successful exchange', async () => { + const response = await GET(request('state=state-1&code=code-1'), context) + + expect(mockConsumeAttempt).toHaveBeenCalledWith('state-1') + expect(mockCompleteOAuth).toHaveBeenCalledWith( + { enrollmentId: 'enrollment-1' }, + attempt, + 'code-1' + ) + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?connected=option-1' + ) + }) + + it('returns without exchanging when the user denies consent', async () => { + const response = await GET(request('state=state-1&error=access_denied'), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=denied' + ) + expect(mockGetContext).not.toHaveBeenCalled() + expect(mockCompleteOAuth).not.toHaveBeenCalled() + }) + + it('rejects replayed, expired, or cross-provider state', async () => { + mockConsumeAttempt.mockResolvedValue(null) + + const replayedResponse = await GET(request('state=state-1&code=code-1'), context) + expect(replayedResponse.status).toBe(400) + + mockConsumeAttempt.mockResolvedValue({ ...attempt, provider: 'slack' }) + const mismatchedResponse = await GET(request('state=state-2&code=code-2'), context) + expect(mismatchedResponse.status).toBe(400) + expect(mockCompleteOAuth).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts new file mode 100644 index 00000000000..32143aedbba --- /dev/null +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { credentialGroupOAuthCallbackContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth' +import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupOAuthCallbackAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback') + if (limited) return limited + + const parsed = await parseRequest(credentialGroupOAuthCallbackContract, request, context) + if (!parsed.success) return parsed.response + const { provider } = parsed.data.params + const { state, code, error: providerError } = parsed.data.query + + let attempt + try { + attempt = await consumeCredentialGroupOAuthAttempt(state) + } catch (error) { + logger.error('Failed to consume credential group OAuth state', { + error: getErrorMessage(error), + }) + return NextResponse.json( + { error: 'Authorization state is unavailable. Please try again.' }, + { status: 503, headers: { 'Cache-Control': 'no-store' } } + ) + } + if (!attempt || attempt.provider !== provider) { + return NextResponse.json( + { error: 'Authorization state is invalid or expired.' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } + if (providerError) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' }) + } + if (!code) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' }) + } + + const enrollment = await getCredentialGroupOAuthContext( + attempt.invitationToken, + attempt.optionId + ) + if (!enrollment) { + return NextResponse.json( + { error: 'Invitation is invalid or expired.' }, + { status: 404, headers: { 'Cache-Control': 'no-store' } } + ) + } + + try { + await completeCredentialGroupOAuth(enrollment, attempt, code) + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + connected: attempt.optionId, + }) + } catch (error) { + logger.error('Managed OAuth authorization failed', { + provider, + error: getErrorMessage(error), + }) + const status = + error instanceof CredentialGroupOAuthError && error.statusCode === 403 + ? error.message.startsWith('Sign in with') + ? 'account_mismatch' + : 'permissions_required' + : error instanceof CredentialGroupOAuthError && error.statusCode === 409 + ? 'configuration_changed' + : 'failed' + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status }) + } + } +) diff --git a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts new file mode 100644 index 00000000000..441e0978439 --- /dev/null +++ b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' + +const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI') +const CHANNEL_NAME = 'slack-managed-users' + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function jsonLiteral(value: unknown): string { + return JSON.stringify(value).replace(//g, '\\u003e') +} + +function closePopup(params: { + ok: boolean + message: string + state?: string + credentialGroupId?: string + slackBotCredentialId?: string + reason: string +}): NextResponse { + const title = params.ok ? 'Slack configured' : 'Slack setup failed' + const payload = { + type: CHANNEL_NAME, + ok: params.ok, + state: params.state, + credentialGroupId: params.credentialGroupId, + slackBotCredentialId: params.slackBotCredentialId, + reason: params.reason, + } + const body = `${title}

${escapeHtml(params.message)}

` + return new NextResponse(body, { + headers: { + 'Cache-Control': 'no-store, max-age=0', + 'Content-Type': 'text/html; charset=utf-8', + }, + }) +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const rawState = new URL(request.url).searchParams.get('state')?.slice(0, 512) + const session = await getSession() + if (!session?.user?.id || !session.session?.id) { + return closePopup({ + ok: false, + message: 'Sign in to Sim to complete this Slack setup.', + state: rawState, + reason: 'unauthenticated', + }) + } + const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {}) + if (!parsed.success) { + return closePopup({ + ok: false, + message: 'Slack returned an invalid authorization response.', + state: rawState, + reason: 'invalid_callback', + }) + } + const { state, code, error: providerError } = parsed.data.query + try { + const result = await completeSlackCredentialGroupConfiguration.execute({ + principal: { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }, + input: { state, code, providerError }, + request, + }) + return result.ok + ? closePopup({ + ok: true, + message: 'Slack is ready for this Credential Group. You can close this window.', + state, + credentialGroupId: result.result.credentialGroupId, + slackBotCredentialId: result.result.slackBotCredentialId, + reason: result.reason, + }) + : closePopup({ + ok: false, + message: 'Slack authorization was cancelled.', + state, + reason: result.reason, + }) + } catch (error) { + const orchestrationError = asOrchestrationError(error) + const message = + error instanceof SlackManagedUsersError || orchestrationError + ? getErrorMessage(error) + : 'Slack setup failed. Please try again.' + logger.error('Slack Credential Group configuration callback failed', { + error: getErrorMessage(error), + }) + return closePopup({ + ok: false, + message, + state, + reason: + error instanceof SlackManagedUsersError + ? error.code + : (orchestrationError?.code ?? 'unknown'), + }) + } +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 72132ee56d0..7c87041c4d6 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -27,12 +27,19 @@ interface RouteContext { async function requireCredentialAdmin(credentialId: string, userId: string) { const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) - if (!cred) return null + if (!cred || cred.type === 'managed_oauth') { + return null + } const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) if (perm === null) return null @@ -67,12 +74,17 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route const { id: credentialId } = await context.params const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) - if (!cred) { + if (!cred || cred.type === 'managed_oauth') { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 3ff1de37444..ca1eee11b9c 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -50,7 +50,7 @@ export const GET = withRouteHandler( try { const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { + if (!access.credential || access.credential.type === 'managed_oauth') { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } if (!canUseCredential(access)) { @@ -82,6 +82,11 @@ export const PUT = withRouteHandler( const { id } = parsed.data.params const body = parsed.data.body + const currentAccess = await getCredentialActorContext(id, session.user.id) + if (!currentAccess.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + const result = await performUpdateCredential({ credentialId: id, userId: session.user.id, @@ -149,6 +154,10 @@ export const DELETE = withRouteHandler( const { id } = await params try { + const currentAccess = await getCredentialActorContext(id, session.user.id) + if (!currentAccess.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } const result = await performDeleteCredential({ credentialId: id, userId: session.user.id, @@ -162,9 +171,11 @@ export const DELETE = withRouteHandler( ? 404 : result.errorCode === 'forbidden' ? 403 - : result.errorCode === 'validation' - ? 400 - : 500 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'validation' + ? 400 + : 500 return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 2e693609438..15fdfcb5d7f 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -35,7 +35,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (credentialId) { const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { + if ( + !access.credential || + access.credential.type === 'managed_oauth' || + access.credential.workspaceId !== workspaceId || + !access.isAdmin + ) { return NextResponse.json( { error: 'Admin access required on the target credential' }, { status: 403 } diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 7e855d2caca..33227c66de0 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' @@ -31,7 +31,9 @@ export const GET = withRouteHandler(async () => { }) .from(credentialMember) .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(eq(credentialMember.userId, session.user.id)) + .where( + and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth')) + ) return NextResponse.json({ memberships }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 51d01be9981..991b76d712a 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, @@ -167,7 +167,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId: credential.providerId, }) .from(credential) - .where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId))) + .where( + and( + eq(credential.id, lookupCredentialId), + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) .limit(1) if (!row) { @@ -182,7 +188,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where( and( eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId) + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth') ) ) .limit(1) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts new file mode 100644 index 00000000000..9c182d4a4a3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { resendCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authorizeCredentialGroupSettings, + CredentialGroupAccessError, +} from '@/lib/credential-groups/access' +import { + CredentialGroupEnrollmentError, + resendCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' +import { enforceCredentialGroupInvitationRateLimit } from '@/lib/credential-groups/rate-limit' + +const logger = createLogger('CredentialGroupEnrollmentResendAPI') + +type RouteContext = { params: Promise<{ id: string; groupId: string; enrollmentId: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(resendCredentialGroupEnrollmentContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const rateLimited = await enforceCredentialGroupInvitationRateLimit(parsed.data.params.id) + if (rateLimited) return rateLimited + const inviterName = session.user.name?.trim() || session.user.email + if (!inviterName) { + return NextResponse.json({ error: 'Inviting user has no display identity' }, { status: 409 }) + } + const credentialGroupEnrollment = await resendCredentialGroupEnrollment( + parsed.data.params.id, + parsed.data.params.groupId, + parsed.data.params.enrollmentId, + session.user.id, + inviterName + ) + return NextResponse.json({ credentialGroupEnrollment }) + } catch (error) { + if ( + error instanceof CredentialGroupAccessError || + error instanceof CredentialGroupEnrollmentError + ) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Failed to resend credential group enrollment', error) + return NextResponse.json( + { error: 'Failed to resend credential group enrollment' }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts new file mode 100644 index 00000000000..b70e7b0ada1 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authorizeCredentialGroupSettings, + CredentialGroupAccessError, +} from '@/lib/credential-groups/access' +import { + CredentialGroupEnrollmentError, + revokeCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' + +const logger = createLogger('CredentialGroupEnrollmentAPI') + +type RouteContext = { params: Promise<{ id: string; groupId: string; enrollmentId: string }> } + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(revokeCredentialGroupEnrollmentContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const credentialGroupEnrollment = await revokeCredentialGroupEnrollment( + parsed.data.params.id, + parsed.data.params.groupId, + parsed.data.params.enrollmentId + ) + return NextResponse.json({ credentialGroupEnrollment }) + } catch (error) { + if ( + error instanceof CredentialGroupAccessError || + error instanceof CredentialGroupEnrollmentError + ) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Failed to revoke credential group enrollment', error) + return NextResponse.json( + { error: 'Failed to revoke credential group enrollment' }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts new file mode 100644 index 00000000000..059ab1e6844 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ + +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthorizeCredentialGroupSettings, + mockEnforceInvitationRateLimit, + mockGetSession, + mockInviteEnrollments, +} = vi.hoisted(() => ({ + mockAuthorizeCredentialGroupSettings: vi.fn(), + mockEnforceInvitationRateLimit: vi.fn(), + mockGetSession: vi.fn(), + mockInviteEnrollments: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/credential-groups/access', () => { + class CredentialGroupAccessError extends Error { + constructor( + message: string, + readonly status: 403 | 404 + ) { + super(message) + } + } + return { + authorizeCredentialGroupSettings: mockAuthorizeCredentialGroupSettings, + CredentialGroupAccessError, + } +}) + +vi.mock('@/lib/credential-groups/enrollments', () => { + class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: 404 | 409 | 502 + ) { + super(message) + } + } + return { + inviteCredentialGroupEnrollments: mockInviteEnrollments, + CredentialGroupEnrollmentError, + } +}) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforceCredentialGroupInvitationRateLimit: mockEnforceInvitationRateLimit, +})) + +import { POST } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const GROUP_ID = 'group-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) } + +function createRequest(body: unknown): NextRequest { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/enrollments`, + { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + } + ) +} + +describe('credential group enrollment invitation route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Taylor', email: 'taylor@example.com' }, + }) + mockAuthorizeCredentialGroupSettings.mockResolvedValue({}) + mockEnforceInvitationRateLimit.mockResolvedValue(null) + mockInviteEnrollments.mockResolvedValue({ + results: [ + { + email: 'alex@example.com', + success: false, + error: 'Delivery failed', + }, + ], + sentCount: 0, + failedCount: 1, + }) + }) + + it('authenticates before parsing the batch', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await POST(createRequest({}), context) + + expect(response.status).toBe(401) + expect(mockAuthorizeCredentialGroupSettings).not.toHaveBeenCalled() + expect(mockInviteEnrollments).not.toHaveBeenCalled() + }) + + it('sends the entire validated batch through the enrollment service', async () => { + const body = { emails: ['alex@example.com', 'sam@example.com'] } + + const response = await POST(createRequest(body), context) + + expect(response.status).toBe(200) + expect(mockInviteEnrollments).toHaveBeenCalledWith( + WORKSPACE_ID, + GROUP_ID, + 'user-1', + 'Taylor', + body + ) + expect(await response.json()).toMatchObject({ sentCount: 0, failedCount: 1 }) + }) + + it('rejects a batch larger than 100 before invoking delivery', async () => { + const response = await POST( + createRequest({ + emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`), + }), + context + ) + + expect(response.status).toBe(400) + expect(mockInviteEnrollments).not.toHaveBeenCalled() + }) + + it('applies the shared workspace invitation rate limit', async () => { + mockEnforceInvitationRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) + ) + + const response = await POST(createRequest({ emails: ['alex@example.com'] }), context) + + expect(response.status).toBe(429) + expect(mockEnforceInvitationRateLimit).toHaveBeenCalledWith(WORKSPACE_ID) + expect(mockInviteEnrollments).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts new file mode 100644 index 00000000000..b6b6de71b03 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { inviteCredentialGroupEnrollmentsContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authorizeCredentialGroupSettings, + CredentialGroupAccessError, +} from '@/lib/credential-groups/access' +import { + CredentialGroupEnrollmentError, + inviteCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' +import { enforceCredentialGroupInvitationRateLimit } from '@/lib/credential-groups/rate-limit' + +const logger = createLogger('CredentialGroupEnrollmentsAPI') + +type RouteContext = { params: Promise<{ id: string; groupId: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(inviteCredentialGroupEnrollmentsContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const rateLimited = await enforceCredentialGroupInvitationRateLimit(parsed.data.params.id) + if (rateLimited) return rateLimited + const inviterName = session.user.name?.trim() || session.user.email + if (!inviterName) { + return NextResponse.json({ error: 'Inviting user has no display identity' }, { status: 409 }) + } + const result = await inviteCredentialGroupEnrollments( + parsed.data.params.id, + parsed.data.params.groupId, + session.user.id, + inviterName, + parsed.data.body + ) + return NextResponse.json(result) + } catch (error) { + if ( + error instanceof CredentialGroupAccessError || + error instanceof CredentialGroupEnrollmentError + ) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Failed to invite credential group enrollments', error) + return NextResponse.json( + { error: 'Failed to invite credential group enrollments' }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts new file mode 100644 index 00000000000..feba4249b48 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -0,0 +1,126 @@ +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + deleteCredentialGroupContract, + getCredentialGroupContract, + updateCredentialGroupContract, +} from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authorizeCredentialGroupSettings, + CredentialGroupAccessError, +} from '@/lib/credential-groups/access' +import { + CredentialGroupEnrollmentError, + listCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { + deleteCredentialGroup, + getCredentialGroup, + updateCredentialGroup, +} from '@/lib/credential-groups/service' + +const logger = createLogger('CredentialGroupAPI') + +type RouteContext = { params: Promise<{ id: string; groupId: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getCredentialGroupContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const credentialGroup = await getCredentialGroup( + parsed.data.params.id, + parsed.data.params.groupId + ) + if (!credentialGroup) { + return NextResponse.json({ error: 'Credential group not found' }, { status: 404 }) + } + const enrollmentPage = await listCredentialGroupEnrollments( + parsed.data.params.id, + parsed.data.params.groupId, + parsed.data.query.limit, + parsed.data.query.cursor + ) + return NextResponse.json({ credentialGroup, ...enrollmentPage }) + } catch (error) { + if ( + error instanceof CredentialGroupAccessError || + error instanceof CredentialGroupEnrollmentError + ) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Failed to get credential group', error) + return NextResponse.json({ error: 'Failed to get credential group' }, { status: 500 }) + } +}) + +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateCredentialGroupContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const updated = await updateCredentialGroup( + parsed.data.params.id, + parsed.data.params.groupId, + parsed.data.body + ) + if (!updated) return NextResponse.json({ error: 'Credential group not found' }, { status: 404 }) + return NextResponse.json({ credentialGroup: updated }) + } catch (error) { + if (error instanceof CredentialGroupAccessError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'A credential group with this name already exists' }, + { status: 409 } + ) + } + if (error instanceof CredentialGroupProviderConfigurationError) { + return NextResponse.json({ error: error.message }, { status: 503 }) + } + logger.error('Failed to update credential group', error) + return NextResponse.json({ error: 'Failed to update credential group' }, { status: 500 }) + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteCredentialGroupContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const deleted = await deleteCredentialGroup(parsed.data.params.id, parsed.data.params.groupId) + if (!deleted) return NextResponse.json({ error: 'Credential group not found' }, { status: 404 }) + return NextResponse.json({ success: true as const }) + } catch (error) { + if (error instanceof CredentialGroupAccessError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Failed to delete credential group', error) + return NextResponse.json({ error: 'Failed to delete credential group' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts new file mode 100644 index 00000000000..41c9e098d84 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts @@ -0,0 +1,36 @@ +import { startSlackCredentialGroupConfigurationContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' + +const errorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => + error instanceof SlackManagedUsersError + ? internalErrorResponse(400, { error: error.message }) + : null +) + +export const POST = defineInternalJsonRoute({ + contract: startSlackCredentialGroupConfigurationContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.startSlackConfiguration, + rateLimit: internalRateLimits.none({ + reason: 'Slack applies provider authorization limits and setup requires a workspace admin', + }), + errorPolicy, + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + slackBotCredentialId: body.slackBotCredentialId, + clientId: body.clientId, + clientSecret: body.clientSecret, + }), + useCase: startSlackCredentialGroupConfiguration, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts new file mode 100644 index 00000000000..89a0522d53c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthorizeCredentialGroupSettings, + mockCreateCredentialGroup, + mockGetSession, + mockListCredentialGroups, +} = vi.hoisted(() => ({ + mockAuthorizeCredentialGroupSettings: vi.fn(), + mockCreateCredentialGroup: vi.fn(), + mockGetSession: vi.fn(), + mockListCredentialGroups: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/credential-groups/access', async () => { + class CredentialGroupAccessError extends Error { + constructor( + message: string, + readonly status: 403 | 404 + ) { + super(message) + this.name = 'CredentialGroupAccessError' + } + } + + return { + authorizeCredentialGroupSettings: mockAuthorizeCredentialGroupSettings, + CredentialGroupAccessError, + } +}) + +vi.mock('@/lib/credential-groups/service', () => ({ + createCredentialGroup: mockCreateCredentialGroup, + listCredentialGroups: mockListCredentialGroups, +})) + +import { CredentialGroupAccessError } from '@/lib/credential-groups/access' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, { + method, + ...(body + ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } } + : {}), + }) +} + +describe('credential groups collection route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockAuthorizeCredentialGroupSettings.mockResolvedValue({}) + mockListCredentialGroups.mockResolvedValue([]) + }) + + it('authenticates before parsing the request body', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await POST(createRequest('POST', {}), context) + + expect(response.status).toBe(401) + expect(mockAuthorizeCredentialGroupSettings).not.toHaveBeenCalled() + }) + + it('returns configured groups to an authorized workspace admin', async () => { + const response = await GET(createRequest('GET'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentialGroups: [] }) + expect(mockAuthorizeCredentialGroupSettings).toHaveBeenCalledWith(WORKSPACE_ID, 'user-1') + }) + + it('hides the surface when the entitlement gate rejects access', async () => { + mockAuthorizeCredentialGroupSettings.mockRejectedValue( + new CredentialGroupAccessError('Credential Groups are not available', 404) + ) + + const response = await GET(createRequest('GET'), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Credential Groups are not available' }) + }) + + it('fails fast when managed Gmail OAuth is not configured', async () => { + mockCreateCredentialGroup.mockRejectedValue( + new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured') + ) + + const response = await POST( + createRequest('POST', { + name: 'Support inboxes', + options: [ + { + provider: 'gmail', + label: 'Gmail', + required: true, + }, + ], + }), + context + ) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: 'Managed Gmail authorization is not configured', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts new file mode 100644 index 00000000000..0a780a22ba4 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + createCredentialGroupContract, + listCredentialGroupsContract, +} from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authorizeCredentialGroupSettings, + CredentialGroupAccessError, +} from '@/lib/credential-groups/access' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { createCredentialGroup, listCredentialGroups } from '@/lib/credential-groups/service' + +const logger = createLogger('CredentialGroupsAPI') + +type RouteContext = { params: Promise<{ id: string }> } + +function accessErrorResponse(error: CredentialGroupAccessError) { + return NextResponse.json({ error: error.message }, { status: error.status }) +} + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listCredentialGroupsContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const credentialGroups = await listCredentialGroups(parsed.data.params.id) + return NextResponse.json({ credentialGroups }) + } catch (error) { + if (error instanceof CredentialGroupAccessError) return accessErrorResponse(error) + logger.error('Failed to list credential groups', error) + return NextResponse.json({ error: 'Failed to list credential groups' }, { status: 500 }) + } +}) + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(createCredentialGroupContract, request, context) + if (!parsed.success) return parsed.response + + try { + await authorizeCredentialGroupSettings(parsed.data.params.id, session.user.id) + const credentialGroup = await createCredentialGroup( + parsed.data.params.id, + session.user.id, + parsed.data.body + ) + return NextResponse.json({ credentialGroup }, { status: 201 }) + } catch (error) { + if (error instanceof CredentialGroupAccessError) return accessErrorResponse(error) + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'A credential group with this name already exists' }, + { status: 409 } + ) + } + if (error instanceof CredentialGroupProviderConfigurationError) { + return NextResponse.json({ error: error.message }, { status: 503 }) + } + logger.error('Failed to create credential group', error) + return NextResponse.json({ error: 'Failed to create credential group' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx new file mode 100644 index 00000000000..fc3bfd1f8d9 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -0,0 +1,15 @@ +'use client' + +import { chipVariants } from '@sim/emcn' + +interface OAuthReconnectLinkProps { + href: string +} + +export function OAuthReconnectLink({ href }: OAuthReconnectLinkProps) { + return ( + + Reconnect + + ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx new file mode 100644 index 00000000000..b311fdaf86e --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockError, mockSetOAuthStatus, mockSuccess } = vi.hoisted(() => ({ + mockError: vi.fn(), + mockSetOAuthStatus: vi.fn().mockResolvedValue(null), + mockSuccess: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + useToast: () => ({ + toast: { + error: mockError, + success: mockSuccess, + }, + }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [{}, mockSetOAuthStatus], +})) + +import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' + +function renderToast(variant: 'success' | 'error', message: string): Root { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + act(() => root.render()) + return root +} + +describe('CredentialGroupOAuthToast', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows OAuth success once and removes callback state from the URL', () => { + const root = renderToast('success', 'Gmail connected successfully.') + + expect(mockSuccess).toHaveBeenCalledOnce() + expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) + + it('uses the error toast and preserves unrelated query parameters', () => { + const root = renderToast('error', 'Authorization was canceled.') + + expect(mockError).toHaveBeenCalledWith('Authorization was canceled.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) + + it('removes the submitted state after showing the completion toast', () => { + const root = renderToast('success', 'Accounts submitted successfully.') + + expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx new file mode 100644 index 00000000000..c3029508020 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useToast } from '@sim/emcn' +import { useQueryStates } from 'nuqs' +import { + credentialGroupEnrollmentStatusParsers, + credentialGroupEnrollmentStatusUrlKeys, +} from '@/app/credential-groups/enroll/[token]/search-params' + +interface CredentialGroupOAuthToastProps { + message: string + variant: 'success' | 'error' +} + +export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupOAuthToastProps) { + const { toast } = useToast() + const [, setOAuthStatus] = useQueryStates( + credentialGroupEnrollmentStatusParsers, + credentialGroupEnrollmentStatusUrlKeys + ) + const shownRef = useRef(false) + + useEffect(() => { + if (shownRef.current) return + shownRef.current = true + + if (variant === 'success') toast.success(message) + else toast.error(message) + + void setOAuthStatus( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + }, [message, setOAuthStatus, toast, variant]) + + return null +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx new file mode 100644 index 00000000000..e18257cf2fc --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -0,0 +1,180 @@ +import { type ReactNode, Suspense } from 'react' +import { Chip, ToastProvider } from '@sim/emcn' +import type { Metadata } from 'next' +import { headers } from 'next/headers' +import { getPublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { SupportFooter } from '@/app/(auth)/components' +import { LogoShell } from '@/app/(landing)/components' +import { OAuthReconnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' +import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +export const metadata: Metadata = { + title: 'Connect accounts', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +interface CredentialGroupEnrollmentPageProps { + params: Promise<{ token: string }> + searchParams: Promise> +} + +interface PageShellProps { + children: ReactNode +} + +function PageShell({ children }: PageShellProps) { + return ( + + }> +
+ {children} +
+
+
+ ) +} + +function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) { + return ( + +
+

+ {rateLimited ? 'Too many requests' : 'Invitation unavailable'} +

+

+ {rateLimited + ? 'This link has been opened too many times. Wait a few minutes and try again.' + : 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'} +

+
+
+ ) +} + +const OAUTH_MESSAGES = { + denied: 'Authorization was canceled. Nothing was connected.', + account_mismatch: 'Choose the account matching the email address on this invitation.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'This credential option changed. Reload the page and try again.', + incomplete: 'Connect every account before submitting.', + unavailable: 'Account authorization is temporarily unavailable. Please try again.', + failed: 'Account authorization did not complete. Please try again.', +} as const + +function getSearchParam( + searchParams: Record, + key: string +): string | undefined { + const value = searchParams[key] + return Array.isArray(value) ? value[0] : value +} + +export default async function CredentialGroupEnrollmentPage({ + params, + searchParams, +}: CredentialGroupEnrollmentPageProps) { + const requestHeaders = await headers() + const limited = await enforcePublicCredentialGroupIpRateLimit( + { headers: requestHeaders }, + 'metadata' + ) + if (limited) return + + const { token } = await params + if (!token || token.length > 128) return + + const enrollment = await getPublicCredentialGroupEnrollment(token) + if (!enrollment) return + + const resolvedSearchParams = await searchParams + const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') + const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') + const submitted = getSearchParam(resolvedSearchParams, 'submitted') + const oauthMessage = + oauthStatus && oauthStatus in OAUTH_MESSAGES + ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] + : null + const activeOptions = enrollment.options.filter((option) => option.status === 'active') + const connectedOption = connectedOptionId + ? activeOptions.find((option) => option.id === connectedOptionId) + : undefined + const notification = submitted + ? { message: 'Accounts submitted successfully.', variant: 'success' as const } + : connectedOptionId + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null + const allConnected = + activeOptions.length > 0 && + activeOptions.every( + (option) => option.connections.length === 1 && option.connections[0]?.status === 'connected' + ) + + return ( + + {notification && ( + + + + )} +
+

+ Connect your accounts +

+

+ {enrollment.inviterName}{' '} + invited you to connect accounts for{' '} + {enrollment.workspaceName}. +

+
+ +
+ +
+ {activeOptions.map((option) => { + const ProviderIcon = getCredentialGroupProviderService(option.provider).icon + const connection = option.connections[0] + return ( + } + title={option.label} + description={connection?.email ?? 'Not connected'} + trailing={ + + } + /> + ) + })} +
+
+ {(allConnected || enrollment.status === 'completed') && ( +
+ + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/search-params.ts b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts new file mode 100644 index 00000000000..0b6411b0423 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts @@ -0,0 +1,13 @@ +import { parseAsString } from 'nuqs/server' + +/** One-shot OAuth result signals are nullable because absence means no toast. */ +export const credentialGroupEnrollmentStatusParsers = { + connected: parseAsString, + oauth: parseAsString, + submitted: parseAsString, +} as const + +export const credentialGroupEnrollmentStatusUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index f3f0a652f3b..1a64d6206a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -14,6 +14,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -48,6 +49,7 @@ const TOP_LEVEL_REDIRECTS: Readonly stri const WORKSPACE_SECTION_MAP: Partial> = { teammates: 'teammates', secrets: 'secrets', + 'credential-groups': 'credential-groups', byok: 'byok', sandboxes: 'sandboxes', 'custom-tools': 'custom-tools', @@ -118,14 +120,16 @@ export default async function WorkspaceSettingsSectionPage({ const workspaceSection = WORKSPACE_SECTION_MAP[parsed] if (workspaceSection) { - const [permissionGroup, forksAvailable, inboxAvailable, sandboxes] = await Promise.all([ - hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise - ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) - : null, - isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id), - hasWorkspaceInboxAccess(workspaceId), - hasWorkspaceSandboxAccess(workspaceId), - ]) + const [permissionGroup, forksAvailable, inboxAvailable, sandboxes, credentialGroupsAvailable] = + await Promise.all([ + hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise + ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) + : null, + isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id), + hasWorkspaceInboxAccess(workspaceId), + hasWorkspaceSandboxAccess(workspaceId), + isCredentialGroupsAvailable(hostContext.ownerBilling), + ]) const customBlocksAvailable = isHosted ? hostContext.ownerBilling.isEnterprise : isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED')) @@ -134,6 +138,7 @@ export default async function WorkspaceSettingsSectionPage({ permissionConfig: permissionGroup?.config ?? {}, entitlements: { byok: isHosted, + credentialGroups: credentialGroupsAvailable, inbox: inboxAvailable, customBlocks: customBlocksAvailable, forks: forksAvailable, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bde90b3f029..3f85cdc3d8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -80,6 +80,30 @@ export const groupIdUrlKeys = { clearOnDefault: true, } as const +/** `credential-group-id` deep-links Credential Groups to one collection's detail view. */ +export const credentialGroupIdParam = { + key: 'credential-group-id', + parser: parseAsString, +} as const + +/** Opening a credential group is a destination; closing replaces the detail URL. */ +export const credentialGroupIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** Active view inside a credential-group detail page. */ +export const credentialGroupTabParam = { + key: 'credential-group-tab', + parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const credentialGroupTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `group-tab` is the active tab inside the deep-linked permission-group detail * view, so a shared `group-id` link can land on the same tab (mirrors diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 632e818dec1..9ba369ef92a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -84,6 +84,9 @@ const AccessControl = dynamic(() => const CustomBlocks = dynamic(() => import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) ) +const CredentialGroups = dynamic(() => + import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings) +) const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) @@ -158,6 +161,9 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'browser' && } {effectiveSection === 'terminal' && } {effectiveSection === 'secrets' && } + {effectiveSection === 'credential-groups' && ( + + )} {effectiveSection === 'access-control' && organizationId && ( { { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'credential-groups', label: 'Credential groups', section: 'workspace' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, @@ -68,6 +69,7 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', + 'credential-groups', 'mcp', 'custom-tools', 'byok', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index bd1cd4d6114..4449886f4a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -253,7 +253,9 @@ export function CredentialSelector({ const comboboxOptions = useMemo(() => { if (isAllCredentials) { - const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth') + const oauthCredentials = allWorkspaceCredentials.filter( + (credential) => credential.type === 'oauth' + ) return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) } if (isMergedKinds) return [] @@ -409,7 +411,9 @@ export function CredentialSelector({ } const matchedCred = ( - isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials + isAllCredentials + ? allWorkspaceCredentials.filter((credential) => credential.type === 'oauth') + : credentials ).find((c) => c.id === value) if (matchedCred) { handleSelect(value) 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 504105afec6..3119c40fe2a 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 @@ -141,6 +141,12 @@ export function SettingsSidebar({ if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) { return false } + if ( + item.id === 'credential-groups' && + (!hostContext.features?.credentialGroups || !canAdminWorkspace) + ) { + return false + } if (item.selfHostedOverride && !isHosted) { /** diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts new file mode 100644 index 00000000000..5b1fbd3e7cc --- /dev/null +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -0,0 +1,365 @@ +import { GridOffset } from '@sim/emcn/icons' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { + type CanonicalGroup, + resolveActiveCanonicalValue, +} from '@/lib/workflows/subblocks/visibility' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { BlockConfig } from '@/blocks/types' +import { + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const CREDENTIAL_GROUP_CANONICAL_GROUP = { + canonicalId: 'credentialGroupId', + basicId: 'credentialGroup', + advancedIds: ['manualCredentialGroup'], +} as const satisfies CanonicalGroup + +async function fetchCachedCredentialGroups() { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + return getQueryClient().fetchQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +function resolveCredentialGroupIdForBlock(blockId: string): string | null { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return null + const values = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {} + const canonicalModes = useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes + const value = resolveActiveCanonicalValue( + CREDENTIAL_GROUP_CANONICAL_GROUP, + values, + canonicalModes + ) + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +interface CredentialGroupBlockOutput { + success: boolean + output: { + credentials: Array<{ + credentialId: string + email: string + displayName: string + providerId: string + providerSubjectId: string + providerTenantId: string | null + }> + credentialGroups: Array<{ + id: string + name: string + description: string | null + status: 'active' | 'disabled' + providerIds: string[] + createdAt: string + updatedAt: string + }> + people: Array<{ + id: string + email: string + status: string + expired: boolean + invitedAt: string + connections: Array<{ provider: string; status: string; count: number }> + }> + enrollmentId: string + email: string + status: string + invitedAt: string + expiresAt: string + count: number + hasMore: boolean + nextCursor: string | null + } +} + +const GROUP_OPERATIONS = ['list_credentials', 'send_invite', 'list_people'] as const +const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const + +export const CredentialGroupBlock: BlockConfig = { + type: 'credential_group', + name: 'Credential Groups', + description: 'Invite people and use credentials collected by Credential Groups', + longDescription: + 'List usable managed credentials, inspect invited people, send an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', + bestPractices: ` + - Use "List Credentials" with a ForEach loop to run a provider block once for every connected account. + - Filter by email to select credentials belonging to one invited person, by provider to select one account type, or by both for an exact match. + - Continue with nextCursor until hasMore is false when a list operation returns multiple pages. + - "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded. + - Use "List People" to inspect invitation and connection progress without exposing credential secrets. + - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. + `, + docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group', + bgColor: '#7C3AED', + icon: GridOffset, + canvasPresentation: { + defaultTitle: 'Credential Groups', + sentences: { + byOperation: { + list_credentials: [ + { + text: 'List credentials from', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', for', field: 'email' }, + { text: ', from', field: ['providerFilter', 'manualProviderIds'] }, + { text: ', up to', field: 'limit', after: 'credentials' }, + ], + send_invite: [ + { text: 'Invite', field: 'email', core: true }, + { + text: 'to', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + ], + list_people: [ + { + text: 'List people in', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', matching', field: 'email' }, + { text: ', with status', field: 'peopleStatuses' }, + ], + list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }], + }, + }, + }, + category: 'blocks', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Credentials', id: 'list_credentials' }, + { label: 'Send Invite', id: 'send_invite' }, + { label: 'List People', id: 'list_people' }, + { label: 'List Credential Groups', id: 'list_groups' }, + ], + value: () => 'list_credentials', + }, + { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + options: [], + required: { field: 'operation', value: [...GROUP_OPERATIONS] }, + mode: 'basic', + canonicalParamId: 'credentialGroupId', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + fetchOptions: async () => { + const groups = await fetchCachedCredentialGroups() + return groups + .filter((group) => group.status === 'active') + .map((group) => ({ label: group.name, id: group.id })) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + fetchOptionById: async (_blockId: string, optionId: string) => { + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === optionId) + return group ? { label: group.name, id: group.id } : null + }, + }, + { + id: 'manualCredentialGroup', + title: 'Credential Group ID', + type: 'short-input', + required: { field: 'operation', value: [...GROUP_OPERATIONS] }, + mode: 'advanced', + placeholder: 'Enter credential group ID', + canonicalParamId: 'credentialGroupId', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + }, + { + id: 'email', + title: 'Email', + type: 'short-input', + required: { field: 'operation', value: 'send_invite' }, + placeholder: 'person@example.com', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + }, + { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + multiSelect: true, + emptyIsValid: true, + options: [], + required: false, + mode: 'basic', + canonicalParamId: 'credentialProviderIds', + dependsOn: ['credentialGroupId'], + condition: { field: 'operation', value: 'list_credentials' }, + fetchOptions: async (blockId: string) => { + const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) + if (!credentialGroupId) return [] + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === credentialGroupId) + if (!group) return [] + return group.options + .filter((option) => option.status === 'active') + .map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + fetchOptionById: async (blockId: string, optionId: string) => { + const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) + if (!credentialGroupId) return null + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === credentialGroupId) + const option = group?.options.find( + (candidate) => + candidate.status === 'active' && + getCredentialGroupProviderService(candidate.provider).providerId === optionId + ) + if (!option) return null + return { + id: optionId, + label: getCredentialGroupProviderService(option.provider).name, + } + }, + }, + { + id: 'manualProviderIds', + title: 'Provider IDs', + type: 'short-input', + required: false, + mode: 'advanced', + canonicalParamId: 'credentialProviderIds', + dependsOn: ['credentialGroupId'], + placeholder: '["google-email", "slack"] — leave empty for all providers', + condition: { field: 'operation', value: 'list_credentials' }, + }, + { + id: 'peopleStatuses', + title: 'Status', + type: 'dropdown', + multiSelect: true, + emptyIsValid: true, + options: [ + { label: 'Invited', id: 'invited' }, + { label: 'Delivery failed', id: 'delivery_failed' }, + { label: 'In progress', id: 'in_progress' }, + { label: 'Connected', id: 'completed' }, + { label: 'Revoked', id: 'revoked' }, + ], + condition: { field: 'operation', value: 'list_people' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + value: () => '100', + mode: 'advanced', + placeholder: '1-100', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + mode: 'advanced', + placeholder: 'nextCursor from a previous page', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + ], + tools: { access: [] }, + inputs: { + operation: { + type: 'string', + description: "'list_credentials', 'send_invite', 'list_people', or 'list_groups'", + }, + credentialGroupId: { type: 'string', description: 'Credential Group ID' }, + email: { + type: 'string', + description: 'Recipient email for invites or exact email filter for list operations', + }, + credentialProviderIds: { + type: 'json', + description: 'Optional OAuth provider IDs to include when listing credentials', + }, + peopleStatuses: { + type: 'json', + description: 'Optional invitation statuses to include when listing people', + }, + limit: { type: 'number', description: 'Maximum results per page (1-100)' }, + cursor: { type: 'string', description: 'nextCursor from a previous page' }, + }, + outputs: { + credentials: { + type: 'json', + description: + 'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)', + condition: { field: 'operation', value: 'list_credentials' }, + }, + credentialGroups: { + type: 'json', + description: + 'Credential Group summaries (id, name, description, status, providerIds, createdAt, updatedAt)', + condition: { field: 'operation', value: 'list_groups' }, + }, + people: { + type: 'json', + description: + 'Invited people and current connection summaries (id, email, status, expired, invitedAt, connections)', + condition: { field: 'operation', value: 'list_people' }, + }, + enrollmentId: { + type: 'string', + description: 'Enrollment ID created or refreshed by the invitation', + condition: { field: 'operation', value: 'send_invite' }, + }, + email: { + type: 'string', + description: 'Normalized invitation recipient email', + condition: { field: 'operation', value: 'send_invite' }, + }, + status: { + type: 'string', + description: 'Invitation status', + condition: { field: 'operation', value: 'send_invite' }, + }, + invitedAt: { + type: 'string', + description: 'Invitation timestamp', + condition: { field: 'operation', value: 'send_invite' }, + }, + expiresAt: { + type: 'string', + description: 'Invitation expiration timestamp', + condition: { field: 'operation', value: 'send_invite' }, + }, + count: { + type: 'number', + description: 'Number of records returned', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + hasMore: { + type: 'boolean', + description: 'Whether another page is available', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + nextCursor: { + type: 'string', + description: 'Cursor for the next page, or null on the last page', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + }, +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 733cf047f69..d12e0beade3 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -46,6 +46,7 @@ import { ConfluenceBlock, ConfluenceBlockMeta, ConfluenceV2Block } from '@/block import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_dev' import { ConvexBlock, ConvexBlockMeta } from '@/blocks/blocks/convex' import { CredentialBlock } from '@/blocks/blocks/credential' +import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike' import { CursorBlock, CursorBlockMeta, CursorV2Block } from '@/blocks/blocks/cursor' import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' @@ -404,6 +405,7 @@ export const BLOCK_REGISTRY: Record = { context_dev: ContextDevBlock, convex: ConvexBlock, credential: CredentialBlock, + credential_group: CredentialGroupBlock, crowdstrike: CrowdStrikeBlock, cursor: CursorBlock, cursor_v2: CursorV2Block, diff --git a/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx new file mode 100644 index 00000000000..1e2383a9579 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx @@ -0,0 +1,50 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface CredentialGroupInvitationEmailProps { + recipientEmail: string + inviterName: string + workspaceName: string + credentialGroupName: string + invitationLink: string +} + +export function CredentialGroupInvitationEmail({ + recipientEmail, + inviterName, + workspaceName, + credentialGroupName, + invitationLink, +}: CredentialGroupInvitationEmailProps) { + const brand = getBrandConfig() + + return ( + + Hello, + + {inviterName} invited {recipientEmail} to connect accounts + for {credentialGroupName} in the {workspaceName} workspace + on {brand.name}. + + + + Connect Accounts + + +
+ + + This private link expires in 7 days. {brand.name} will send you to each provider to sign in + and will never ask for your provider password. If you did not expect this invitation, you + can ignore it. + + + ) +} + +export default CredentialGroupInvitationEmail diff --git a/apps/sim/components/emails/credential-groups/index.ts b/apps/sim/components/emails/credential-groups/index.ts new file mode 100644 index 00000000000..fec4e2d1428 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/index.ts @@ -0,0 +1 @@ +export { CredentialGroupInvitationEmail } from './credential-group-invitation-email' diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index a3c8bc50a93..b0626721be7 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -6,6 +6,8 @@ export * from './auth' export * from './billing' // Shared components export * from './components' +// Credential group emails +export * from './credential-groups' // Invitation emails export * from './invitations' // Operational notification emails diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 83d285fe17f..272ea46b3eb 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -19,6 +19,7 @@ import { UsageLimitReachedEmail, UsageThresholdEmail, } from '@/components/emails/billing' +import { CredentialGroupInvitationEmail } from '@/components/emails/credential-groups' import { BatchInvitationEmail, InvitationEmail, @@ -252,6 +253,16 @@ export async function renderWorkspaceInvitationEmail( ) } +export async function renderCredentialGroupInvitationEmail(params: { + recipientEmail: string + inviterName: string + workspaceName: string + credentialGroupName: string + invitationLink: string +}): Promise { + return await render(CredentialGroupInvitationEmail(params)) +} + export async function renderWorkspaceAddedEmail( inviterName: string, workspaceName: string, diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index d23e5bab48e..6a66e3ed75a 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -50,6 +50,7 @@ describe('settings navigation boundaries', () => { 'teammates', 'organization', 'secrets', + 'credential-groups', 'custom-tools', 'mcp', 'apikeys', @@ -99,6 +100,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'forks', + 'credential-groups', 'custom-blocks', 'self-host', ]) @@ -114,6 +116,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -139,6 +142,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -158,6 +162,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -450,6 +455,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -474,6 +480,7 @@ describe('settings navigation boundaries', () => { }, entitlements: { byok: true, + credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -488,6 +495,7 @@ describe('settings navigation boundaries', () => { 'workflow-mcp-servers', 'recently-deleted', 'forks', + 'credential-groups', 'custom-blocks', 'self-host', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 514dc2bd7d6..d7707e37bcb 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -5,6 +5,7 @@ import { Credit, Database, Globe, + GridOffset, HexSimple, Key, KeySquare, @@ -64,6 +65,7 @@ export type OrganizationSettingsSection = export type WorkspaceSettingsSection = | 'teammates' | 'secrets' + | 'credential-groups' | 'byok' | 'sandboxes' | 'custom-tools' @@ -99,6 +101,7 @@ export type UnifiedSettingsSection = | 'browser' | 'terminal' | 'secrets' + | 'credential-groups' | 'access-control' | 'custom-blocks' | 'audit-logs' @@ -527,6 +530,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] workspace: { id: 'secrets', group: 'workspace', order: 1 }, }, }, + { + label: 'Credential groups', + icon: GridOffset, + unified: { + id: 'credential-groups', + description: 'Collect and manage OAuth credentials for people outside this workspace.', + group: 'workspace', + order: 2, + requiresEnterprise: true, + allowNonOrgAdmin: true, + selfHostedOverride: true, + }, + planes: { + workspace: { id: 'credential-groups', group: 'enterprise', order: 10 }, + }, + }, { label: 'Custom tools', icon: Wrench, @@ -948,6 +967,7 @@ export interface WorkspacePermissionConfig { export interface WorkspaceSettingsEntitlements { byok: boolean + credentialGroups: boolean customBlocks: boolean forks: boolean inbox: boolean @@ -982,6 +1002,7 @@ export interface ResolvedWorkspaceNavigationItem const WORKSPACE_MUTATION_PERMISSION: Record = { teammates: 'admin', secrets: 'write', + 'credential-groups': 'admin', byok: 'admin', sandboxes: 'admin', 'custom-tools': 'write', @@ -1021,6 +1042,12 @@ export function resolveWorkspaceNavigation({ 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 === 'credential-groups' && + (permission !== 'admin' || !entitlements.credentialGroups) + ) { + return [] + } if (item.id === 'byok' && !entitlements.byok) return [] if (item.id === 'custom-blocks' && !entitlements.customBlocks) return [] // Absent on Sim Cloud, where the managed service owns these settings. diff --git a/apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx new file mode 100644 index 00000000000..cf2666e46b6 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-create-modal.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useCreateCredentialGroup } from '@/hooks/queries/credential-groups' + +interface CredentialGroupCreateModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (groupId: string) => void + workspaceId: string +} + +export function CredentialGroupCreateModal({ + open, + onOpenChange, + onCreated, + workspaceId, +}: CredentialGroupCreateModalProps) { + const createGroup = useCreateCredentialGroup() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + + const reset = () => { + setName('') + setDescription('') + createGroup.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (createGroup.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleCreate = async () => { + if (!name.trim() || createGroup.isPending) return + try { + const result = await createGroup.mutateAsync({ + workspaceId, + body: { + name: name.trim(), + description: description.trim() || undefined, + options: [], + }, + }) + onCreated(result.credentialGroup.id) + handleOpenChange(false) + } catch { + return + } + } + + return ( + + handleOpenChange(false)}> + Create credential group + + + + + + {createGroup.error ? getErrorMessage(createGroup.error) : null} + + + handleOpenChange(false)} + cancelDisabled={createGroup.isPending} + primaryAction={{ + label: createGroup.isPending ? 'Creating...' : 'Create', + onClick: handleCreate, + disabled: !name.trim() || createGroup.isPending, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx new file mode 100644 index 00000000000..7cef2e25e6f --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' +import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' +import type { + CredentialGroupEnrollment, + CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentDetail, +} from '@/lib/api/contracts/credential-groups' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { + credentialGroupTabParam, + credentialGroupTabUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' +import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' +import { + useCredentialGroupDetail, + useResendCredentialGroupEnrollment, + useRevokeCredentialGroupEnrollment, +} from '@/hooks/queries/credential-groups' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +interface CredentialGroupDetailProps { + workspaceId: string + groupId: string + onBack: () => void +} + +type CredentialGroupTab = 'details' | 'people' + +const CREDENTIAL_GROUP_TABS = [ + { value: 'details', label: 'Details' }, + { value: 'people', label: 'People' }, +] as const + +function getEnrollmentStatus( + enrollment: CredentialGroupEnrollmentDetail, + activeProviders: CredentialGroupProvider[] +) { + if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false } + if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true } + const needsReauthorization = enrollment.connections.some( + (connection) => connection.status === 'needs_reauth' + ) + if (enrollment.expired) return { label: 'Expired', invalid: true } + if (needsReauthorization) return { label: 'Reconnect needed', invalid: false } + const connectedProviders = new Set( + enrollment.connections + .filter((connection) => connection.status === 'active') + .map((connection) => connection.provider) + ) + const allProvidersConnected = + activeProviders.length > 0 && + activeProviders.every((provider) => connectedProviders.has(provider)) + if (enrollment.status === 'completed' && allProvidersConnected) { + return { label: 'Connected', invalid: false } + } + if (enrollment.status === 'completed') return { label: 'In progress', invalid: false } + if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false } + return { label: 'Invited', invalid: false } +} + +interface EnrollmentConnectionsProps { + connections: CredentialGroupEnrollmentConnection[] +} + +interface CredentialProviderIconProps { + provider: CredentialGroupProvider +} + +function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { + const ProviderIcon = getCredentialGroupProviderService(provider).icon + return +} + +function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { + const connected = connections.filter((connection) => connection.status === 'active') + const count = connected.reduce((total, connection) => total + connection.count, 0) + const providers = [...new Set(connected.map((connection) => connection.provider))] + + return ( + + {providers.map((provider) => { + return + })} + + {count} connected {count === 1 ? 'account' : 'accounts'} + + + ) +} + +export function CredentialGroupDetail({ + workspaceId, + groupId, + onBack, +}: CredentialGroupDetailProps) { + const detail = useCredentialGroupDetail(workspaceId, groupId) + const slackBots = useWorkspaceCredentials({ + workspaceId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + }) + const resend = useResendCredentialGroupEnrollment() + const revoke = useRevokeCredentialGroupEnrollment() + const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const [showInvite, setShowInvite] = useState(false) + const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const credentialGroup = detail.data?.pages[0]?.credentialGroup + const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] + const revokingEnrollment = revokingEnrollmentId + ? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null) + : null + const activeProviders = + credentialGroup?.options + .filter((option) => option.status === 'active') + .map((option) => option.provider) ?? [] + const configurationReady = + Boolean(credentialGroup?.options.length) && + credentialGroup?.options.every( + (option) => + option.provider !== 'slack' || + (option.configurationStatus === 'ready' && + slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) + ) + + const actions: SettingsAction[] = credentialGroup + ? [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary', + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ] + : [] + + const handleResend = async (enrollment: CredentialGroupEnrollment) => { + try { + await resend.mutateAsync({ workspaceId, groupId, enrollmentId: enrollment.id }) + toast.success(`Invitation resent to ${enrollment.email}`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to resend invitation')) + } + } + + const handleRevoke = async () => { + if (!revokingEnrollment) return + try { + await revoke.mutateAsync({ + workspaceId, + groupId, + enrollmentId: revokingEnrollment.id, + }) + toast.success(`Invitation revoked for ${revokingEnrollment.email}`) + setRevokingEnrollmentId(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to revoke invitation')) + } + } + + const handleBack = () => { + void setActiveTab(null, { history: 'replace' }) + onBack() + } + + return ( + <> + + {detail.error ? ( + + {getErrorMessage(detail.error, "Couldn't load credential group")} + + ) : detail.isPending || !credentialGroup ? null : ( +
+ void setActiveTab(value as CredentialGroupTab)} + aria-label='Credential group sections' + /> + + {activeTab === 'details' && ( + + )} + + {activeTab === 'people' && ( + void detail.fetchNextPage()} + disabled={detail.isFetchingNextPage} + > + {detail.isFetchingNextPage ? 'Loading...' : 'Load more'} + + ) : undefined + } + > + {enrollments.length === 0 ? ( + No people invited yet + ) : ( +
+ {enrollments.map((enrollment) => { + const status = getEnrollmentStatus(enrollment, activeProviders) + return ( + } + title={enrollment.email} + description={ + + } + badge={ + + {status.label} + + } + trailing={ + enrollment.status === 'revoked' ? undefined : ( + void handleResend(enrollment), + disabled: resend.isPending, + }, + { + label: 'Revoke', + destructive: true, + onSelect: () => setRevokingEnrollmentId(enrollment.id), + }, + ]} + /> + ) + } + /> + ) + })} +
+ )} +
+ )} +
+ )} +
+ {credentialGroup && ( + + )} + !open && !revoke.isPending && setRevokingEnrollmentId(null)} + srTitle='Revoke invitation' + title='Revoke invitation?' + text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`} + dismissLabel='Cancel' + confirm={{ + label: revoke.isPending ? 'Revoking...' : 'Revoke', + onClick: handleRevoke, + disabled: revoke.isPending, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx new file mode 100644 index 00000000000..501f0a7b366 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { + CredentialGroup, + CredentialGroupOption, + UpdateCredentialGroupBody, +} from '@/lib/api/contracts/credential-groups' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + type CredentialGroupProvider, + type CredentialGroupStandardOAuthProvider, + getCredentialGroupProviderService, + getCredentialGroupProviderSupport, + isCredentialGroupStandardOAuthProvider, +} from '@/lib/credential-groups/providers' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { SettingRow } from '@/ee/components/setting-row' +import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' +import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +interface CredentialGroupDetailsProps { + credentialGroup: CredentialGroup + workspaceId: string +} + +function toOptionUpdateInput( + option: CredentialGroupOption +): NonNullable[number] { + const common = { + id: option.id, + label: getCredentialGroupProviderService(option.provider).name, + required: true, + } + if (option.provider !== 'slack') return { ...common, provider: option.provider } + return { + ...common, + provider: 'slack', + slackBotCredentialId: option.slackBotCredentialId, + } +} + +export function CredentialGroupDetails({ + credentialGroup, + workspaceId, +}: CredentialGroupDetailsProps) { + const updateGroup = useUpdateCredentialGroup() + const slackBots = useWorkspaceCredentials({ + workspaceId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + }) + const [name, setName] = useState(credentialGroup.name) + const [description, setDescription] = useState(credentialGroup.description ?? '') + const [slackSetupOpen, setSlackSetupOpen] = useState(false) + const [slackSetupCredentialId, setSlackSetupCredentialId] = useState() + const [removingProvider, setRemovingProvider] = useState(null) + + const normalizedDescription = description.trim() || null + const detailsDirty = + name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description + const isUpdating = updateGroup.isPending + + const updateOptions = async ( + options: NonNullable, + successMessage: string + ) => { + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { options }, + }) + toast.success(successMessage) + return true + } catch (error) { + toast.error(getErrorMessage(error, 'Could not update account collection')) + return false + } + } + + const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { + const service = getCredentialGroupProviderService(provider) + const existing = credentialGroup.options.map(toOptionUpdateInput) + const nextOption: NonNullable[number] = { + provider, + label: service.name, + required: true, + } + return updateOptions([...existing, nextOption], `${service.name} added`) + } + + const openSlackSetup = (credentialId?: string) => { + setSlackSetupCredentialId(credentialId) + setSlackSetupOpen(true) + } + + const handleProviderAction = (provider: CredentialGroupProvider) => { + const support = getCredentialGroupProviderSupport(provider) + if (isCredentialGroupStandardOAuthProvider(provider)) { + void addProvider(provider) + return + } + if (support.configuration === 'slack_custom_bot') { + openSlackSetup() + return + } + throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) + } + + const handleSaveDetails = async () => { + if (!detailsDirty || !name.trim() || isUpdating) return + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { name: name.trim(), description: normalizedDescription }, + }) + toast.success('Details saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not save details')) + } + } + + const handleRemoveProvider = async () => { + if (!removingProvider) return + const service = getCredentialGroupProviderService(removingProvider) + const options = credentialGroup.options + .filter((option) => option.provider !== removingProvider) + .map(toOptionUpdateInput) + if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) + } + + return ( + <> +
+ void handleSaveDetails()} + disabled={!name.trim() || isUpdating} + > + {isUpdating ? 'Saving...' : 'Save changes'} + + ) : undefined + } + > +
+ + setName(event.target.value)} + error={!name.trim()} + /> + + + setDescription(event.target.value)} + placeholder='What these accounts will be used for' + rows={3} + /> + +
+
+ + +
+ {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + const service = getCredentialGroupProviderService(provider) + const support = getCredentialGroupProviderSupport(provider) + const option = credentialGroup.options.find( + (candidate) => candidate.provider === provider + ) + const ProviderIcon = service.icon + const slackBot = + provider === 'slack' && option?.provider === 'slack' + ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) + : undefined + const slackNeedsSetup = + provider === 'slack' && + option?.provider === 'slack' && + (!slackBot || option.configurationStatus !== 'ready') + const descriptionText = + provider === 'slack' && option + ? slackBot + ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` + : slackBots.isPending + ? 'Loading custom Slack app...' + : 'Custom Slack app unavailable' + : support.description + + return ( + } + title={service.name} + description={descriptionText} + badge={ + option && !slackNeedsSetup ? ( + Connected + ) : undefined + } + trailing={ + option ? ( +
+ {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( + openSlackSetup(slackBot.id)} disabled={isUpdating}> + Continue setup + + ) : null} + + openSlackSetup( + option?.provider === 'slack' + ? option.slackBotCredentialId + : undefined + ), + disabled: isUpdating, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingProvider(provider), + disabled: isUpdating, + }, + ]} + /> +
+ ) : ( + handleProviderAction(provider)} + disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} + > + {support.configuration === 'oauth' ? 'Add' : 'Set up'} + + ) + } + /> + ) + })} +
+
+
+ + { + setSlackSetupOpen(nextOpen) + if (!nextOpen) setSlackSetupCredentialId(undefined) + }} + bots={slackBots.data ?? []} + isLoading={slackBots.isPending} + error={slackBots.error} + initialCredentialId={slackSetupCredentialId} + /> + + !open && !isUpdating && setRemovingProvider(null)} + srTitle='Remove account type' + title={`Remove ${ + removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account' + }`} + text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.' + dismissLabel='Cancel' + confirm={{ + label: isUpdating ? 'Removing...' : 'Remove', + onClick: handleRemoveProvider, + disabled: isUpdating, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx new file mode 100644 index 00000000000..17d0bdf0682 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useCallback, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { useInviteCredentialGroupEnrollments } from '@/hooks/queries/credential-groups' + +interface CredentialGroupInviteModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + groupId: string +} + +export function CredentialGroupInviteModal({ + open, + onOpenChange, + workspaceId, + groupId, +}: CredentialGroupInviteModalProps) { + const invite = useInviteCredentialGroupEnrollments() + const [emails, setEmails] = useState([]) + const [deliveryError, setDeliveryError] = useState(null) + const canSubmit = emails.length > 0 && !invite.isPending + + const validateEmail = useCallback((email: string): string | null => { + const result = quickValidateEmail(email) + return result.isValid ? null : (result.reason ?? 'Invalid email') + }, []) + + const handleEmailsChange = useCallback((next: string[]) => { + setEmails(next) + setDeliveryError(null) + }, []) + + const handleOpenChange = (nextOpen: boolean) => { + if (invite.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) { + setEmails([]) + setDeliveryError(null) + invite.reset() + } + } + + const handleSubmit = async () => { + if (!canSubmit) return + setDeliveryError(null) + try { + const result = await invite.mutateAsync({ + workspaceId, + groupId, + body: { emails }, + }) + const failures = result.results.filter((item) => !item.success) + if (failures.length === 0) { + toast.success( + result.sentCount === 1 ? 'Invitation sent' : `${result.sentCount} invitations sent` + ) + handleOpenChange(false) + return + } + + setEmails(failures.map((item) => item.email)) + setDeliveryError( + result.sentCount > 0 + ? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => item.email).join(', ')}` + : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` + ) + } catch (error) { + setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } + } + + return ( + + handleOpenChange(false)}>Invite users + + + + {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + + + handleOpenChange(false)} + cancelDisabled={invite.isPending} + primaryAction={{ + label: invite.isPending ? 'Sending...' : 'Send invites', + onClick: handleSubmit, + disabled: !canSubmit, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx new file mode 100644 index 00000000000..a8ea91b9482 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipTag } from '@sim/emcn' +import { GridOffset, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' +import { + credentialGroupIdParam, + credentialGroupIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { CredentialGroupCreateModal } from '@/ee/credential-groups/components/credential-group-create-modal' +import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail' +import { useCredentialGroups, useDeleteCredentialGroup } from '@/hooks/queries/credential-groups' + +interface CredentialGroupsSettingsProps { + workspaceId: string +} + +export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) { + const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId) + const deleteGroup = useDeleteCredentialGroup() + const [search, setSearch] = useSettingsSearch() + const [showCreate, setShowCreate] = useState(false) + const [deletingGroupId, setDeletingGroupId] = useState(null) + const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { + ...credentialGroupIdParam.parser, + ...credentialGroupIdUrlKeys, + }) + const deletingGroup = groups.find((group) => group.id === deletingGroupId) + const selectedGroup = selectedGroupId + ? groups.find((group) => group.id === selectedGroupId) + : undefined + + const query = search.trim().toLowerCase() + const filtered = query + ? groups.filter((group) => + [group.name, group.description ?? '', ...group.options.map((option) => option.label)].some( + (value) => value.toLowerCase().includes(query) + ) + ) + : groups + + const actions: SettingsAction[] = [ + { + text: 'Create group', + icon: Plus, + variant: 'primary', + onSelect: () => setShowCreate(true), + }, + ] + + const handleDelete = async () => { + if (!deletingGroupId) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) + setDeletingGroupId(null) + } catch { + return + } + } + + if (selectedGroup) { + return ( + void setSelectedGroupId(null, { history: 'replace' })} + /> + ) + } + + return ( + <> + + {error ? ( + + {getErrorMessage(error, "Couldn't load credential groups")} + + ) : isPending ? null : groups.length === 0 ? ( + Click "Create group" above to get started + ) : filtered.length === 0 ? ( + No groups match "{search}" + ) : ( +
+ {filtered.map((group) => { + const optionCount = group.options.length + return ( + } + title={group.name} + description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} + onClick={() => void setSelectedGroupId(group.id)} + clickLabel={`Open ${group.name}`} + navigable + badge={ + group.status === 'disabled' ? ( + Disabled + ) : undefined + } + trailing={ + setDeletingGroupId(group.id), + }, + ]} + /> + } + /> + ) + })} +
+ )} +
+ void setSelectedGroupId(groupId)} + workspaceId={workspaceId} + /> + !open && !deleteGroup.isPending && setDeletingGroupId(null)} + srTitle='Delete credential group' + title='Delete credential group' + text={[ + `Delete ${deletingGroup?.name ?? 'this credential group'}?`, + { text: ' This cannot be undone.', error: true }, + ]} + dismissLabel='Cancel' + confirm={{ + label: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onClick: handleDelete, + disabled: deleteGroup.isPending, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/index.ts b/apps/sim/ee/credential-groups/components/index.ts new file mode 100644 index 00000000000..d8b5132e3d7 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/index.ts @@ -0,0 +1 @@ +export { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings' diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx new file mode 100644 index 00000000000..14699605435 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + Skeleton, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryClient } from '@tanstack/react-query' +import { SlackIcon } from '@/components/icons' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups' +import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' + +const CHANNEL_NAME = 'slack-managed-users' +const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000 + +interface SlackManagedUsersModalProps { + bots: WorkspaceCredential[] + credentialGroupId: string + error: Error | null + initialCredentialId?: string + isLoading: boolean + onOpenChange: (open: boolean) => void + open: boolean + workspaceId: string +} + +interface SlackManagedUsersMessage { + type: typeof CHANNEL_NAME + ok: boolean + state?: string + credentialGroupId?: string + slackBotCredentialId?: string +} + +function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage { + if (!value || typeof value !== 'object') return false + const message = value as Record + return ( + message.type === CHANNEL_NAME && + typeof message.ok === 'boolean' && + (message.state === undefined || typeof message.state === 'string') && + (message.credentialGroupId === undefined || typeof message.credentialGroupId === 'string') && + (message.slackBotCredentialId === undefined || typeof message.slackBotCredentialId === 'string') + ) +} + +export function SlackManagedUsersModal({ + bots, + credentialGroupId, + error, + initialCredentialId, + isLoading, + onOpenChange, + open, + workspaceId, +}: SlackManagedUsersModalProps) { + const queryClient = useQueryClient() + const startAuthorization = useStartSlackCredentialGroupConfiguration() + const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [pending, setPending] = useState(false) + const expectedState = useRef(null) + const expectedCredentialId = useRef(null) + const popup = useRef(null) + const popupWatcher = useRef(null) + + const defaultCredentialId = initialCredentialId + ? bots.some((bot) => bot.id === initialCredentialId) + ? initialCredentialId + : '' + : bots.length === 1 + ? bots[0].id + : '' + const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId + const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + + useEffect(() => { + if (!open) return + const channel = new BroadcastChannel(CHANNEL_NAME) + channel.onmessage = (event: MessageEvent) => { + if (!isSlackManagedUsersMessage(event.data)) return + if (!expectedState.current || event.data.state !== expectedState.current) return + const verifiedCredentialId = expectedCredentialId.current + expectedState.current = null + expectedCredentialId.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + popup.current?.close() + popup.current = null + setPending(false) + if ( + !event.data.ok || + event.data.credentialGroupId !== credentialGroupId || + !verifiedCredentialId || + event.data.slackBotCredentialId !== verifiedCredentialId + ) { + toast.error('Slack app verification failed. Please try again.') + return + } + if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + toast.error('The verified Slack app is no longer available.') + return + } + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), + }) + toast.success('Slack configured') + onOpenChange(false) + reset() + } + return () => channel.close() + }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + + useEffect( + () => () => { + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popup.current?.close() + }, + [] + ) + + const reset = () => { + popup.current?.close() + popup.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + expectedState.current = null + expectedCredentialId.current = null + setSelectedCredentialId(null) + setClientId('') + setClientSecret('') + setPending(false) + startAuthorization.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (pending && !nextOpen) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleSelectBot = (credentialId: string) => { + if (pending) return + setSelectedCredentialId(credentialId) + setClientId('') + setClientSecret('') + startAuthorization.reset() + } + + const handleSubmit = async () => { + if (!selectedBot || pending) return + if (!clientId.trim() || !clientSecret.trim()) return + + const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760') + if (!opened) { + toast.error('Allow popups to verify the Slack app') + return + } + popup.current = opened + setPending(true) + try { + const result = await startAuthorization.mutateAsync({ + workspaceId, + credentialGroupId, + body: { + slackBotCredentialId: selectedBot.id, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }, + }) + expectedState.current = result.state + expectedCredentialId.current = selectedBot.id + opened.location.href = result.authorizationUrl + const startedAt = Date.now() + popupWatcher.current = window.setInterval(() => { + if (!opened.closed && Date.now() - startedAt < AUTHORIZATION_TIMEOUT_MS) return + window.clearInterval(popupWatcher.current ?? undefined) + popupWatcher.current = null + opened.close() + popup.current = null + expectedState.current = null + expectedCredentialId.current = null + setPending(false) + toast.error('Slack authorization expired. Please try again.') + }, 500) + } catch (authorizationError) { + opened.close() + popup.current = null + setPending(false) + toast.error(getErrorMessage(authorizationError, 'Could not start Slack authorization')) + } + } + + const noBots = !isLoading && bots.length === 0 + const primaryLabel = isLoading + ? 'Loading...' + : noBots + ? 'Add Slack' + : pending + ? 'Waiting for Slack...' + : 'Verify and add' + const primaryDisabled = + isLoading || noBots || !selectedBot || pending || !clientId.trim() || !clientSecret.trim() + + return ( + + handleOpenChange(false)} + closeDisabled={pending} + > + Set up Slack + + + {isLoading ? ( +
+ + +
+ ) : noBots ? ( +

+ Add a custom Slack app from Integrations before adding Slack to this group. +

+ ) : ( + <> + ({ + value: bot.id, + label: bot.displayName, + icon: SlackIcon, + }))} + placeholder='Select a custom bot' + disabled={pending} + required + /> + {selectedBot ? ( + <> + + + + ) : null} + + )} + {error ? getErrorMessage(error) : null} +
+ handleOpenChange(false)} + cancelDisabled={pending} + primaryAction={{ + label: primaryLabel, + onClick: () => void handleSubmit(), + disabled: primaryDisabled, + }} + /> +
+ ) +} diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 6e905d0fc4f..77482255cff 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -41,6 +41,7 @@ export enum BlockType { WORKFLOW_INPUT = 'workflow_input', CREDENTIAL = 'credential', + CREDENTIAL_GROUP = 'credential_group', WAIT = 'wait', diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts new file mode 100644 index 00000000000..6e3b5281454 --- /dev/null +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockType } from '@/executor/constants' +import type { ExecutionContext } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + buildHeaders: vi.fn(), + enforceInviteRateLimit: vi.fn(), + listCredentials: vi.fn(), + listGroups: vi.fn(), + listPeople: vi.fn(), + sendInvite: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/delegation', () => ({ + authenticateCredentialGroupDelegation: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/list-credentials', () => ({ + listCredentialGroupCredentials: { execute: mocks.listCredentials }, +})) + +vi.mock('@/lib/credential-groups/application/list-groups', () => ({ + listCredentialGroupsForWorkflow: { execute: mocks.listGroups }, +})) + +vi.mock('@/lib/credential-groups/application/list-people', () => ({ + CREDENTIAL_GROUP_PEOPLE_STATUSES: [ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', + ], + listCredentialGroupPeople: { execute: mocks.listPeople }, +})) + +vi.mock('@/lib/credential-groups/application/send-invite', () => ({ + sendCredentialGroupInvite: { execute: mocks.sendInvite }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit, +})) + +vi.mock('@/executor/utils/http', () => ({ + buildExecutorDelegationHeaders: mocks.buildHeaders, +})) + +import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, +} + +const context = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + userId: 'user-1', +} as ExecutionContext + +const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock + +describe('CredentialGroupBlockHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' }) + mocks.authenticate.mockResolvedValue(principal) + }) + + it('recognizes only Credential Group blocks', () => { + const handler = new CredentialGroupBlockHandler() + + expect(handler.canHandle(block)).toBe(true) + expect(handler.canHandle({ metadata: { id: BlockType.CREDENTIAL } } as SerializedBlock)).toBe( + false + ) + }) + + it('lists credentials with normalized provider, email, and page filters', async () => { + mocks.listCredentials.mockResolvedValue({ + credentials: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + const result = await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_credentials', + credentialGroupId: ' group-1 ', + credentialProviderIds: '["google-email", "google-email"]', + email: ' person@example.com ', + limit: '25', + cursor: ' credential-1 ', + }) + + expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1') + expect(mocks.listCredentials).toHaveBeenCalledWith({ + principal, + input: { + credentialGroupId: 'group-1', + credentialProviderIds: ['google-email'], + email: 'person@example.com', + limit: 25, + cursor: 'credential-1', + }, + }) + expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null }) + }) + + it('lists groups under workspace-scoped delegation', async () => { + mocks.listGroups.mockResolvedValue({ + credentialGroups: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_groups', + limit: 10, + }) + + expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined) + expect(mocks.listGroups).toHaveBeenCalledWith({ + principal, + input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined }, + }) + }) + + it('applies the shared workspace invitation budget before sending', async () => { + mocks.sendInvite.mockResolvedValue({ + enrollment: { + id: 'enrollment-1', + email: 'person@example.com', + status: 'invited', + invitedAt: '2026-08-13T12:00:00.000Z', + expiresAt: '2026-08-20T12:00:00.000Z', + }, + }) + + await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'send_invite', + credentialGroupId: 'group-1', + email: ' person@example.com ', + }) + + expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1') + expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendInvite.mock.invocationCallOrder[0]! + ) + expect(mocks.sendInvite).toHaveBeenCalledWith({ + principal, + input: { credentialGroupId: 'group-1', email: 'person@example.com' }, + }) + }) + + it('fails fast on unsupported people statuses', async () => { + await expect( + new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_people', + credentialGroupId: 'group-1', + peopleStatuses: ['unknown'], + }) + ).rejects.toThrow('People statuses contain an unsupported value') + expect(mocks.listPeople).not.toHaveBeenCalled() + }) + + it('rejects unsupported operations before delegation', async () => { + await expect( + new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' }) + ).rejects.toThrow('Unsupported Credential Group operation: unknown') + expect(mocks.buildHeaders).not.toHaveBeenCalled() + expect(mocks.authenticate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts new file mode 100644 index 00000000000..d038451e22e --- /dev/null +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -0,0 +1,207 @@ +import { createLogger } from '@sim/logger' +import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation' +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' +import { + CREDENTIAL_GROUP_PEOPLE_STATUSES, + listCredentialGroupPeople, +} from '@/lib/credential-groups/application/list-people' +import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/send-invite' +import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials' +import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments' +import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit' +import type { BlockOutput } from '@/blocks/types' +import { BlockType } from '@/executor/constants' +import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' +import { buildExecutorDelegationHeaders } from '@/executor/utils/http' +import type { SerializedBlock } from '@/serializer/types' + +const logger = createLogger('CredentialGroupBlockHandler') + +const CREDENTIAL_GROUP_OPERATION_IDS = [ + 'list_credentials', + 'send_invite', + 'list_people', + 'list_groups', +] as const + +type CredentialGroupOperation = (typeof CREDENTIAL_GROUP_OPERATION_IDS)[number] + +function parseOperation(value: unknown): CredentialGroupOperation { + const operation = typeof value === 'string' ? value : 'list_credentials' + const supported = CREDENTIAL_GROUP_OPERATION_IDS.find((candidate) => candidate === operation) + if (!supported) throw new Error(`Unsupported Credential Group operation: ${operation}`) + return supported +} + +function parseStringList(value: unknown, label: string): string[] | undefined { + if (value === undefined || value === null || value === '') return undefined + + let parsed: unknown = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + if (!trimmed.startsWith('[')) return [trimmed] + try { + parsed = JSON.parse(trimmed) + } catch { + throw new Error(`${label} must be a valid JSON array of strings`) + } + } + + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string' && item.trim())) { + throw new Error(`${label} must be an array of non-empty strings`) + } + + const values = [...new Set(parsed.map((item) => item.trim()))] + return values.length > 0 ? values : undefined +} + +function parseLimit(value: unknown): number { + const raw = value ?? MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE + const limit = + typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE) { + throw new Error( + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` + ) + } + return limit +} + +function parseOptionalString(value: unknown, label: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string' || !value.trim()) + throw new Error(`${label} must be a non-empty string`) + return value.trim() +} + +function requireString(value: unknown, label: string): string { + const parsed = parseOptionalString(value, label) + if (!parsed) throw new Error(`${label} is required`) + return parsed +} + +function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin { + const origin = + ctx.executorDelegationOrigin ?? + (ctx.userId + ? { + subjectUserId: ctx.userId, + workflowId: ctx.workflowId, + ...(ctx.executionId ? { executionId: ctx.executionId } : {}), + } + : undefined) + if (!origin) { + throw new Error('Credential Group operations require an authenticated workflow execution') + } + return origin +} + +export class CredentialGroupBlockHandler implements BlockHandler { + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === BlockType.CREDENTIAL_GROUP + } + + async execute( + ctx: ExecutionContext, + _block: SerializedBlock, + inputs: Record + ): Promise { + if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations') + const operation = parseOperation(inputs.operation) + const credentialGroupId = + operation === 'list_groups' + ? undefined + : requireString(inputs.credentialGroupId, 'Credential Group') + const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx)) + const authorization = headers.Authorization + if (!authorization) throw new Error('Executor delegation authorization is missing') + const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId) + + switch (operation) { + case 'list_credentials': { + const credentialProviderIds = parseStringList( + inputs.credentialProviderIds, + 'Credential provider IDs' + ) + const result = await listCredentialGroupCredentials.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + credentialProviderIds, + }, + }) + logger.info('Listed Credential Group credentials', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + case 'send_invite': { + await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) + const result = await sendCredentialGroupInvite.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + email: requireString(inputs.email, 'Email'), + }, + }) + logger.info('Sent Credential Group invitation', { + credentialGroupId, + enrollmentId: result.enrollment.id, + }) + return { + enrollmentId: result.enrollment.id, + email: result.enrollment.email, + status: result.enrollment.status, + invitedAt: result.enrollment.invitedAt, + expiresAt: result.enrollment.expiresAt, + } + } + case 'list_people': { + const statuses = parseStringList(inputs.peopleStatuses, 'People statuses') + const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) + if (statuses?.some((status) => !allowedStatuses.has(status))) { + throw new Error('People statuses contain an unsupported value') + } + const result = await listCredentialGroupPeople.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + statuses: statuses as CredentialGroupEnrollmentStatus[] | undefined, + }, + }) + logger.info('Listed Credential Group people', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + case 'list_groups': { + const result = await listCredentialGroupsForWorkflow.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + }, + }) + logger.info('Listed Credential Groups', { + workspaceId: ctx.workspaceId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + } + } +} diff --git a/apps/sim/executor/handlers/credential/credential-handler.ts b/apps/sim/executor/handlers/credential/credential-handler.ts index 2619ae5bd07..ff01b77ff95 100644 --- a/apps/sim/executor/handlers/credential/credential-handler.ts +++ b/apps/sim/executor/handlers/credential/credential-handler.ts @@ -25,11 +25,14 @@ export class CredentialBlockHandler implements BlockHandler { const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select' - if (operation === 'list') { - return this.listCredentials(ctx.workspaceId, inputs) + switch (operation) { + case 'select': + return this.selectCredential(ctx.workspaceId, inputs) + case 'list': + return this.listCredentials(ctx.workspaceId, inputs) + default: + throw new Error(`Unsupported Credential operation: ${operation}`) } - - return this.selectCredential(ctx.workspaceId, inputs) } private async selectCredential( diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index cd8c57d1c61..bbe0e52debb 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler' +import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' @@ -45,6 +46,7 @@ export function createBlockHandlers(): BlockHandler[] { new WorkflowBlockHandler(), new WaitBlockHandler(), new EvaluatorBlockHandler(), + new CredentialGroupBlockHandler(), new CredentialBlockHandler(), new GenericBlockHandler(), ] diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts new file mode 100644 index 00000000000..d45dbed396d --- /dev/null +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -0,0 +1,196 @@ +'use client' + +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + createCredentialGroupContract, + deleteCredentialGroupContract, + getCredentialGroupContract, + inviteCredentialGroupEnrollmentsContract, + resendCredentialGroupEnrollmentContract, + revokeCredentialGroupEnrollmentContract, + startSlackCredentialGroupConfigurationContract, + updateCredentialGroupContract, +} from '@/lib/api/contracts/credential-groups' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { + CREDENTIAL_GROUP_DETAIL_STALE_TIME, + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' + +export function useCredentialGroups(workspaceId?: string) { + return useQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: async ({ signal }) => { + if (!workspaceId) return [] + return fetchCredentialGroupList(workspaceId, signal) + }, + enabled: Boolean(workspaceId), + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + }) +} + +export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) { + return useInfiniteQuery({ + queryKey: credentialGroupKeys.detail(workspaceId, groupId), + queryFn: ({ signal, pageParam }) => { + if (!workspaceId || !groupId) + throw new Error('Credential group detail identifiers are required') + return requestJson(getCredentialGroupContract, { + params: { id: workspaceId, groupId }, + query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) }, + signal, + }) + }, + initialPageParam: null as string | null, + getNextPageParam: (lastPage: ContractJsonResponse) => + lastPage.nextCursor ?? undefined, + enabled: Boolean(workspaceId && groupId), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +export function useCreateCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + body, + }: { + workspaceId: string + body: ContractBodyInput + }) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useDeleteCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ workspaceId, groupId }: { workspaceId: string; groupId: string }) => + requestJson(deleteCredentialGroupContract, { + params: { id: workspaceId, groupId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useUpdateCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(updateCredentialGroupContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useStartSlackCredentialGroupConfiguration() { + return useMutation({ + mutationFn: async ({ + workspaceId, + credentialGroupId, + body, + }: { + workspaceId: string + credentialGroupId: string + body: ContractBodyInput + }) => + requestJson(startSlackCredentialGroupConfigurationContract, { + params: { id: workspaceId, groupId: credentialGroupId }, + body, + }), + }) +} + +export function useInviteCredentialGroupEnrollments() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(inviteCredentialGroupEnrollmentsContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useResendCredentialGroupEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + enrollmentId, + }: { + workspaceId: string + groupId: string + enrollmentId: string + }) => + requestJson(resendCredentialGroupEnrollmentContract, { + params: { id: workspaceId, groupId, enrollmentId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useRevokeCredentialGroupEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + enrollmentId, + }: { + workspaceId: string + groupId: string + enrollmentId: string + }) => + requestJson(revokeCredentialGroupEnrollmentContract, { + params: { id: workspaceId, groupId, enrollmentId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/utils/credential-group-queries.ts b/apps/sim/hooks/queries/utils/credential-group-queries.ts new file mode 100644 index 00000000000..780b2f31f49 --- /dev/null +++ b/apps/sim/hooks/queries/utils/credential-group-queries.ts @@ -0,0 +1,26 @@ +import { requestJson } from '@/lib/api/client/request' +import type { CredentialGroup } from '@/lib/api/contracts/credential-groups' +import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups' + +export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY +export const CREDENTIAL_GROUP_LIST_STALE_TIME = 30 * 1000 + +export const credentialGroupKeys = { + all: ['credential-groups'] as const, + lists: () => [...credentialGroupKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...credentialGroupKeys.lists(), workspaceId ?? ''] as const, + details: () => [...credentialGroupKeys.all, 'detail'] as const, + detail: (workspaceId?: string, groupId?: string) => + [...credentialGroupKeys.details(), workspaceId ?? '', groupId ?? ''] as const, +} + +export async function fetchCredentialGroupList( + workspaceId: string, + signal?: AbortSignal +): Promise { + const data = await requestJson(listCredentialGroupsContract, { + params: { id: workspaceId }, + signal, + }) + return data.credentialGroups +} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index a161bf9a924..0f132d1dde7 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -20,6 +20,7 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, } from '@/lib/permission-groups/types' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' @@ -56,6 +57,7 @@ export function usePermissionConfig(): PermissionConfigResult { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined const blockOverlayVersion = useCustomBlockOverlayVersion() + const hostContext = useOptionalWorkspaceHostContext() const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig(workspaceId) @@ -94,6 +96,9 @@ export function usePermissionConfig(): PermissionConfigResult { const isBlockAllowed = useMemo(() => { return (blockType: string) => { const normalizedBlockType = blockType.toLowerCase() + if (normalizedBlockType === 'credential_group' && !hostContext?.features?.credentialGroups) { + return false + } const availability = integrationAvailability.get(normalizedBlockType) if ( isDeploymentGatedIntegrationType(normalizedBlockType) && @@ -106,7 +111,7 @@ export function usePermissionConfig(): PermissionConfigResult { if (mergedAllowedIntegrations === null) return true return mergedAllowedIntegrations.includes(normalizedBlockType) } - }, [integrationAvailability, mergedAllowedIntegrations]) + }, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations]) const isProviderAllowed = useMemo(() => { return (providerId: string) => { diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts new file mode 100644 index 00000000000..edbea439b0f --- /dev/null +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { + createCredentialGroupBodySchema, + credentialGroupEnrollmentDetailSchema, + credentialGroupEnrollmentListQuerySchema, + credentialGroupSchema, + inviteCredentialGroupEnrollmentsBodySchema, + updateCredentialGroupBodySchema, +} from '@/lib/api/contracts/credential-groups' + +describe('credential group contracts', () => { + it('accepts a group before account types are added', () => { + const parsed = createCredentialGroupBodySchema.parse({ + name: 'Support team', + options: [], + }) + + expect(parsed.options).toEqual([]) + }) + + it('accepts one option per provider after the group exists', () => { + const parsed = updateCredentialGroupBodySchema.parse({ + options: [ + { provider: 'gmail', label: 'Gmail', required: true }, + { provider: 'google-calendar', label: 'Google Calendar', required: true }, + { + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }, + ], + }) + + expect(parsed.options).toHaveLength(3) + }) + + it('rejects the removed multiple-account option', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { + provider: 'gmail', + label: 'Primary inbox', + required: true, + allowMultiple: true, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects duplicate option labels case-insensitively', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { + provider: 'gmail', + label: 'Inbox', + required: true, + }, + { + provider: 'gmail', + label: 'inbox', + required: true, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects duplicate providers', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { provider: 'gmail', label: 'Primary inbox', required: true }, + { provider: 'gmail', label: 'Escalations', required: true }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('requires a custom bot for Slack option updates', () => { + const missingApp = updateCredentialGroupBodySchema.safeParse({ + options: [ + { + provider: 'slack', + label: 'Slack', + required: true, + }, + ], + }) + const withApp = updateCredentialGroupBodySchema.safeParse({ + options: [ + { + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }, + ], + }) + + expect(missingApp.success).toBe(false) + expect(withApp.success).toBe(true) + }) + + it('rejects duplicate option IDs on update', () => { + const option = { + id: 'option-1', + provider: 'gmail' as const, + required: true, + } + const result = updateCredentialGroupBodySchema.safeParse({ + options: [ + { ...option, label: 'Inbox' }, + { ...option, label: 'Escalations' }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects the authorization-app identity from settings responses', () => { + const result = credentialGroupSchema.safeParse({ + id: 'group-1', + workspaceId: 'workspace-1', + name: 'Support team', + description: null, + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Inbox', + required: true, + status: 'active', + authorizationAppId: 'server-only', + }, + ], + status: 'active', + createdAt: '2026-08-10T12:00:00.000Z', + updatedAt: '2026-08-10T12:00:00.000Z', + }) + + expect(result.success).toBe(false) + }) + + it('accepts a batch of invitation emails', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.parse({ + emails: ['alex@example.com', 'sam@example.com'], + }) + + expect(result.emails).toEqual(['alex@example.com', 'sam@example.com']) + }) + + it('rejects invitation batches larger than 100 recipients', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({ + emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`), + }) + + expect(result.success).toBe(false) + }) + + it('rejects invalid invitation email addresses', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({ + emails: ['not-an-email'], + }) + + expect(result.success).toBe(false) + }) + + it('bounds enrollment pages and defaults them to 50 rows', () => { + expect(credentialGroupEnrollmentListQuerySchema.parse({})).toEqual({ limit: 50 }) + expect(credentialGroupEnrollmentListQuerySchema.safeParse({ limit: 101 }).success).toBe(false) + }) + + it('accepts aggregated provider connections on an enrollment', () => { + const result = credentialGroupEnrollmentDetailSchema.parse({ + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'alex@example.com', + status: 'completed', + expiresAt: '2026-08-18T12:00:00.000Z', + invitedAt: '2026-08-11T12:00:00.000Z', + sentAt: '2026-08-11T12:00:01.000Z', + completedAt: '2026-08-11T12:05:00.000Z', + revokedAt: null, + expired: false, + createdAt: '2026-08-11T12:00:00.000Z', + updatedAt: '2026-08-11T12:05:00.000Z', + connections: [{ provider: 'gmail', status: 'active', count: 2 }], + }) + + expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }]) + }) +}) diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts new file mode 100644 index 00000000000..f943fba510c --- /dev/null +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -0,0 +1,425 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, +} from '@/lib/credential-groups/providers' + +export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) +export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) +export const credentialGroupEnrollmentStatusSchema = z.enum([ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +]) +export const credentialGroupOptionConfigurationStatusSchema = z.enum([ + 'not_configured', + 'ready', + 'needs_update', +]) + +const credentialGroupOptionFields = { + label: z.string().trim().min(1, 'Option label is required').max(100), + required: z.boolean(), +} as const + +const standardOAuthCredentialGroupOptionInputSchema = z + .object({ + provider: z.enum(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS), + ...credentialGroupOptionFields, + }) + .strict() + +const slackCredentialGroupOptionInputSchema = z + .object({ + provider: z.literal('slack'), + ...credentialGroupOptionFields, + slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + }) + .strict() + +export const credentialGroupOptionInputSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema, + slackCredentialGroupOptionInputSchema, +]) + +export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), + slackCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), +]) + +export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1).max(128).optional(), + }), + slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), +]) + +export const credentialGroupSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + name: z.string(), + description: z.string().nullable(), + options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + status: credentialGroupStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type CredentialGroup = z.output +export type CredentialGroupOption = z.output +export type CredentialGroupOptionInput = z.input + +export const credentialGroupEnrollmentSchema = z.object({ + id: z.string(), + credentialGroupId: z.string(), + email: z.string().email(), + status: credentialGroupEnrollmentStatusSchema, + expiresAt: z.string(), + invitedAt: z.string(), + sentAt: z.string().nullable(), + completedAt: z.string().nullable(), + revokedAt: z.string().nullable(), + expired: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type CredentialGroupEnrollment = z.output + +export const credentialGroupEnrollmentConnectionSchema = z.object({ + provider: credentialGroupProviderSchema, + status: z.enum(['active', 'needs_reauth', 'revoked']), + count: z.number().int().positive(), +}) + +export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({ + connections: z + .array(credentialGroupEnrollmentConnectionSchema) + .max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3), +}) + +export type CredentialGroupEnrollmentConnection = z.output< + typeof credentialGroupEnrollmentConnectionSchema +> +export type CredentialGroupEnrollmentDetail = z.output + +export const credentialGroupWorkspaceParamsSchema = z.object({ + id: workspaceIdSchema, +}) + +export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsSchema.extend({ + groupId: z.string().min(1, 'Credential group ID is required').max(128), +}) + +export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({ + enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128), +}) + +export const publicCredentialGroupEnrollmentParamsSchema = z.object({ + token: z.string().min(1, 'Invitation token is required').max(128), +}) + +export const startCredentialGroupOAuthParamsSchema = + publicCredentialGroupEnrollmentParamsSchema.extend({ + optionId: z.string().min(1, 'Credential option ID is required').max(128), + }) + +export const credentialGroupOAuthCallbackQuerySchema = z + .object({ + state: z.string().min(1, 'OAuth state is required').max(512), + code: z.string().min(1).max(2048).optional(), + error: z.string().min(1).max(256).optional(), + error_description: z.string().max(1000).optional(), + }) + .superRefine((query, ctx) => { + if (!query.code && !query.error) { + ctx.addIssue({ + code: 'custom', + path: ['code'], + message: 'OAuth callback must include a code or error', + }) + } + }) + +export const credentialGroupOAuthCallbackParamsSchema = z.object({ + provider: credentialGroupProviderSchema, +}) + +export const startSlackCredentialGroupConfigurationBodySchema = z + .object({ + slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + clientId: z.string().trim().min(1, 'Slack Client ID is required').max(256), + clientSecret: z.string().trim().min(1, 'Slack Client Secret is required').max(512), + }) + .strict() + +export const slackCredentialGroupConfigurationCallbackQuerySchema = + credentialGroupOAuthCallbackQuerySchema + +export const credentialGroupEnrollmentListQuerySchema = z.object({ + cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}) + +export const inviteCredentialGroupEnrollmentsBodySchema = z + .object({ + emails: z + .array(z.string().trim().email('Enter a valid email address').max(320)) + .min(1, 'At least one email address is required') + .max(100, 'You can invite at most 100 people at once'), + }) + .strict() + +export type InviteCredentialGroupEnrollmentsBody = z.input< + typeof inviteCredentialGroupEnrollmentsBodySchema +> + +export const credentialGroupEnrollmentInviteResultSchema = z.discriminatedUnion('success', [ + z.object({ + email: z.string().email(), + success: z.literal(true), + enrollment: credentialGroupEnrollmentSchema, + }), + z.object({ + email: z.string().email(), + success: z.literal(false), + error: z.string(), + }), +]) + +export const createCredentialGroupBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100), + description: z.string().trim().max(500).optional(), + options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + }) + .strict() + .superRefine((body, ctx) => { + const labels = new Set() + const providers = new Set() + for (const [index, option] of body.options.entries()) { + if (option.provider === 'slack') { + ctx.addIssue({ + code: 'custom', + path: ['options', index], + message: 'Create the Credential Group before configuring Slack', + }) + } + const normalized = option.label.toLocaleLowerCase() + if (labels.has(normalized)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'label'], + message: 'Credential option labels must be unique within a group', + }) + } + labels.add(normalized) + if (providers.has(option.provider)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'provider'], + message: 'Each provider can only be added once', + }) + } + providers.add(option.provider) + } + }) + +export type CreateCredentialGroupBody = z.input + +export const updateCredentialGroupBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100).optional(), + description: z.string().trim().max(500).nullable().optional(), + options: z + .array(credentialGroupOptionUpdateInputSchema) + .max(CREDENTIAL_GROUP_PROVIDER_IDS.length) + .optional(), + status: credentialGroupStatusSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (Object.keys(body).length === 0) { + ctx.addIssue({ code: 'custom', message: 'At least one field must be updated' }) + } + if (!body.options) return + const labels = new Set() + const optionIds = new Set() + const providers = new Set() + for (const [index, option] of body.options.entries()) { + const normalized = option.label.toLowerCase() + if (labels.has(normalized)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'label'], + message: 'Credential option labels must be unique within a group', + }) + } + labels.add(normalized) + if (providers.has(option.provider)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'provider'], + message: 'Each provider can only be added once', + }) + } + providers.add(option.provider) + if (option.id && optionIds.has(option.id)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'id'], + message: 'Credential option IDs must be unique within a group', + }) + } + if (option.id) optionIds.add(option.id) + } + }) + +export type UpdateCredentialGroupBody = z.input + +export const listCredentialGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/credential-groups', + params: credentialGroupWorkspaceParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }), + }, +}) + +export const createCredentialGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups', + params: credentialGroupWorkspaceParamsSchema, + body: createCredentialGroupBodySchema, + response: { + mode: 'json', + schema: z.object({ credentialGroup: credentialGroupSchema }), + }, +}) + +export const getCredentialGroupContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + query: credentialGroupEnrollmentListQuerySchema, + response: { + mode: 'json', + schema: z.object({ + credentialGroup: credentialGroupSchema, + enrollments: z.array(credentialGroupEnrollmentDetailSchema), + nextCursor: z.string().nullable(), + }), + }, +}) + +export const inviteCredentialGroupEnrollmentsContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments', + params: credentialGroupDetailParamsSchema, + body: inviteCredentialGroupEnrollmentsBodySchema, + response: { + mode: 'json', + schema: z.object({ + results: z.array(credentialGroupEnrollmentInviteResultSchema).min(1).max(100), + sentCount: z.number().int().nonnegative(), + failedCount: z.number().int().nonnegative(), + }), + }, +}) + +export const resendCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend', + params: credentialGroupEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) + +export const revokeCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]', + params: credentialGroupEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) + +export const deleteCredentialGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) + +export const updateCredentialGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + body: updateCredentialGroupBodySchema, + response: { + mode: 'json', + schema: z.object({ credentialGroup: credentialGroupSchema }), + }, +}) + +export const startSlackCredentialGroupConfigurationContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users', + params: credentialGroupDetailParamsSchema, + body: startSlackCredentialGroupConfigurationBodySchema, + response: { + mode: 'json', + schema: z.object({ + authorizationUrl: z.string().url(), + state: z.string().min(1), + }), + }, +}) + +export const slackCredentialGroupConfigurationCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/slack-managed-users/callback', + query: slackCredentialGroupConfigurationCallbackQuerySchema, + response: { mode: 'text' }, +}) + +export const startCredentialGroupOAuthContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/enroll/[token]/oauth/[optionId]', + params: startCredentialGroupOAuthParamsSchema, + response: { mode: 'empty' }, +}) + +export const completeCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'POST', + path: '/api/credential-groups/enroll/[token]/complete', + params: publicCredentialGroupEnrollmentParamsSchema, + response: { mode: 'empty' }, +}) + +export const credentialGroupOAuthCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/oauth/[provider]/callback', + params: credentialGroupOAuthCallbackParamsSchema, + query: credentialGroupOAuthCallbackQuerySchema, + response: { mode: 'empty' }, +}) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index e3c00abec1f..944cbd54980 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -17,9 +17,14 @@ export const workspaceCredentialTypeSchema = z.enum([ 'env_personal', 'service_account', ]) +const creatableWorkspaceCredentialTypeSchema = z.enum([ + 'oauth', + 'env_workspace', + 'env_personal', + 'service_account', +]) export const workspaceCredentialRoleSchema = z.enum(['admin', 'member']) export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked']) - export const workspaceCredentialSchema = z.object({ id: z.string(), workspaceId: z.string(), @@ -112,7 +117,7 @@ export const serviceAccountJsonSchema = z export const createCredentialBodySchema = z .object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema, + type: creatableWorkspaceCredentialTypeSchema, displayName: z.string().trim().min(1).max(255).optional(), description: z.string().trim().max(500).optional(), providerId: z.string().trim().min(1).optional(), diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 1b74fdb1567..8e22d386c50 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -6,6 +6,7 @@ export * from './chats' export * from './cli-auth' export * from './common' export * from './copilot' +export * from './credential-groups' export * from './credentials' export * from './demo-requests' export * from './desktop-auth' diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..234f86419ea 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -7,6 +7,8 @@ import type { } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' +export const MANAGED_OAUTH_DELEGATION_HEADER = 'x-sim-managed-oauth-delegation' + export const oauthAccountSummarySchema = z.object({ id: z.string(), name: z.string(), @@ -70,6 +72,7 @@ export const oauthTokenRequestBodySchema = z credentialId: z.string().min(1).optional(), credentialAccountUserId: z.string().min(1).optional(), providerId: z.string().min(1).optional(), + toolId: z.string().min(1).optional(), workflowId: z.string().min(1).nullish(), scopes: z.array(z.string()).optional(), impersonateEmail: impersonateEmailSchema.optional(), @@ -91,6 +94,10 @@ export const oauthTokenPostQuerySchema = z.object({ userId: z.string().min(1).optional(), }) +export const oauthTokenPostHeadersSchema = z.object({ + [MANAGED_OAUTH_DELEGATION_HEADER]: z.string().min(1).optional(), +}) + const oauthTokenResponseSchema = z.object({ accessToken: z.string(), idToken: z.string().optional(), @@ -119,6 +126,7 @@ export const oauthTokenPostContract = defineRouteContract({ method: 'POST', path: '/api/auth/oauth/token', query: oauthTokenPostQuerySchema, + headers: oauthTokenPostHeadersSchema, body: oauthTokenRequestBodySchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 6d4c64e60a1..0485b7abfae 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -256,6 +256,11 @@ export const workspaceHostContextSchema = z.object({ isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), }), + features: z + .object({ + credentialGroups: z.boolean(), + }) + .optional(), }) export type WorkspaceHostContext = z.output diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts new file mode 100644 index 00000000000..b1cd461bf06 --- /dev/null +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto' +import type { OAuth2Tokens } from '@better-auth/core/oauth2' +import type { GenericOAuthConfig } from 'better-auth/plugins' +import { OAuth2Client, type TokenPayload } from 'google-auth-library' + +const GOOGLE_OPENID_SCOPE = 'openid' +const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' +const GOOGLE_PROFILE_SCOPE = 'https://www.googleapis.com/auth/userinfo.profile' +const GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly' +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' +const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' + +export interface ManagedOAuthConnectorIdentity { + providerSubjectId: string + providerTenantId: string | null + email: string + emailVerified: boolean + displayName?: string + avatarUrl?: string + nonce?: string + grantedScopes: string[] +} + +export interface ManagedOAuthConnectorConfig { + additionalScopes: string[] + requiresRefreshToken: boolean + pkce: boolean + prompt?: string + authorizationUrlParams?: Record + getAuthorizationAppId(clientId: string): string + verifyIdentity(params: { + tokens: OAuth2Tokens + clientId: string + }): Promise + hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean + isTerminalRefreshError(errorCode: string | undefined): boolean +} + +export interface ConnectorProviderConfig extends GenericOAuthConfig { + managedOAuth?: ManagedOAuthConnectorConfig +} + +function canonicalGoogleScope(scope: string): string { + if (scope === 'email') return GOOGLE_EMAIL_SCOPE + if (scope === 'profile') return GOOGLE_PROFILE_SCOPE + return scope +} + +function hasRequiredGoogleScopes( + providerId: string, + grantedScopes: string[], + requiredScopes: string[] +): boolean { + const granted = new Set(grantedScopes.map(canonicalGoogleScope)) + return requiredScopes.every((requestedScope) => { + const required = canonicalGoogleScope(requestedScope) + if (granted.has(required)) return true + return ( + providerId === 'google-email' && + granted.has(GMAIL_MODIFY_SCOPE) && + (required === GMAIL_READONLY_SCOPE || + required === GMAIL_SEND_SCOPE || + required === GMAIL_LABELS_SCOPE) + ) + }) +} + +function requireVerifiedGooglePayload(payload: TokenPayload | undefined): TokenPayload & { + sub: string + email: string +} { + if (!payload?.sub || !payload.email || payload.email_verified !== true) { + throw new Error('Google returned an invalid identity token') + } + return payload as TokenPayload & { sub: string; email: string } +} + +export function createGoogleManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig { + return { + additionalScopes: [GOOGLE_OPENID_SCOPE], + requiresRefreshToken: true, + pkce: true, + prompt: 'consent select_account', + authorizationUrlParams: { include_granted_scopes: 'false' }, + getAuthorizationAppId(clientId) { + return `google:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens, clientId }) { + if (!tokens.idToken || !tokens.accessToken) { + throw new Error('Google returned an incomplete authorization') + } + const client = new OAuth2Client({ clientId }) + const ticket = await client.verifyIdToken({ idToken: tokens.idToken, audience: clientId }) + const payload = requireVerifiedGooglePayload(ticket.getPayload()) + const tokenInfo = await client.getTokenInfo(tokens.accessToken) + if (tokenInfo.aud !== clientId || tokenInfo.sub !== payload.sub) { + throw new Error('Google returned an access token for another identity') + } + return { + providerSubjectId: payload.sub, + providerTenantId: payload.hd ?? null, + email: payload.email, + emailVerified: true, + ...(payload.name ? { displayName: payload.name } : {}), + ...(payload.picture ? { avatarUrl: payload.picture } : {}), + ...(payload.nonce ? { nonce: payload.nonce } : {}), + grantedScopes: [...new Set(tokenInfo.scopes)], + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + return hasRequiredGoogleScopes(providerId, grantedScopes, requiredScopes) + }, + isTerminalRefreshError(errorCode) { + return errorCode === 'invalid_grant' + }, + } +} diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 041b0c35ec8..5a25e2e69ca 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -6,6 +6,10 @@ import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import type { GenericOAuthConfig } from 'better-auth/plugins' import { syntheticConnectorEmail } from '@/lib/auth/connector-email' +import { + type ConnectorProviderConfig, + createGoogleManagedOAuthConnector, +} from '@/lib/auth/connectors/managed-oauth' import { env } from '@/lib/core/config/env' import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { @@ -147,8 +151,8 @@ function salesforceConnector(providerId: string, loginHost: string): GenericOAut * matching its union) and every `getUserInfo` parameter becomes implicitly * `any`. */ -export function buildConnectorProviders(): GenericOAuthConfig[] { - const providers: GenericOAuthConfig[] = [ +export function buildConnectorProviders(): ConnectorProviderConfig[] { + const providers: ConnectorProviderConfig[] = [ { providerId: 'google-email', clientId: env.GOOGLE_CLIENT_ID as string, @@ -158,6 +162,7 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { scopes: getCanonicalScopesForProvider('google-email'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`, + managedOAuth: createGoogleManagedOAuthConnector('google-email'), getUserInfo: async (tokens) => { try { const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { @@ -194,6 +199,7 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { scopes: getCanonicalScopesForProvider('google-calendar'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`, + managedOAuth: createGoogleManagedOAuthConnector('google-calendar'), getUserInfo: async (tokens) => { try { const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { @@ -2427,3 +2433,9 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { ({ providerId }) => inspectConfiguredOAuthClient(providerId).state === 'ready' ) } + +export function getConnectorProviderConfig( + providerId: string +): ConnectorProviderConfig | undefined { + return buildConnectorProviders().find((provider) => provider.providerId === providerId) +} diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 719cd30f8ca..2671bf1f0de 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -18,7 +18,7 @@ export interface CredentialAccessResult { credentialOwnerUserId?: string workspaceId?: string resolvedCredentialId?: string - credentialType?: 'oauth' | 'service_account' + credentialType?: 'oauth' | 'managed_oauth' | 'service_account' } const NO_CREDENTIAL_ACCESS = @@ -123,6 +123,13 @@ export async function authorizeCredentialUseForAuth( const accessError = credentialAccessError(platformAccess) if (accessError) return { ok: false, error: accessError } + if (platformCredential.type === 'managed_oauth') { + return { + ok: false, + error: 'Managed credential access requires scoped workflow delegation', + } + } + if (platformCredential.type === 'service_account') { return { ok: true, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index baf2e9deffc..4ef32b4279f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -528,6 +528,7 @@ export const env = createEnv({ TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu + CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 7f0d0dbea50..ca87caf815d 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -14,6 +14,8 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ FORKING_ENABLED: undefined as boolean | undefined, DEPLOY_AS_BLOCK: undefined as boolean | undefined, TABLES_V2_API: undefined as boolean | undefined, + TABLE_VIEWS: undefined as boolean | undefined, + CREDENTIAL_GROUPS: undefined as boolean | undefined, }, })) @@ -122,6 +124,8 @@ describe('isFeatureEnabled', () => { setEnvFlags({ isAppConfigEnabled: false }) envRef.FORKING_ENABLED = undefined envRef.DEPLOY_AS_BLOCK = undefined + envRef.CREDENTIAL_GROUPS = undefined + envRef.TABLE_VIEWS = undefined }) describe('workspace-forking flag', () => { @@ -160,6 +164,20 @@ describe('isFeatureEnabled', () => { }) }) + describe('credential-groups flag', () => { + it('uses a global fallback switch off AppConfig', async () => { + expect(await isFeatureEnabled('credential-groups')).toBe(false) + + envRef.CREDENTIAL_GROUPS = true + expect(await isFeatureEnabled('credential-groups')).toBe(true) + }) + + it('uses only the global AppConfig clause', async () => { + withAppConfig({ 'credential-groups': { enabled: true } }) + expect(await isFeatureEnabled('credential-groups')).toBe(true) + }) + }) + describe('table-views flag', () => { it('falls back to TABLE_VIEWS when AppConfig is disabled', async () => { envRef.TABLE_VIEWS = undefined diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cef6ab2e66b..c068326a648 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -144,6 +144,12 @@ const FEATURE_FLAGS = { 'Off-AppConfig falls back to TABLE_VIEWS.', fallback: 'TABLE_VIEWS', }, + 'credential-groups': { + description: + 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + + 'Global on/off only; hosted workspaces must also have an Enterprise subscription.', + fallback: 'CREDENTIAL_GROUPS', + }, } satisfies Record /** diff --git a/apps/sim/lib/credential-groups/access.ts b/apps/sim/lib/credential-groups/access.ts new file mode 100644 index 00000000000..e96b422d6e3 --- /dev/null +++ b/apps/sim/lib/credential-groups/access.ts @@ -0,0 +1,29 @@ +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' + +export class CredentialGroupAccessError extends Error { + constructor( + message: string, + readonly status: 403 | 404 + ) { + super(message) + this.name = 'CredentialGroupAccessError' + } +} + +/** Resolves the workspace entitlement and requires workspace-admin access. */ +export async function authorizeCredentialGroupSettings( + workspaceId: string, + userId: string +): Promise { + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) + if (!hostContext) throw new CredentialGroupAccessError('Workspace not found', 404) + if (!(await isCredentialGroupsAvailable(hostContext.ownerBilling))) { + throw new CredentialGroupAccessError('Credential Groups are not available', 404) + } + if (hostContext.viewer.permission !== 'admin') { + throw new CredentialGroupAccessError('Workspace admin access is required', 403) + } + return hostContext +} diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts new file mode 100644 index 00000000000..f61a27f8ee3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -0,0 +1,26 @@ +import type { Principal } from '@sim/auth/principal' +import type { + WorkspaceAuthorizationContext, + WorkspaceDelegationPolicy, +} from '@/lib/core/application' +import type { CredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' + +export const CREDENTIAL_GROUP_DELEGATION_AUDIENCE = 'sim:credential-groups' + +export interface CredentialGroupApplicationContext + extends WorkspaceAuthorizationContext, + CredentialGroupCredentialListContext {} + +export const credentialGroupDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: CredentialGroupApplicationContext + ) => principal.resourceScope?.credentialGroupId === context.credentialGroupId, +} satisfies WorkspaceDelegationPolicy + +export const credentialGroupWorkspaceDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: (principal: Extract) => + principal.resourceScope?.credentialGroupId === undefined, +} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credential-groups/application/context.ts b/apps/sim/lib/credential-groups/application/context.ts new file mode 100644 index 00000000000..2d60fe0b59f --- /dev/null +++ b/apps/sim/lib/credential-groups/application/context.ts @@ -0,0 +1,27 @@ +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { loadCredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export async function requireCredentialGroupsAvailable(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new OrchestrationError('forbidden', 'Credential Groups are not available') + } +} + +export async function resolveCredentialGroupWorkspaceContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +export async function resolveCredentialGroupContext( + credentialGroupId: string +): Promise { + const group = await loadCredentialGroupCredentialListContext(credentialGroupId) + if (!group) throw new OrchestrationError('not_found', 'Credential group not found') + return { ...(await resolveCredentialGroupWorkspaceContext(group.workspaceId)), ...group } +} diff --git a/apps/sim/lib/credential-groups/application/delegation.ts b/apps/sim/lib/credential-groups/application/delegation.ts new file mode 100644 index 00000000000..56ad6895c0a --- /dev/null +++ b/apps/sim/lib/credential-groups/application/delegation.ts @@ -0,0 +1,41 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' + +export class InvalidCredentialGroupDelegationError extends Error { + constructor() { + super('Credential Group execution requires valid workflow delegation') + this.name = 'InvalidCredentialGroupDelegationError' + } +} + +/** Authenticates and binds executor claims to Credential Group application scope. */ +export async function authenticateCredentialGroupDelegation( + authorization: string, + credentialGroupId?: string +): Promise { + if (!authorization.startsWith('Bearer ')) throw new InvalidCredentialGroupDelegationError() + + try { + const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + return await bindInternalExecutorDelegation(claims, { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + ...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}), + }) + } catch (error) { + if ( + error instanceof InvalidInternalDelegationTokenError || + error instanceof InvalidInternalDelegationBindingError + ) { + throw new InvalidCredentialGroupDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts new file mode 100644 index 00000000000..63d16c9bdaa --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -0,0 +1,254 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn(), + isCredentialGroupsAvailable: vi.fn(), + listCredentials: vi.fn(), + loadGroup: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: mocks.isCredentialGroupsAvailable, +})) + +vi.mock('@/lib/credential-groups/credentials', () => ({ + CredentialGroupCredentialCursorNotFoundError: class extends Error { + constructor() { + super('Credential group credential cursor not found') + this.name = 'CredentialGroupCredentialCursorNotFoundError' + } + }, + listCredentialGroupCredentialReferences: mocks.listCredentials, + loadCredentialGroupCredentialListContext: mocks.loadGroup, + MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE: 100, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { CredentialGroupCredentialCursorNotFoundError } from '@/lib/credential-groups/credentials' + +const groupContext = { + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + name: 'Credential Group', + status: 'active' as const, + options: [ + { + id: 'option-1', + provider: 'gmail' as const, + label: 'Work Gmail', + authorizationAppId: 'google:client-1', + requiredScopes: ['gmail.readonly'], + scopeVersion: 1, + required: true, + status: 'active' as const, + }, + { + id: 'option-disabled', + provider: 'gmail' as const, + label: 'Old Gmail', + authorizationAppId: 'google:client-1', + requiredScopes: ['gmail.readonly'], + scopeVersion: 1, + required: false, + status: 'disabled' as const, + }, + ], +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { credentialGroupId: 'group-1', limit: 50 } + +function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialGroupId }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } +} + +describe('listCredentialGroupCredentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadGroup.mockResolvedValue(groupContext) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) + mocks.isCredentialGroupsAvailable.mockResolvedValue(true) + mocks.listCredentials.mockResolvedValue({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'person@example.com', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + nextCursor: 'credential-1', + }) + }) + + it('rejects unsupported principals before loading the group', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialGroupCredentials.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadGroup).not.toHaveBeenCalled() + }) + + it('rejects executor delegation scoped to another group', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal('group-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('returns a bounded page after current workspace and entitlement checks', async () => { + const result = await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.listCredentials).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + cursor: undefined, + email: undefined, + credentialProviderIds: undefined, + credentialGroupOptionIds: ['option-1'], + }) + expect(result).toEqual({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'person@example.com', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + count: 1, + hasMore: true, + nextCursor: 'credential-1', + }) + }) + + it('filters by canonical providers active in the group', async () => { + await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['google-email', 'google-email'] }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ credentialProviderIds: ['google-email'] }) + ) + }) + + it('normalizes an exact enrollment email filter', async () => { + await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, email: ' Person@Example.COM ' }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ email: 'person@example.com' }) + ) + }) + + it('rejects providers that are not active in the group before credential access', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['slack'] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('fails before listing when the group is disabled', async () => { + mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('fails before listing when Credential Groups are unavailable', async () => { + mocks.isCredentialGroupsAvailable.mockResolvedValue(false) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('rejects limits outside the bounded page size', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, limit: 101 }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('classifies a stale or cross-group cursor as invalid input', async () => { + mocks.listCredentials.mockRejectedValueOnce(new CredentialGroupCredentialCursorNotFoundError()) + + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, cursor: 'credential-other' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts new file mode 100644 index 00000000000..3dec5d6e958 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -0,0 +1,111 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupCredentialCursorNotFoundError, + type CredentialGroupCredentialReference, + listCredentialGroupCredentialReferences, + MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE, +} from '@/lib/credential-groups/credentials' +import { + getCredentialGroupProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +export interface ListCredentialGroupCredentialsInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + credentialProviderIds?: string[] +} + +export interface ListCredentialGroupCredentialsResult { + credentials: CredentialGroupCredentialReference[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listCredentials, + resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` + ) + } + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + + const credentialProviderIds = [...new Set(input.credentialProviderIds ?? [])] + if (credentialProviderIds.some((providerId) => !providerId.trim())) { + throw new OrchestrationError('validation', 'Credential provider IDs must not be empty') + } + const activeOptions = context.options.filter((option) => option.status === 'active') + const activeProviderIds = new Set( + activeOptions.map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Credential Group provider is not registered: ${option.provider}`) + } + return getCredentialGroupProviderId(option.provider) + }) + ) + const invalidProviderIds = credentialProviderIds.filter( + (providerId) => !activeProviderIds.has(providerId) + ) + if (invalidProviderIds.length > 0) { + throw new OrchestrationError( + 'validation', + `Credential providers are not active in this group: ${invalidProviderIds.join(', ')}` + ) + } + + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupCredentialReferences({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + credentialGroupOptionIds: activeOptions.map((option) => option.id), + limit: input.limit, + cursor: input.cursor, + email, + credentialProviderIds: credentialProviderIds.length > 0 ? credentialProviderIds : undefined, + }) + } catch (error) { + if (error instanceof CredentialGroupCredentialCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + credentials: page.credentials, + count: page.credentials.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/list-groups.ts b/apps/sim/lib/credential-groups/application/list-groups.ts new file mode 100644 index 00000000000..3a89e4fca2d --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-groups.ts @@ -0,0 +1,68 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupWorkspaceDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupWorkspaceContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupCursorNotFoundError, + type CredentialGroupSummary, + listCredentialGroupSummaries, + MAX_CREDENTIAL_GROUP_PAGE_SIZE, +} from '@/lib/credential-groups/groups' + +export interface ListCredentialGroupsInput { + workspaceId: string + limit: number + cursor?: string +} + +export interface ListCredentialGroupsResult { + credentialGroups: CredentialGroupSummary[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listGroups, + resolveContext: ({ input }: { input: ListCredentialGroupsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_PAGE_SIZE}` + ) + } + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupSummaries({ + workspaceId: context.workspaceId, + limit: input.limit, + cursor: input.cursor, + }) + } catch (error) { + if (error instanceof CredentialGroupCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + credentialGroups: page.credentialGroups, + count: page.credentialGroups.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts new file mode 100644 index 00000000000..418ee854c6b --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -0,0 +1,79 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupEnrollmentError, + type CredentialGroupEnrollmentStatus, + listCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' + +export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +] as const satisfies readonly CredentialGroupEnrollmentStatus[] + +export interface ListCredentialGroupPeopleInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + statuses?: CredentialGroupEnrollmentStatus[] +} + +export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listPeople, + resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }) => { + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) { + throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100') + } + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + const statuses = [...new Set(input.statuses ?? [])] + const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) + if (statuses.some((status) => !allowedStatuses.has(status))) { + throw new OrchestrationError('validation', 'People status filter is invalid') + } + await requireCredentialGroupsAvailable(context.workspaceId) + + try { + const page = await listCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + input.limit, + input.cursor, + { email, statuses: statuses.length > 0 ? statuses : undefined } + ) + return { + people: page.enrollments, + count: page.enrollments.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError) { + throw new OrchestrationError( + error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal', + error.message + ) + } + throw error + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts new file mode 100644 index 00000000000..dde2981efca --- /dev/null +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -0,0 +1,44 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const credentialGroupOperations = { + listCredentials: defineWorkspaceOperation({ + id: 'credential_groups.credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + listGroups: defineWorkspaceOperation({ + id: 'credential_groups.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + listPeople: defineWorkspaceOperation({ + id: 'credential_groups.people.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + sendInvite: defineWorkspaceOperation({ + id: 'credential_groups.invites.send', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + startSlackConfiguration: defineWorkspaceOperation({ + id: 'credential_groups.slack_configuration.start', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + completeSlackConfiguration: defineWorkspaceOperation({ + id: 'credential_groups.slack_configuration.complete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), +} as const diff --git a/apps/sim/lib/credential-groups/application/send-invite.ts b/apps/sim/lib/credential-groups/application/send-invite.ts new file mode 100644 index 00000000000..6ae7d855219 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/send-invite.ts @@ -0,0 +1,72 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupEnrollmentError, + inviteCredentialGroupEnrollment, + loadCredentialGroupInviterIdentity, +} from '@/lib/credential-groups/enrollments' + +export interface SendCredentialGroupInviteInput { + credentialGroupId: string + email: string +} + +export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.sendInvite, + resolveContext: ({ input }: { input: SendCredentialGroupInviteInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ principal, input, context }) => { + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + const email = normalizeEmail(input.email) + if (!isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + await requireCredentialGroupsAvailable(context.workspaceId) + + const userId = requirePrincipalSubjectUserId(principal) + const inviter = await loadCredentialGroupInviterIdentity(userId) + const inviterName = inviter?.name?.trim() || inviter?.email + if (!inviterName) { + throw new OrchestrationError('conflict', 'Inviting user has no display identity') + } + + try { + const enrollment = await inviteCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + userId, + inviterName, + email + ) + return { enrollment } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError) { + throw new OrchestrationError( + error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal', + error.message + ) + } + throw error + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Invited ${result.enrollment.email} to connect accounts`, + metadata: { email: normalizeEmail(input.email), enrollmentId: result.enrollment.id }, + }), +}) diff --git a/apps/sim/lib/credential-groups/application/slack-managed-users.ts b/apps/sim/lib/credential-groups/application/slack-managed-users.ts new file mode 100644 index 00000000000..a448edbd284 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/slack-managed-users.ts @@ -0,0 +1,138 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + consumeSlackManagedUsersAttempt, + createSlackManagedUsersAttempt, + exchangeAndConfigureSlackManagedUsers, + loadSlackManagedUsersAttempt, + type SlackManagedUsersAttempt, +} from '@/lib/credential-groups/slack-managed-users' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +async function requireCredentialGroups(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new OrchestrationError('not_found', 'Credential Groups are not available') + } +} + +async function resolveWorkspace(workspaceId: string) { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveCredentialGroup(groupId: string, assertedWorkspaceId: string) { + const [group] = await db + .select({ id: credentialGroup.id, workspaceId: credentialGroup.workspaceId }) + .from(credentialGroup) + .where(eq(credentialGroup.id, groupId)) + .limit(1) + if (!group || group.workspaceId !== assertedWorkspaceId) { + throw new OrchestrationError('not_found', 'Credential Group not found') + } + return { ...(await resolveWorkspace(group.workspaceId)), credentialGroupId: group.id } +} + +export interface StartSlackCredentialGroupConfigurationInput { + assertedWorkspaceId: string + credentialGroupId: string + slackBotCredentialId: string + clientId: string + clientSecret: string +} + +export const startSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.startSlackConfiguration, + resolveContext: ({ input }: { input: StartSlackCredentialGroupConfigurationInput }) => + resolveCredentialGroup(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroups(context.workspaceId) + return createSlackManagedUsersAttempt({ + workspaceId: context.workspaceId, + userId: principal.userId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: input.slackBotCredentialId, + clientId: input.clientId, + clientSecret: input.clientSecret, + }) + }, +}) + +interface SlackCredentialGroupConfigurationCallbackInput { + state: string + code?: string + providerError?: string +} + +type SlackCredentialGroupConfigurationCallbackContext = Awaited< + ReturnType +> & { + attempt: SlackManagedUsersAttempt +} + +export const completeSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.completeSlackConfiguration, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: SlackCredentialGroupConfigurationCallbackInput + }): Promise => { + const attempt = await loadSlackManagedUsersAttempt(input.state) + if (!attempt) { + throw new OrchestrationError('validation', 'Authorization state is invalid or expired') + } + if (attempt.userId !== principal.userId) { + throw new OrchestrationError( + 'forbidden', + 'Authorization must be completed by the user who started it' + ) + } + return { ...(await resolveWorkspace(attempt.workspaceId)), attempt } + }, + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroups(context.workspaceId) + const attempt = await consumeSlackManagedUsersAttempt(input.state) + if ( + !attempt || + attempt.workspaceId !== context.attempt.workspaceId || + attempt.userId !== context.attempt.userId || + attempt.credentialGroupId !== context.attempt.credentialGroupId || + attempt.slackBotCredentialId !== context.attempt.slackBotCredentialId || + attempt.clientId !== context.attempt.clientId || + attempt.createdAt !== context.attempt.createdAt + ) { + throw new OrchestrationError('validation', 'Authorization state is invalid or expired') + } + if (input.providerError) return { ok: false as const, reason: 'provider_error' as const } + if (!input.code) throw new OrchestrationError('validation', 'Authorization code is missing') + const result = await exchangeAndConfigureSlackManagedUsers({ attempt, code: input.code }) + return { ok: true as const, reason: 'authorized' as const, result } + }, + projectAudit: ({ result }) => + result.ok + ? { + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.result.credentialGroupId, + resourceName: result.result.credentialGroupName, + description: 'Configured Slack for a Credential Group', + metadata: { + slackBotCredentialId: result.result.slackBotCredentialId, + slackAppId: result.result.appId, + slackTeamId: result.result.teamId, + }, + } + : [], +}) diff --git a/apps/sim/lib/credential-groups/availability.ts b/apps/sim/lib/credential-groups/availability.ts new file mode 100644 index 00000000000..42fb1bad7c8 --- /dev/null +++ b/apps/sim/lib/credential-groups/availability.ts @@ -0,0 +1,11 @@ +import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces' +import { isHosted } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + +/** Credential Groups are globally gated and restricted to Enterprise workspaces on Sim Cloud. */ +export async function isCredentialGroupsAvailable( + ownerBilling: Pick +): Promise { + if (!(await isFeatureEnabled('credential-groups'))) return false + return !isHosted || ownerBilling.isEnterprise +} diff --git a/apps/sim/lib/credential-groups/credentials.test.ts b/apps/sim/lib/credential-groups/credentials.test.ts new file mode 100644 index 00000000000..200a2671d76 --- /dev/null +++ b/apps/sim/lib/credential-groups/credentials.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listCredentialGroupCredentialReferences } from '@/lib/credential-groups/credentials' + +describe('listCredentialGroupCredentialReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns the invited email associated with each managed credential', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + email: 'person@example.com', + displayName: 'Personal Gmail', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + createdAt: new Date('2026-08-12T12:00:00.000Z'), + }, + ]) + + const result = await listCredentialGroupCredentialReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupOptionIds: ['option-1'], + limit: 50, + }) + + expect(result).toEqual({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'Personal Gmail', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + nextCursor: null, + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts new file mode 100644 index 00000000000..4450ef0ff85 --- /dev/null +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -0,0 +1,173 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, +} from '@sim/db/schema' +import { and, asc, eq, gt, inArray, or } from 'drizzle-orm' + +export const MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE = 100 + +export interface CredentialGroupCredentialListContext { + credentialGroupId: string + workspaceId: string + name: string + status: 'active' | 'disabled' + options: CredentialGroupOptionConfig[] +} + +export interface CredentialGroupCredentialReference { + credentialId: string + email: string + displayName: string + providerId: string + providerSubjectId: string + providerTenantId: string | null +} + +export class CredentialGroupCredentialCursorNotFoundError extends Error { + constructor() { + super('Credential group credential cursor not found') + this.name = 'CredentialGroupCredentialCursorNotFoundError' + } +} + +interface ListCredentialGroupCredentialReferencesInput { + workspaceId: string + credentialGroupId: string + limit: number + cursor?: string + email?: string + credentialProviderIds?: string[] + credentialGroupOptionIds: string[] +} + +/** Loads the canonical group ownership needed by the application authorization boundary. */ +export async function loadCredentialGroupCredentialListContext( + credentialGroupId: string +): Promise { + const [row] = await db + .select({ + credentialGroupId: credentialGroup.id, + workspaceId: credentialGroup.workspaceId, + name: credentialGroup.name, + status: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .where(eq(credentialGroup.id, credentialGroupId)) + .limit(1) + return row ?? null +} + +/** Lists one bounded page of active managed credentials without selecting token material. */ +export async function listCredentialGroupCredentialReferences({ + workspaceId, + credentialGroupId, + limit, + cursor, + email, + credentialProviderIds, + credentialGroupOptionIds, +}: ListCredentialGroupCredentialReferencesInput): Promise<{ + credentials: CredentialGroupCredentialReference[] + nextCursor: string | null +}> { + if (credentialGroupOptionIds.length === 0) { + if (cursor) throw new CredentialGroupCredentialCursorNotFoundError() + return { credentials: [], nextCursor: null } + } + + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credential.id, createdAt: credential.createdAt }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.id, cursor), + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_oauth'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), + email ? eq(credentialGroupEnrollment.email, email) : undefined, + credentialProviderIds?.length + ? inArray(credential.providerId, credentialProviderIds) + : undefined, + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']) + ) + ) + .limit(1) + if (!cursorRow) throw new CredentialGroupCredentialCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credential.id, + email: credentialGroupEnrollment.email, + displayName: credential.displayName, + providerId: credential.providerId, + providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, + createdAt: credential.createdAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_oauth'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), + email ? eq(credentialGroupEnrollment.email, email) : undefined, + credentialProviderIds?.length + ? inArray(credential.providerId, credentialProviderIds) + : undefined, + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + cursorPosition + ? or( + gt(credential.createdAt, cursorPosition.createdAt), + and( + eq(credential.createdAt, cursorPosition.createdAt), + gt(credential.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(asc(credential.createdAt), asc(credential.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('Credential page cursor could not be derived') + return { + credentials: pageRows.map((row) => { + if (!row.providerId) throw new Error(`Managed credential ${row.id} has no provider ID`) + if (!row.providerSubjectId) { + throw new Error(`Managed credential ${row.id} has no provider subject ID`) + } + return { + credentialId: row.id, + email: row.email, + displayName: row.displayName, + providerId: row.providerId, + providerSubjectId: row.providerSubjectId, + providerTenantId: row.providerTenantId, + } + }), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts new file mode 100644 index 00000000000..021863f8bcb --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/components/emails/render', () => ({ + renderCredentialGroupInvitationEmail: vi.fn(), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: vi.fn() })) + +import { listCredentialGroupEnrollments } from '@/lib/credential-groups/enrollments' +import { CREDENTIAL_GROUP_PROVIDER_IDS } from '@/lib/credential-groups/providers' + +const MAX_CONNECTION_SUMMARIES = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3 + +const ENROLLMENT = { + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'alex@example.com', + status: 'completed' as const, + invitationTokenHash: 'a'.repeat(64), + invitationExpiresAt: new Date('2026-08-18T12:00:00.000Z'), + invitedAt: new Date('2026-08-11T12:00:00.000Z'), + sentAt: new Date('2026-08-11T12:00:01.000Z'), + completedAt: new Date('2026-08-11T12:05:00.000Z'), + revokedAt: null, + lastDeliveryError: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-11T12:00:00.000Z'), + updatedAt: new Date('2026-08-11T12:05:00.000Z'), +} + +describe('listCredentialGroupEnrollments', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns bounded provider summaries instead of materializing every credential', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ + { + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'active', + count: 2, + }, + { + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'needs_reauth', + count: 1, + }, + ]) + + const result = await listCredentialGroupEnrollments('workspace-1', 'group-1', 50) + + expect(result.enrollments[0]?.connections).toEqual([ + { provider: 'gmail', status: 'active', count: 2 }, + { provider: 'gmail', status: 'needs_reauth', count: 1 }, + ]) + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(3, MAX_CONNECTION_SUMMARIES + 1) + }) + + it('fails fast when a managed credential uses an unsupported provider', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ + { + enrollmentId: ENROLLMENT.id, + providerId: 'unexpected-provider', + status: 'active', + count: 1, + }, + ]) + + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 50)).rejects.toThrow( + 'Unsupported managed credential provider: unexpected-provider' + ) + }) + + it('rejects connection summaries beyond the bounded provider-state cardinality', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce( + Array.from({ length: MAX_CONNECTION_SUMMARIES + 1 }, (_, index) => ({ + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'active', + count: index + 1, + })) + ) + + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 50)).rejects.toThrow( + 'Managed credential connection summaries exceed the supported provider states' + ) + }) + + it('rejects an unbounded enrollment page request', async () => { + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 101)).rejects.toThrow( + 'Credential group enrollment limit must be between 1 and 100' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts new file mode 100644 index 00000000000..dde3ed0f15c --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -0,0 +1,687 @@ +import { createHash } from 'crypto' +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, + user, + workspace, +} from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { normalizeEmail, truncate } from '@sim/utils/string' +import { and, count, desc, eq, inArray, lt, or } from 'drizzle-orm' +import { renderCredentialGroupInvitationEmail } from '@/components/emails/render' +import type { + CredentialGroupEnrollment, + CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentDetail, + InviteCredentialGroupEnrollmentsBody, +} from '@/lib/api/contracts/credential-groups' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + getCredentialGroupProviderFromProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { getBrandConfig } from '@/ee/whitelabeling' + +const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const DELIVERY_CONCURRENCY = 5 +const MAX_ENROLLMENT_PAGE_SIZE = 100 +const CONNECTION_SUMMARIES_PER_ENROLLMENT = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3 + +type EnrollmentRow = typeof credentialGroupEnrollment.$inferSelect + +export type CredentialGroupEnrollmentStatus = EnrollmentRow['status'] + +export interface ListCredentialGroupEnrollmentFilters { + email?: string + statuses?: CredentialGroupEnrollmentStatus[] +} + +interface InvitationContext { + workspaceId: string + workspaceName: string + groupId: string + groupName: string +} + +export interface PublicCredentialGroupEnrollment { + inviterName: string + workspaceName: string + credentialGroupName: string + options: Array< + Pick & { + provider: CredentialGroupProvider + connections: Array<{ + email: string + displayName: string | null + avatarUrl: string | null + status: 'connected' | 'needs_reauth' | 'revoked' + grantedAt: string + }> + } + > + status: CredentialGroupEnrollment['status'] +} + +export interface CredentialGroupOAuthContext { + enrollmentId: string + credentialGroupId: string + workspaceId: string + workspaceName: string + workspaceOwnerId: string + email: string + enrollmentStatus: EnrollmentRow['status'] + option: CredentialGroupOptionConfig + options: CredentialGroupOptionConfig[] +} + +export class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: 404 | 409 | 502 + ) { + super(message) + this.name = 'CredentialGroupEnrollmentError' + } +} + +function hashInvitationToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function metadataString(metadata: object | null, key: string): string | null { + const value = metadata ? (metadata as Record)[key] : undefined + return typeof value === 'string' && value.length > 0 ? value : null +} + +async function resolvePublicEnrollmentRow(token: string) { + const [row] = await db + .select({ + enrollment: credentialGroupEnrollment, + groupId: credentialGroup.id, + groupName: credentialGroup.name, + groupStatus: credentialGroup.status, + options: credentialGroup.options, + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceOwnerId: workspace.ownerId, + inviterName: user.name, + }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(workspace, eq(workspace.id, credentialGroup.workspaceId)) + .leftJoin(user, eq(user.id, credentialGroupEnrollment.createdBy)) + .where(eq(credentialGroupEnrollment.invitationTokenHash, hashInvitationToken(token))) + .limit(1) + + if (!row || row.groupStatus !== 'active') return null + if (row.enrollment.status === 'revoked' || row.enrollment.status === 'delivery_failed') + return null + if (row.enrollment.invitationExpiresAt.getTime() <= Date.now()) return null + + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(row.workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) return null + return row +} + +function toCredentialGroupEnrollment(row: EnrollmentRow): CredentialGroupEnrollment { + return { + id: row.id, + credentialGroupId: row.credentialGroupId, + email: row.email, + status: row.status, + expiresAt: row.invitationExpiresAt.toISOString(), + invitedAt: row.invitedAt.toISOString(), + sentAt: row.sentAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + revokedAt: row.revokedAt?.toISOString() ?? null, + expired: row.invitationExpiresAt.getTime() <= Date.now(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +function toCredentialGroupConnectionProvider( + providerId: string | null +): CredentialGroupEnrollmentConnection['provider'] { + if (!providerId) throw new Error('Managed credential provider is missing') + return getCredentialGroupProviderFromProviderId(providerId) +} + +function toCredentialGroupConnectionStatus( + status: (typeof credential.$inferSelect)['managedOauthStatus'] +): CredentialGroupEnrollmentConnection['status'] { + if (status === 'active' || status === 'needs_reauth' || status === 'revoked') return status + throw new Error('Managed credential status is missing') +} + +async function getInvitationContext( + workspaceId: string, + groupId: string +): Promise { + const [row] = await db + .select({ + workspaceId: credentialGroup.workspaceId, + workspaceName: workspace.name, + groupId: credentialGroup.id, + groupName: credentialGroup.name, + groupStatus: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .innerJoin(workspace, eq(workspace.id, credentialGroup.workspaceId)) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + + if (!row) throw new CredentialGroupEnrollmentError('Credential group not found', 404) + if (row.groupStatus !== 'active') { + throw new CredentialGroupEnrollmentError('Credential group is disabled', 409) + } + if (!row.options.some((option) => option.status === 'active')) { + throw new CredentialGroupEnrollmentError('Add an account type before inviting people', 409) + } + return row +} + +async function sendInvitation( + context: InvitationContext, + userId: string, + inviterName: string, + email: string +): Promise { + const now = new Date() + const token = generateId() + const tokenHash = hashInvitationToken(token) + const expiresAt = new Date(now.getTime() + INVITATION_TTL_MS) + + const [issued] = await db + .insert(credentialGroupEnrollment) + .values({ + id: generateId(), + credentialGroupId: context.groupId, + email, + status: 'invited', + invitationTokenHash: tokenHash, + invitationExpiresAt: expiresAt, + invitedAt: now, + sentAt: null, + completedAt: null, + revokedAt: null, + lastDeliveryError: null, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [credentialGroupEnrollment.credentialGroupId, credentialGroupEnrollment.email], + set: { + status: 'invited', + invitationTokenHash: tokenHash, + invitationExpiresAt: expiresAt, + invitedAt: now, + sentAt: null, + completedAt: null, + revokedAt: null, + lastDeliveryError: null, + createdBy: userId, + updatedAt: now, + }, + }) + .returning() + if (!issued) throw new Error('Credential group enrollment upsert returned no row') + + const invitationLink = `${getBaseUrl()}/credential-groups/enroll/${token}` + const html = await renderCredentialGroupInvitationEmail({ + recipientEmail: email, + inviterName, + workspaceName: context.workspaceName, + credentialGroupName: context.groupName, + invitationLink, + }) + const result = await sendEmail({ + to: email, + subject: `${inviterName} invited you to connect accounts for ${context.workspaceName} on ${getBrandConfig().name}`, + html, + from: getFromEmailAddress(), + emailType: 'transactional', + }) + + if (!result.success) { + await db + .update(credentialGroupEnrollment) + .set({ + status: 'delivery_failed', + lastDeliveryError: truncate(result.message, 500), + updatedAt: new Date(), + }) + .where( + and( + eq(credentialGroupEnrollment.id, issued.id), + eq(credentialGroupEnrollment.invitationTokenHash, tokenHash) + ) + ) + throw new CredentialGroupEnrollmentError(result.message, 502) + } + + const [sent] = await db + .update(credentialGroupEnrollment) + .set({ sentAt: new Date(), lastDeliveryError: null, updatedAt: new Date() }) + .where( + and( + eq(credentialGroupEnrollment.id, issued.id), + eq(credentialGroupEnrollment.invitationTokenHash, tokenHash) + ) + ) + .returning() + if (!sent) { + throw new CredentialGroupEnrollmentError( + 'Invitation was superseded by another delivery request', + 409 + ) + } + return toCredentialGroupEnrollment(sent) +} + +export async function listCredentialGroupEnrollments( + workspaceId: string, + groupId: string, + limit: number, + cursor?: string, + filters: ListCredentialGroupEnrollmentFilters = {} +): Promise<{ enrollments: CredentialGroupEnrollmentDetail[]; nextCursor: string | null }> { + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ENROLLMENT_PAGE_SIZE) { + throw new Error( + `Credential group enrollment limit must be between 1 and ${MAX_ENROLLMENT_PAGE_SIZE}` + ) + } + const [group] = await db + .select({ options: credentialGroup.options }) + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + if (!group) throw new CredentialGroupEnrollmentError('Credential group not found', 404) + const activeOptionIds = group.options + .filter((option) => option.status === 'active') + .map((option) => option.id) + + let cursorPosition: { id: string; invitedAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credentialGroupEnrollment.id, invitedAt: credentialGroupEnrollment.invitedAt }) + .from(credentialGroupEnrollment) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .where( + and( + eq(credentialGroupEnrollment.id, cursor), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId), + filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, + filters.statuses?.length + ? inArray(credentialGroupEnrollment.status, filters.statuses) + : undefined + ) + ) + .limit(1) + if (!cursorRow) throw new CredentialGroupEnrollmentError('Enrollment cursor not found', 404) + cursorPosition = cursorRow + } + + const rows = await db + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId), + filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, + filters.statuses?.length + ? inArray(credentialGroupEnrollment.status, filters.statuses) + : undefined, + cursorPosition + ? or( + lt(credentialGroupEnrollment.invitedAt, cursorPosition.invitedAt), + and( + eq(credentialGroupEnrollment.invitedAt, cursorPosition.invitedAt), + lt(credentialGroupEnrollment.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(desc(credentialGroupEnrollment.invitedAt), desc(credentialGroupEnrollment.id)) + .limit(limit + 1) + const hasNextPage = rows.length > limit + const pageRows = hasNextPage ? rows.slice(0, limit) : rows + const enrollmentIds = pageRows.map(({ enrollment }) => enrollment.id) + const connectionSummaryLimit = enrollmentIds.length * CONNECTION_SUMMARIES_PER_ENROLLMENT + const connectionRows = + enrollmentIds.length === 0 || activeOptionIds.length === 0 + ? [] + : await db + .select({ + enrollmentId: credential.credentialGroupEnrollmentId, + providerId: credential.providerId, + status: credential.managedOauthStatus, + count: count(credential.id), + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.credentialGroupOptionId, activeOptionIds) + ) + ) + .groupBy( + credential.credentialGroupEnrollmentId, + credential.providerId, + credential.managedOauthStatus + ) + .limit(connectionSummaryLimit + 1) + if (connectionRows.length > connectionSummaryLimit) { + throw new Error('Managed credential connection summaries exceed the supported provider states') + } + const connectionsByEnrollment = new Map() + for (const connection of connectionRows) { + if (!connection.enrollmentId) { + throw new Error('Managed credential enrollment ID is missing') + } + const summary: CredentialGroupEnrollmentConnection = { + provider: toCredentialGroupConnectionProvider(connection.providerId), + status: toCredentialGroupConnectionStatus(connection.status), + count: connection.count, + } + const current = connectionsByEnrollment.get(connection.enrollmentId) + if (current) current.push(summary) + else connectionsByEnrollment.set(connection.enrollmentId, [summary]) + } + return { + enrollments: pageRows.map(({ enrollment }) => ({ + ...toCredentialGroupEnrollment(enrollment), + connections: connectionsByEnrollment.get(enrollment.id) ?? [], + })), + nextCursor: hasNextPage ? (pageRows.at(-1)?.enrollment.id ?? null) : null, + } +} + +export async function inviteCredentialGroupEnrollments( + workspaceId: string, + groupId: string, + userId: string, + inviterName: string, + body: InviteCredentialGroupEnrollmentsBody +) { + const context = await getInvitationContext(workspaceId, groupId) + const emails = [...new Set(body.emails.map(normalizeEmail))] + const results: Array< + | { email: string; success: true; enrollment: CredentialGroupEnrollment } + | { email: string; success: false; error: string } + > = [] + + for (let index = 0; index < emails.length; index += DELIVERY_CONCURRENCY) { + const chunk = emails.slice(index, index + DELIVERY_CONCURRENCY) + const chunkResults = await Promise.all( + chunk.map(async (email) => { + try { + const enrollment = await sendInvitation(context, userId, inviterName, email) + return { email, success: true as const, enrollment } + } catch (error) { + return { + email, + success: false as const, + error: getErrorMessage(error, 'Failed to send invitation'), + } + } + }) + ) + results.push(...chunkResults) + } + + const sentCount = results.filter((result) => result.success).length + return { results, sentCount, failedCount: results.length - sentCount } +} + +export async function loadCredentialGroupInviterIdentity( + userId: string +): Promise<{ name: string | null; email: string } | null> { + const [row] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + return row ?? null +} + +export async function inviteCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + userId: string, + inviterName: string, + email: string +): Promise { + const context = await getInvitationContext(workspaceId, groupId) + return sendInvitation(context, userId, inviterName, normalizeEmail(email)) +} + +export async function resendCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + enrollmentId: string, + userId: string, + inviterName: string +): Promise { + const context = await getInvitationContext(workspaceId, groupId) + const [row] = await db + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!row) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + if (row.enrollment.status === 'revoked') { + throw new CredentialGroupEnrollmentError('Revoked enrollment cannot be resent', 409) + } + return sendInvitation(context, userId, inviterName, row.enrollment.email) +} + +export async function revokeCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + enrollmentId: string +): Promise { + const [existing] = await db + .select({ enrollmentId: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!existing) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + + return db.transaction(async (tx) => { + const now = new Date() + const [revoked] = await tx + .update(credentialGroupEnrollment) + .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroupEnrollment.credentialGroupId, groupId) + ) + ) + .returning() + if (!revoked) throw new Error('Credential group enrollment update returned no row') + + await tx + .update(credential) + .set({ managedOauthStatus: 'revoked', revokedAt: now, updatedAt: now }) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, enrollmentId) + ) + ) + return toCredentialGroupEnrollment(revoked) + }) +} + +export async function getPublicCredentialGroupEnrollment( + token: string +): Promise { + const row = await resolvePublicEnrollmentRow(token) + if (!row) return null + + return buildPublicCredentialGroupEnrollment(row) +} + +async function buildPublicCredentialGroupEnrollment( + row: NonNullable>> +): Promise { + const connectionRows = await db + .select({ + optionId: credential.credentialGroupOptionId, + status: credential.managedOauthStatus, + scopeVersion: credential.managedOauthScopeVersion, + authorizationAppId: credential.authorizationAppId, + grantedScopes: credential.grantedScopes, + displayName: credential.displayName, + metadata: credential.providerMetadata, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ) + + return { + inviterName: row.inviterName ?? 'A workspace admin', + workspaceName: row.workspaceName, + credentialGroupName: row.groupName, + options: await Promise.all( + row.options.map(async (option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const adapter = getCredentialGroupProviderAdapter(option.provider) + const policy = await adapter.getPolicy(option, { + workspaceId: row.workspaceId, + credentialGroupId: row.groupId, + }) + return { + id: option.id, + provider: option.provider, + label: option.label, + required: option.required, + status: option.status, + connections: connectionRows + .filter((connection) => connection.optionId === option.id && connection.grantedAt) + .map((connection) => { + const email = metadataString(connection.metadata, 'email') ?? connection.displayName + const status = + connection.status === 'revoked' + ? ('revoked' as const) + : connection.status !== 'active' || + connection.authorizationAppId !== policy.authorizationAppId || + connection.scopeVersion !== policy.scopeVersion || + !adapter.hasRequiredScopes( + connection.grantedScopes ?? [], + policy.requiredScopes + ) + ? ('needs_reauth' as const) + : ('connected' as const) + return { + email, + displayName: + metadataString(connection.metadata, 'displayName') ?? + metadataString(connection.metadata, 'name'), + avatarUrl: + metadataString(connection.metadata, 'avatarUrl') ?? + metadataString(connection.metadata, 'picture'), + status, + grantedAt: connection.grantedAt!.toISOString(), + } + }), + } + }) + ), + status: row.enrollment.status, + } +} + +/** Finalizes an enrollment only after every active credential option has one usable connection. */ +export async function completeCredentialGroupEnrollment(token: string): Promise { + const row = await resolvePublicEnrollmentRow(token) + if (!row) return null + const enrollment = await buildPublicCredentialGroupEnrollment(row) + const activeOptions = enrollment.options.filter((option) => option.status === 'active') + const allConnected = + activeOptions.length > 0 && + activeOptions.every( + (option) => option.connections.length === 1 && option.connections[0]?.status === 'connected' + ) + if (!allConnected) return false + + const now = new Date() + const [completed] = await db + .update(credentialGroupEnrollment) + .set({ status: 'completed', completedAt: now, updatedAt: now }) + .where( + and( + eq(credentialGroupEnrollment.id, row.enrollment.id), + inArray(credentialGroupEnrollment.status, ['invited', 'in_progress', 'completed']) + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!completed) throw new Error('Credential group enrollment completion returned no row') + return true +} + +/** Resolves the private, server-only context bound to a public enrollment link and option. */ +export async function getCredentialGroupOAuthContext( + token: string, + optionId: string +): Promise { + const row = await resolvePublicEnrollmentRow(token) + if (!row) return null + const option = row.options.find((candidate) => candidate.id === optionId) + if (!option || option.status !== 'active') return null + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + workspaceName: row.workspaceName, + workspaceOwnerId: row.workspaceOwnerId, + email: row.enrollment.email, + enrollmentStatus: row.enrollment.status, + option, + options: row.options, + } +} diff --git a/apps/sim/lib/credential-groups/groups.ts b/apps/sim/lib/credential-groups/groups.ts new file mode 100644 index 00000000000..abe6ed60b62 --- /dev/null +++ b/apps/sim/lib/credential-groups/groups.ts @@ -0,0 +1,110 @@ +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { and, desc, eq, lt, or } from 'drizzle-orm' +import { + getCredentialGroupProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +export const MAX_CREDENTIAL_GROUP_PAGE_SIZE = 100 + +export interface CredentialGroupSummary { + id: string + name: string + description: string | null + status: 'active' | 'disabled' + providerIds: string[] + createdAt: string + updatedAt: string +} + +export class CredentialGroupCursorNotFoundError extends Error { + constructor() { + super('Credential group cursor not found') + this.name = 'CredentialGroupCursorNotFoundError' + } +} + +interface ListCredentialGroupSummariesInput { + workspaceId: string + limit: number + cursor?: string +} + +/** Lists a bounded page of group metadata without decrypting provider configuration. */ +export async function listCredentialGroupSummaries({ + workspaceId, + limit, + cursor, +}: ListCredentialGroupSummariesInput): Promise<{ + credentialGroups: CredentialGroupSummary[] + nextCursor: string | null +}> { + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credentialGroup.id, createdAt: credentialGroup.createdAt }) + .from(credentialGroup) + .where(and(eq(credentialGroup.id, cursor), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + if (!cursorRow) throw new CredentialGroupCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credentialGroup.id, + name: credentialGroup.name, + description: credentialGroup.description, + status: credentialGroup.status, + options: credentialGroup.options, + createdAt: credentialGroup.createdAt, + updatedAt: credentialGroup.updatedAt, + }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, workspaceId), + cursorPosition + ? or( + lt(credentialGroup.createdAt, cursorPosition.createdAt), + and( + eq(credentialGroup.createdAt, cursorPosition.createdAt), + lt(credentialGroup.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(desc(credentialGroup.createdAt), desc(credentialGroup.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('Credential group page cursor could not be derived') + + return { + credentialGroups: pageRows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + status: row.status, + providerIds: [ + ...new Set( + row.options + .filter((option) => option.status === 'active') + .map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Credential Group provider is not registered: ${option.provider}`) + } + return getCredentialGroupProviderId(option.provider) + }) + ), + ], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/oauth-state.test.ts b/apps/sim/lib/credential-groups/oauth-state.test.ts new file mode 100644 index 00000000000..a172785076e --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-state.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRedis, values } = vi.hoisted(() => { + const values = new Map() + return { + values, + mockRedis: { + set: vi.fn(async (key: string, value: string) => { + if (values.has(key)) return null + values.set(key, value) + return 'OK' + }), + eval: vi.fn(async (_script: string, _keyCount: number, key: string) => { + const value = values.get(key) ?? null + values.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: vi.fn(() => mockRedis), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) + +import { getRedisClient } from '@/lib/core/config/redis' +import { + consumeCredentialGroupOAuthAttempt, + createCredentialGroupOAuthAttempt, + credentialGroupOAuthNonceMatches, +} from '@/lib/credential-groups/oauth-state' + +describe('credential group OAuth state', () => { + beforeEach(() => { + vi.clearAllMocks() + values.clear() + vi.mocked(getRedisClient).mockReturnValue(mockRedis as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('stores encrypted attempt material and consumes state once', async () => { + const created = await createCredentialGroupOAuthAttempt({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:app', + scopeVersion: 1, + requiredScopes: ['openid', 'email'], + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + + const stored = [...values.values()][0] + expect(stored).not.toContain('code-verifier') + expect(stored).not.toContain('invitation-token') + + const consumed = await consumeCredentialGroupOAuthAttempt(created.state) + expect(consumed).toMatchObject({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + expect(credentialGroupOAuthNonceMatches(created.nonce, consumed?.nonceHash ?? '')).toBe(true) + await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() + }) + + it('fails closed when Redis is unavailable', async () => { + vi.mocked(getRedisClient).mockReturnValue(null) + + await expect( + createCredentialGroupOAuthAttempt({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:app', + scopeVersion: 1, + requiredScopes: ['openid'], + redirectUri: 'https://sim.ai/callback', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('Credential group OAuth requires Redis') + }) + + it('supports providers without PKCE while preserving one-time state', async () => { + const created = await createCredentialGroupOAuthAttempt({ + provider: 'slack', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'slack:A123:T123', + scopeVersion: 1, + requiredScopes: ['users:read'], + redirectUri: 'https://sim.ai/api/credential-groups/oauth/slack/callback', + invitationToken: 'invitation-token', + }) + + const consumed = await consumeCredentialGroupOAuthAttempt(created.state) + + expect(consumed).toMatchObject({ + provider: 'slack', + authorizationAppId: 'slack:A123:T123', + invitationToken: 'invitation-token', + }) + expect(consumed?.codeVerifier).toBeUndefined() + await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts new file mode 100644 index 00000000000..a9b00a9849a --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -0,0 +1,180 @@ +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + type CredentialGroupProvider, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +const OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const OAUTH_ATTEMPT_VERSION = 2 as const + +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface StoredCredentialGroupOAuthAttempt { + version: typeof OAUTH_ATTEMPT_VERSION + provider: CredentialGroupProvider + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + nonceHash: string + encryptedCodeVerifier?: string + encryptedInvitationToken: string + createdAt: number +} + +export interface CredentialGroupOAuthAttempt { + state: string + provider: CredentialGroupProvider + nonceHash: string + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + codeVerifier?: string + invitationToken: string + createdAt: number +} + +interface CreateCredentialGroupOAuthAttemptParams { + provider: CredentialGroupProvider + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + codeVerifier?: string + invitationToken: string +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) { + throw new Error('Credential group OAuth requires Redis') + } + return redis +} + +function attemptKey(state: string): string { + return `credential-group:oauth-attempt:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === OAUTH_ATTEMPT_VERSION && + typeof candidate.provider === 'string' && + isCredentialGroupProvider(candidate.provider) && + typeof candidate.enrollmentId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.optionId === 'string' && + typeof candidate.authorizationAppId === 'string' && + typeof candidate.scopeVersion === 'number' && + Number.isInteger(candidate.scopeVersion) && + candidate.scopeVersion > 0 && + Array.isArray(candidate.requiredScopes) && + candidate.requiredScopes.length > 0 && + candidate.requiredScopes.every((scope) => typeof scope === 'string' && scope.length > 0) && + typeof candidate.redirectUri === 'string' && + typeof candidate.nonceHash === 'string' && + (candidate.encryptedCodeVerifier === undefined || + typeof candidate.encryptedCodeVerifier === 'string') && + typeof candidate.encryptedInvitationToken === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +/** Creates a short-lived, one-time OAuth attempt. Only state and nonce leave the server. */ +export async function createCredentialGroupOAuthAttempt( + params: CreateCredentialGroupOAuthAttemptParams +): Promise<{ state: string; nonce: string }> { + const redis = requireRedis() + const state = generateId() + const nonce = generateId() + const [encryptedCodeVerifier, encryptedInvitationToken] = await Promise.all([ + params.codeVerifier ? encryptSecret(params.codeVerifier) : undefined, + encryptSecret(params.invitationToken), + ]) + const attempt: StoredCredentialGroupOAuthAttempt = { + version: OAUTH_ATTEMPT_VERSION, + provider: params.provider, + enrollmentId: params.enrollmentId, + credentialGroupId: params.credentialGroupId, + optionId: params.optionId, + authorizationAppId: params.authorizationAppId, + scopeVersion: params.scopeVersion, + requiredScopes: params.requiredScopes, + redirectUri: params.redirectUri, + nonceHash: sha256Hex(nonce), + ...(encryptedCodeVerifier ? { encryptedCodeVerifier: encryptedCodeVerifier.encrypted } : {}), + encryptedInvitationToken: encryptedInvitationToken.encrypted, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(state), + JSON.stringify(attempt), + 'PX', + OAUTH_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Credential group OAuth state collision') + return { state, nonce } +} + +/** Atomically burns state before the single-use authorization code is exchanged. */ +export async function consumeCredentialGroupOAuthAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.eval(CONSUME_SCRIPT, 1, attemptKey(state)) + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Credential group OAuth state is malformed') + + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Credential group OAuth state is malformed') + if (Date.now() - parsed.createdAt > OAUTH_ATTEMPT_TTL_MS) return null + + const [codeVerifier, invitationToken] = await Promise.all([ + parsed.encryptedCodeVerifier ? decryptSecret(parsed.encryptedCodeVerifier) : undefined, + decryptSecret(parsed.encryptedInvitationToken), + ]) + return { + state, + provider: parsed.provider, + nonceHash: parsed.nonceHash, + enrollmentId: parsed.enrollmentId, + credentialGroupId: parsed.credentialGroupId, + optionId: parsed.optionId, + authorizationAppId: parsed.authorizationAppId, + scopeVersion: parsed.scopeVersion, + requiredScopes: parsed.requiredScopes, + redirectUri: parsed.redirectUri, + ...(codeVerifier ? { codeVerifier: codeVerifier.decrypted } : {}), + invitationToken: invitationToken.decrypted, + createdAt: parsed.createdAt, + } +} + +/** Compares a verified ID-token nonce with the hash retained in the OAuth attempt. */ +export function credentialGroupOAuthNonceMatches(nonce: string, storedNonceHash: string): boolean { + return safeCompare(sha256Hex(nonce), storedNonceHash) +} diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts new file mode 100644 index 00000000000..f9c78956019 --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -0,0 +1,220 @@ +import { db } from '@sim/db' +import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, sql } from 'drizzle-orm' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import { + type CredentialGroupOAuthAttempt, + createCredentialGroupOAuthAttempt, +} from '@/lib/credential-groups/oauth-state' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, + VerifiedCredentialGroupGrant, +} from '@/lib/credential-groups/provider-adapter' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + getCredentialGroupProviderService, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import { + decryptManagedOAuthTokenSet, + encryptManagedOAuthTokenSet, +} from '@/lib/credentials/managed-oauth' + +function scopesEqual(left: string[], right: string[]): boolean { + const normalizedLeft = [...new Set(left)].sort() + const normalizedRight = [...new Set(right)].sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((scope, index) => scope === normalizedRight[index]) + ) +} + +function getOptionAdapter(context: CredentialGroupOAuthContext): CredentialGroupProviderAdapter { + if (!isCredentialGroupProvider(context.option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${context.option.provider}`) + } + return getCredentialGroupProviderAdapter(context.option.provider) +} + +async function assertCurrentPolicy( + context: CredentialGroupOAuthContext, + adapter: CredentialGroupProviderAdapter, + attempt?: CredentialGroupOAuthAttempt +): Promise { + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const optionMatches = context.option.provider === policy.provider + const attemptMatches = + !attempt || + (attempt.provider === policy.provider && + attempt.authorizationAppId === policy.authorizationAppId && + attempt.scopeVersion === policy.scopeVersion && + scopesEqual(attempt.requiredScopes, policy.requiredScopes)) + if (!optionMatches || !attemptMatches) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + return policy +} + +/** Builds a provider authorization URL after persisting a provider-bound one-time attempt. */ +export async function startCredentialGroupOAuth( + context: CredentialGroupOAuthContext, + invitationToken: string +): Promise { + const adapter = getOptionAdapter(context) + const policy = await assertCurrentPolicy(context, adapter) + const prepared = await adapter.prepareAuthorization(context, policy) + const { state, nonce } = await createCredentialGroupOAuthAttempt({ + provider: policy.provider, + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + optionId: context.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes: policy.requiredScopes, + redirectUri: prepared.redirectUri, + codeVerifier: prepared.codeVerifier, + invitationToken, + }) + return await prepared.buildAuthorizationUrl({ state, nonce }) +} + +async function persistGrant( + context: CredentialGroupOAuthContext, + adapter: CredentialGroupProviderAdapter, + policy: CredentialGroupProviderPolicy, + grant: VerifiedCredentialGroupGrant +): Promise { + if (grant.providerId !== policy.providerId) { + throw new CredentialGroupOAuthError('Provider returned a credential for another app.', 502) + } + + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-oauth:${context.enrollmentId}:${context.option.id}`}, 0))` + ) + const [existing] = await tx + .select({ + id: credential.id, + providerSubjectId: credential.providerSubjectId, + encryptedOauthTokenSet: credential.encryptedOauthTokenSet, + refreshTokenExpiresAt: credential.refreshTokenExpiresAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, context.enrollmentId), + eq(credential.credentialGroupOptionId, context.option.id) + ) + ) + .limit(1) + + let refreshToken = grant.refreshToken + if ( + !refreshToken && + existing?.providerSubjectId === grant.providerSubjectId && + existing.encryptedOauthTokenSet + ) { + refreshToken = (await decryptManagedOAuthTokenSet(existing.encryptedOauthTokenSet)) + .refreshToken + } + if (adapter.requiresRefreshToken && !refreshToken) { + const service = getCredentialGroupProviderService(policy.provider) + throw new CredentialGroupOAuthError( + `${service.name} did not issue offline access. Remove Sim from the provider and try again.`, + 409 + ) + } + + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ + accessToken: grant.accessToken, + ...(refreshToken ? { refreshToken } : {}), + }) + const now = new Date() + const service = getCredentialGroupProviderService(policy.provider) + const values = { + workspaceId: context.workspaceId, + type: 'managed_oauth' as const, + displayName: grant.displayName, + description: `Managed ${service.name} account for ${context.workspaceName}`, + providerId: policy.providerId, + accountId: null, + authorizationAppId: policy.authorizationAppId, + credentialGroupEnrollmentId: context.enrollmentId, + credentialGroupOptionId: context.option.id, + managedOauthScopeVersion: policy.scopeVersion, + providerSubjectId: grant.providerSubjectId, + providerTenantId: grant.providerTenantId, + managedOauthStatus: 'active' as const, + grantedScopes: grant.grantedScopes, + providerMetadata: grant.metadata, + encryptedOauthTokenSet, + grantedAt: now, + revokedAt: null, + accessTokenExpiresAt: grant.accessTokenExpiresAt, + refreshTokenExpiresAt: grant.refreshTokenExpiresAt ?? existing?.refreshTokenExpiresAt ?? null, + lastRefreshedAt: null, + updatedAt: now, + } + + if (existing) { + const [updated] = await tx + .update(credential) + .set(values) + .where(eq(credential.id, existing.id)) + .returning({ id: credential.id }) + if (!updated) throw new Error('Managed OAuth credential update returned no row') + } else { + const [inserted] = await tx + .insert(credential) + .values({ + id: generateId(), + ...values, + createdBy: context.workspaceOwnerId, + createdAt: now, + }) + .returning({ id: credential.id }) + if (!inserted) throw new Error('Managed OAuth credential insert returned no row') + } + + const [updatedEnrollment] = await tx + .update(credentialGroupEnrollment) + .set({ + status: 'in_progress', + completedAt: null, + updatedAt: now, + }) + .where(eq(credentialGroupEnrollment.id, context.enrollmentId)) + .returning({ id: credentialGroupEnrollment.id }) + if (!updatedEnrollment) throw new Error('Credential group enrollment update returned no row') + }) +} + +/** Exchanges a single-use code through its provider adapter and persists a normalized grant. */ +export async function completeCredentialGroupOAuth( + context: CredentialGroupOAuthContext, + attempt: CredentialGroupOAuthAttempt, + code: string +): Promise { + if ( + attempt.enrollmentId !== context.enrollmentId || + attempt.credentialGroupId !== context.credentialGroupId || + attempt.optionId !== context.option.id || + attempt.provider !== context.option.provider + ) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const adapter = getOptionAdapter(context) + const policy = await assertCurrentPolicy(context, adapter, attempt) + const grant = await adapter.exchangeAndVerify({ context, attempt, code, policy }) + await persistGrant(context, adapter, policy, grant) +} diff --git a/apps/sim/lib/credential-groups/provider-adapter.ts b/apps/sim/lib/credential-groups/provider-adapter.ts new file mode 100644 index 00000000000..0f27828ace4 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-adapter.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import type { CredentialGroupOptionConfig, ManagedOAuthProviderMetadata } from '@sim/db/schema' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import type { DbOrTx } from '@/lib/db/types' +import type { RefreshTokenResult } from '@/lib/oauth' + +export interface CredentialGroupProviderPolicy { + provider: CredentialGroupProvider + providerId: string + authorizationAppId: string + requiredScopes: string[] + scopeVersion: number +} + +export function credentialGroupScopePolicyVersion(scopes: string[]): number { + const digest = createHash('sha256') + .update([...new Set(scopes)].sort().join('\0')) + .digest() + const version = digest.readUInt32BE(0) & 0x7fffffff + return version || 1 +} + +export interface VerifiedCredentialGroupGrant { + providerId: string + providerSubjectId: string + providerTenantId: string | null + displayName: string + metadata: ManagedOAuthProviderMetadata + accessToken: string + refreshToken?: string + grantedScopes: string[] + accessTokenExpiresAt: Date | null + refreshTokenExpiresAt: Date | null +} + +export interface PreparedCredentialGroupAuthorization { + redirectUri: string + codeVerifier?: string + buildAuthorizationUrl(params: { state: string; nonce: string }): string | Promise +} + +export interface CredentialGroupProviderAdapter { + provider: CredentialGroupProvider + requiresRefreshToken: boolean + getPolicy( + option: Pick | undefined, + context: { + workspaceId: string + credentialGroupId?: string + authorizationAppId?: string + executor?: DbOrTx + } + ): Promise + prepareAuthorization( + context: CredentialGroupOAuthContext, + policy: CredentialGroupProviderPolicy + ): Promise + exchangeAndVerify(params: { + context: CredentialGroupOAuthContext + attempt: CredentialGroupOAuthAttempt + code: string + policy: CredentialGroupProviderPolicy + }): Promise + hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean + refreshToken(refreshToken: string): Promise + isTerminalRefreshError(errorCode: string | undefined): boolean +} + +export class CredentialGroupProviderConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = 'CredentialGroupProviderConfigurationError' + } +} + +export class CredentialGroupOAuthError extends Error { + constructor( + message: string, + readonly statusCode: 400 | 401 | 403 | 404 | 409 | 502 | 503 + ) { + super(message) + this.name = 'CredentialGroupOAuthError' + } +} diff --git a/apps/sim/lib/credential-groups/provider-configuration.ts b/apps/sim/lib/credential-groups/provider-configuration.ts new file mode 100644 index 00000000000..40ea17614ea --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-configuration.ts @@ -0,0 +1,144 @@ +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, sql } from 'drizzle-orm' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { DbOrTx } from '@/lib/db/types' + +const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE = + 'credential-group-provider-configuration' as const +const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION = 1 as const + +export interface SlackCredentialGroupConfiguration { + slackBotCredentialId: string + clientId: string + clientSecret: string + appId: string + teamId: string + scopes: string[] + verifiedAt: string +} + +export interface CredentialGroupProviderConfiguration { + type: typeof CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE + version: typeof CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION + slack?: SlackCredentialGroupConfiguration +} + +function isSlackConfiguration(value: unknown): value is SlackCredentialGroupConfiguration { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.clientId === 'string' && + typeof candidate.clientSecret === 'string' && + typeof candidate.appId === 'string' && + typeof candidate.teamId === 'string' && + Array.isArray(candidate.scopes) && + candidate.scopes.every((scope) => typeof scope === 'string') && + typeof candidate.verifiedAt === 'string' + ) +} + +function parseCredentialGroupProviderConfiguration( + value: unknown +): CredentialGroupProviderConfiguration { + if (!value || typeof value !== 'object') { + throw new Error('Credential Group provider configuration is malformed') + } + const candidate = value as Record + if ( + candidate.type !== CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE || + candidate.version !== CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION || + (candidate.slack !== undefined && !isSlackConfiguration(candidate.slack)) + ) { + throw new Error('Credential Group provider configuration is malformed') + } + return { + type: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE, + version: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION, + ...(candidate.slack ? { slack: candidate.slack as SlackCredentialGroupConfiguration } : {}), + } +} + +export function emptyCredentialGroupProviderConfiguration(): CredentialGroupProviderConfiguration { + return { + type: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE, + version: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION, + } +} + +export async function encryptCredentialGroupProviderConfiguration( + configuration: CredentialGroupProviderConfiguration +): Promise { + const parsed = parseCredentialGroupProviderConfiguration(configuration) + return (await encryptSecret(JSON.stringify(parsed))).encrypted +} + +export async function decryptCredentialGroupProviderConfiguration( + encryptedConfiguration: string | null +): Promise { + if (!encryptedConfiguration) return emptyCredentialGroupProviderConfiguration() + try { + const decrypted = await decryptSecret(encryptedConfiguration) + return parseCredentialGroupProviderConfiguration(JSON.parse(decrypted.decrypted) as unknown) + } catch (error) { + throw new Error( + `Credential Group provider configuration could not be read: ${getErrorMessage(error)}` + ) + } +} + +export async function getSlackCredentialGroupConfiguration(params: { + workspaceId: string + credentialGroupId: string + executor?: DbOrTx +}): Promise { + const executor = params.executor ?? db + const [row] = await executor + .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + if (!row) return null + const configuration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + return configuration.slack ?? null +} + +export async function listSlackCredentialGroupConfigurationsForBot(params: { + workspaceId: string + slackBotCredentialId: string +}): Promise { + const rows = await db + .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, params.workspaceId), + sql`${credentialGroup.options} @> ${JSON.stringify([ + { provider: 'slack', slackBotCredentialId: params.slackBotCredentialId }, + ])}::jsonb` + ) + ) + return Promise.all( + rows.map(async (row) => { + const configuration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + if (!configuration.slack) { + throw new Error('Credential Group Slack configuration is missing') + } + if (configuration.slack.slackBotCredentialId !== params.slackBotCredentialId) { + throw new Error('Credential Group Slack configuration does not match its custom bot') + } + return configuration.slack + }) + ) +} diff --git a/apps/sim/lib/credential-groups/provider-registry.test.ts b/apps/sim/lib/credential-groups/provider-registry.test.ts new file mode 100644 index 00000000000..839d9730632 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-registry.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + getCredentialGroupProviderFromProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' + +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' +const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' + +describe('Credential Group provider registry', () => { + it('derives provider identity and display metadata from the OAuth service catalog', () => { + const service = getCredentialGroupProviderService('gmail') + + expect(service.name).toBe('Gmail') + expect(service.providerId).toBe('google-email') + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe('gmail') + }) + + it('maps Google Calendar to its existing OAuth provider', () => { + const service = getCredentialGroupProviderService('google-calendar') + + expect(service.name).toBe('Google Calendar') + expect(service.providerId).toBe('google-calendar') + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe('google-calendar') + }) + + it('uses provider-owned scope implication rules', () => { + const adapter = getCredentialGroupProviderAdapter('gmail') + const canonicalScopes = getCredentialGroupProviderService('gmail').scopes + const grantedScopes = canonicalScopes.filter( + (scope) => scope !== GMAIL_SEND_SCOPE && scope !== GMAIL_LABELS_SCOPE + ) + + expect(grantedScopes).toContain(GMAIL_MODIFY_SCOPE) + expect(adapter.hasRequiredScopes(grantedScopes, canonicalScopes)).toBe(true) + expect(adapter.hasRequiredScopes([], canonicalScopes)).toBe(false) + }) + + it('requires the complete Google Calendar scope policy', () => { + const adapter = getCredentialGroupProviderAdapter('google-calendar') + const requiredScopes = getCredentialGroupProviderService('google-calendar').scopes + + expect(adapter.hasRequiredScopes(requiredScopes, requiredScopes)).toBe(true) + expect(adapter.hasRequiredScopes(requiredScopes.slice(1), requiredScopes)).toBe(false) + }) + + it('maps the legacy Slack tool scope bundle to the managed-user policy', () => { + const adapter = getCredentialGroupProviderAdapter('slack') + const canonicalScopes = getCredentialGroupProviderService('slack').scopes + + expect(adapter.hasRequiredScopes([...SLACK_MANAGED_USER_SCOPES], canonicalScopes)).toBe(true) + expect( + adapter.hasRequiredScopes( + SLACK_MANAGED_USER_SCOPES.filter((scope) => scope !== 'chat:write'), + canonicalScopes + ) + ).toBe(false) + expect(adapter.hasRequiredScopes(['chat:write'], ['chat:write'])).toBe(true) + }) + + it('fails fast for an unregistered managed provider ID', () => { + expect(() => getCredentialGroupProviderFromProviderId('unknown-provider')).toThrow( + 'Unsupported managed credential provider' + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts new file mode 100644 index 00000000000..5ec9e84a916 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-registry.ts @@ -0,0 +1,28 @@ +import type { CredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-adapter' +import { + type CredentialGroupProvider, + getCredentialGroupProviderFromProviderId, +} from '@/lib/credential-groups/providers' +import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider' +import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' + +const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< + CredentialGroupProvider, + CredentialGroupProviderAdapter +> = { + gmail: createStandardOAuthCredentialGroupProviderAdapter('gmail'), + 'google-calendar': createStandardOAuthCredentialGroupProviderAdapter('google-calendar'), + slack: slackCredentialGroupProviderAdapter, +} + +export function getCredentialGroupProviderAdapter( + provider: CredentialGroupProvider +): CredentialGroupProviderAdapter { + return CREDENTIAL_GROUP_PROVIDER_ADAPTERS[provider] +} + +export function getCredentialGroupProviderAdapterByProviderId( + providerId: string +): CredentialGroupProviderAdapter { + return getCredentialGroupProviderAdapter(getCredentialGroupProviderFromProviderId(providerId)) +} diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts new file mode 100644 index 00000000000..ac9162a06d2 --- /dev/null +++ b/apps/sim/lib/credential-groups/providers.ts @@ -0,0 +1,84 @@ +import type { OAuthServiceConfig } from '@/lib/oauth' +import { getServiceConfigByServiceId } from '@/lib/oauth' + +export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = ['gmail', 'google-calendar'] as const + +export type CredentialGroupStandardOAuthProvider = + (typeof CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)[number] + +export const CREDENTIAL_GROUP_PROVIDER_IDS = [ + ...CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, + 'slack', +] as const + +export type CredentialGroupProvider = (typeof CREDENTIAL_GROUP_PROVIDER_IDS)[number] + +export interface CredentialGroupProviderSupport { + serviceId: string + description: string + configuration: 'oauth' | 'slack_custom_bot' +} + +const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< + CredentialGroupProvider, + CredentialGroupProviderSupport +> = { + gmail: { + serviceId: 'gmail', + description: 'Let each person connect one Gmail account', + configuration: 'oauth', + }, + 'google-calendar': { + serviceId: 'google-calendar', + description: 'Let each person connect one Google Calendar account', + configuration: 'oauth', + }, + slack: { + serviceId: 'slack', + description: 'Let each person connect through your custom Slack app', + configuration: 'slack_custom_bot', + }, +} + +export function isCredentialGroupProvider(value: string): value is CredentialGroupProvider { + return CREDENTIAL_GROUP_PROVIDER_IDS.some((provider) => provider === value) +} + +export function isCredentialGroupStandardOAuthProvider( + value: CredentialGroupProvider +): value is CredentialGroupStandardOAuthProvider { + return CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS.some((provider) => provider === value) +} + +export function getCredentialGroupProviderService( + provider: CredentialGroupProvider +): OAuthServiceConfig { + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + const service = getServiceConfigByServiceId(support.serviceId) + if (!service) { + throw new Error( + `Credential Group provider ${provider} references missing OAuth service ${support.serviceId}` + ) + } + return service +} + +export function getCredentialGroupProviderSupport( + provider: CredentialGroupProvider +): CredentialGroupProviderSupport { + return CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] +} + +export function getCredentialGroupProviderId(provider: CredentialGroupProvider): string { + return getCredentialGroupProviderService(provider).providerId +} + +export function getCredentialGroupProviderFromProviderId( + providerId: string +): CredentialGroupProvider { + const provider = CREDENTIAL_GROUP_PROVIDER_IDS.find( + (candidate) => getCredentialGroupProviderId(candidate) === providerId + ) + if (!provider) throw new Error(`Unsupported managed credential provider: ${providerId}`) + return provider +} diff --git a/apps/sim/lib/credential-groups/rate-limit.ts b/apps/sim/lib/credential-groups/rate-limit.ts new file mode 100644 index 00000000000..8f6b9ace8d3 --- /dev/null +++ b/apps/sim/lib/credential-groups/rate-limit.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server' +import { RateLimitError, RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers' +import { getClientIp } from '@/lib/core/utils/request' + +const rateLimiter = new RateLimiter() + +const CREDENTIAL_GROUP_INVITATION_RATE_LIMIT = { + maxTokens: 5, + refillRate: 5, + refillIntervalMs: 60_000, +} as const + +function credentialGroupInvitationRateLimitKey(workspaceId: string): string { + return `route:credential-group-invitations:workspace:${workspaceId}` +} + +const PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 120, + refillRate: 120, + refillIntervalMs: 60_000, +} + +const PUBLIC_OAUTH_START_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +const PUBLIC_OAUTH_CALLBACK_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 60, + refillRate: 60, + refillIntervalMs: 15 * 60_000, +} + +type PublicCredentialGroupRateLimitScope = + | 'metadata' + | 'oauth-start' + | 'oauth-callback' + | 'complete' + +function rateLimitResponse(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { + const retryAfterSeconds = Math.ceil((retryAfterMs ?? fallbackMs) / 1000) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfterSeconds), + 'Cache-Control': 'no-store', + }, + } + ) +} + +function configForPublicScope(scope: PublicCredentialGroupRateLimitScope): TokenBucketConfig { + if (scope === 'metadata') return PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT + if (scope === 'oauth-start' || scope === 'complete') return PUBLIC_OAUTH_START_RATE_LIMIT + return PUBLIC_OAUTH_CALLBACK_RATE_LIMIT +} + +/** Per-IP guard for unauthenticated enrollment reads and OAuth endpoints. */ +export async function enforcePublicCredentialGroupIpRateLimit( + request: { headers: { get(name: string): string | null } }, + scope: PublicCredentialGroupRateLimitScope +): Promise { + const config = configForPublicScope(scope) + const ip = getClientIp(request) + const result = await rateLimiter.checkRateLimitDirect( + `public-credential-group:${scope}:ip:${ip}`, + config, + { failClosed: scope !== 'metadata' } + ) + return result.allowed ? null : rateLimitResponse(result.retryAfterMs, config.refillIntervalMs) +} + +/** Prevents one leaked invitation from starting unbounded provider consent flows. */ +export async function enforceCredentialGroupEnrollmentOAuthRateLimit( + enrollmentId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + `public-credential-group:oauth-start:enrollment:${enrollmentId}`, + PUBLIC_OAUTH_START_RATE_LIMIT, + { failClosed: true } + ) + return result.allowed + ? null + : rateLimitResponse(result.retryAfterMs, PUBLIC_OAUTH_START_RATE_LIMIT.refillIntervalMs) +} + +/** Shared workspace budget for batch invitations and one-off resends. */ +export function enforceCredentialGroupInvitationRateLimit(workspaceId: string) { + return enforceWorkspaceRateLimit( + 'credential-group-invitations', + workspaceId, + CREDENTIAL_GROUP_INVITATION_RATE_LIMIT + ) +} + +/** Applies the shared invitation budget to non-HTTP workflow execution. */ +export async function enforceCredentialGroupInvitationExecutionRateLimit( + workspaceId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + credentialGroupInvitationRateLimitKey(workspaceId), + CREDENTIAL_GROUP_INVITATION_RATE_LIMIT, + { failClosed: true } + ) + if (!result.allowed) throw new RateLimitError('Credential Group invitation rate limit exceeded') +} diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts new file mode 100644 index 00000000000..c73afcc2bee --- /dev/null +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetPolicy } = vi.hoisted(() => ({ + mockGetPolicy: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => ({ getPolicy: mockGetPolicy }), +})) + +import { updateCredentialGroup } from '@/lib/credential-groups/service' + +describe('Credential Group service', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('validates provider policy through the active update transaction', async () => { + const option = { + id: 'option-1', + provider: 'slack' as const, + label: 'Slack', + slackBotCredentialId: 'bot-1', + authorizationAppId: 'slack:A123:T123', + requiredScopes: ['chat:write'], + scopeVersion: 1, + required: true, + status: 'active' as const, + } + const existing = { + id: 'group-1', + workspaceId: 'workspace-1', + publicId: 'public-1', + name: 'Support accounts', + description: null, + options: [option], + encryptedProviderConfiguration: null, + status: 'active' as const, + createdBy: 'user-1', + createdAt: new Date('2026-08-13T00:00:00Z'), + updatedAt: new Date('2026-08-13T00:00:00Z'), + } + queueTableRows(schemaMock.credentialGroup, [existing]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...existing, updatedAt: new Date('2026-08-13T01:00:00Z') }, + ]) + mockGetPolicy.mockResolvedValue({ + provider: 'slack', + providerId: 'slack', + authorizationAppId: option.authorizationAppId, + requiredScopes: option.requiredScopes, + scopeVersion: option.scopeVersion, + }) + + await expect( + updateCredentialGroup('workspace-1', 'group-1', { + options: [ + { + id: option.id, + provider: option.provider, + label: option.label, + slackBotCredentialId: option.slackBotCredentialId, + required: option.required, + }, + ], + }) + ).resolves.toMatchObject({ id: 'group-1' }) + + expect(mockGetPolicy).toHaveBeenCalledWith( + expect.objectContaining({ slackBotCredentialId: 'bot-1' }), + { + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + executor: dbChainMock.db, + } + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts new file mode 100644 index 00000000000..4bbc9394602 --- /dev/null +++ b/apps/sim/lib/credential-groups/service.ts @@ -0,0 +1,268 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, desc, eq, inArray } from 'drizzle-orm' +import type { + CreateCredentialGroupBody, + CredentialGroup, + CredentialGroupOptionInput, + UpdateCredentialGroupBody, +} from '@/lib/api/contracts/credential-groups' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { DbOrTx } from '@/lib/db/types' + +function scopesEqual(left: string[], right: string[]): boolean { + const normalizedLeft = [...new Set(left)].sort() + const normalizedRight = [...new Set(right)].sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((scope, index) => scope === normalizedRight[index]) + ) +} + +async function buildOption( + workspaceId: string, + option: CredentialGroupOptionInput, + credentialGroupId?: string, + executor: DbOrTx = db +): Promise { + const providerConfig = await getCredentialGroupProviderAdapter(option.provider).getPolicy( + option, + { workspaceId, credentialGroupId, executor } + ) + return { + id: generateId(), + provider: option.provider, + label: option.label, + ...(option.provider === 'slack' ? { slackBotCredentialId: option.slackBotCredentialId } : {}), + authorizationAppId: providerConfig.authorizationAppId, + requiredScopes: providerConfig.requiredScopes, + scopeVersion: providerConfig.scopeVersion, + required: option.required, + status: 'active', + } +} + +async function updateOptions( + workspaceId: string, + credentialGroupId: string, + inputs: NonNullable, + existingOptions: CredentialGroupOptionConfig[], + executor: DbOrTx +): Promise { + const existingById = new Map(existingOptions.map((option) => [option.id, option])) + return Promise.all( + inputs.map(async (input) => { + if (!input.id) return buildOption(workspaceId, input, credentialGroupId, executor) + const existing = existingById.get(input.id) + if (!existing) throw new Error(`Credential group option ${input.id} does not exist`) + if (input.provider !== existing.provider) { + throw new Error('A credential option provider cannot be changed; add a new option instead') + } + + const providerConfig = await getCredentialGroupProviderAdapter(input.provider).getPolicy( + input, + { workspaceId, credentialGroupId, executor } + ) + return { + id: existing.id, + provider: existing.provider, + label: input.label, + ...(input.provider === 'slack' ? { slackBotCredentialId: input.slackBotCredentialId } : {}), + authorizationAppId: providerConfig.authorizationAppId, + requiredScopes: providerConfig.requiredScopes, + scopeVersion: providerConfig.scopeVersion, + required: input.required, + status: existing.status, + } + }) + ) +} + +async function toCredentialGroup( + row: typeof credentialGroup.$inferSelect +): Promise { + const providerConfiguration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + return { + id: row.id, + workspaceId: row.workspaceId, + name: row.name, + description: row.description, + options: row.options.map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const common = { + id: option.id, + label: option.label, + required: option.required, + status: option.status, + } + if (option.provider !== 'slack') { + return { ...common, provider: option.provider, configurationStatus: 'ready' as const } + } + if (!option.slackBotCredentialId) { + throw new Error(`Slack credential option ${option.id} has no custom bot`) + } + return { + ...common, + provider: 'slack' as const, + slackBotCredentialId: option.slackBotCredentialId, + configurationStatus: + !providerConfiguration.slack || + providerConfiguration.slack.slackBotCredentialId !== option.slackBotCredentialId + ? ('not_configured' as const) + : option.scopeVersion !== + credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) || + !SLACK_MANAGED_USER_SCOPES.every((scope) => + providerConfiguration.slack?.scopes.includes(scope) + ) + ? ('needs_update' as const) + : ('ready' as const), + } + }), + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export async function listCredentialGroups(workspaceId: string): Promise { + const rows = await db + .select() + .from(credentialGroup) + .where(eq(credentialGroup.workspaceId, workspaceId)) + .orderBy(desc(credentialGroup.createdAt)) + return Promise.all(rows.map(toCredentialGroup)) +} + +export async function getCredentialGroup( + workspaceId: string, + groupId: string +): Promise { + const [row] = await db + .select() + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + return row ? toCredentialGroup(row) : null +} + +export async function createCredentialGroup( + workspaceId: string, + userId: string, + body: CreateCredentialGroupBody +): Promise { + const now = new Date() + const options = await Promise.all(body.options.map((option) => buildOption(workspaceId, option))) + const [created] = await db + .insert(credentialGroup) + .values({ + id: generateId(), + workspaceId, + publicId: generateId(), + name: body.name, + description: body.description || null, + options, + status: 'active', + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .returning() + + if (!created) throw new Error('Credential group insert returned no row') + return toCredentialGroup(created) +} + +export async function deleteCredentialGroup( + workspaceId: string, + groupId: string +): Promise { + const deleted = await db + .delete(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .returning({ id: credentialGroup.id }) + return deleted.length > 0 +} + +export async function updateCredentialGroup( + workspaceId: string, + groupId: string, + body: UpdateCredentialGroupBody +): Promise { + return db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + .for('update') + if (!existing) return null + + const nextOptions = + body.options !== undefined + ? await updateOptions(workspaceId, groupId, body.options, existing.options, tx) + : existing.options + const keepsSlack = nextOptions.some((option) => option.provider === 'slack') + const encryptedProviderConfiguration = keepsSlack + ? existing.encryptedProviderConfiguration + : null + const nextOptionById = new Map(nextOptions.map((option) => [option.id, option])) + const invalidatedOptionIds = existing.options + .filter((option) => { + const next = nextOptionById.get(option.id) + return ( + !next || + next.authorizationAppId !== option.authorizationAppId || + next.scopeVersion !== option.scopeVersion || + !scopesEqual(next.requiredScopes, option.requiredScopes) || + body.status === 'disabled' + ) + }) + .map((option) => option.id) + + const [updated] = await tx + .update(credentialGroup) + .set({ + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.description !== undefined ? { description: body.description || null } : {}), + ...(body.options !== undefined ? { options: nextOptions } : {}), + ...(body.options !== undefined ? { encryptedProviderConfiguration } : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + updatedAt: new Date(), + }) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .returning() + + if (!updated) throw new Error('Credential group update returned no row') + if (invalidatedOptionIds.length > 0) { + const enrollmentIds = tx + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, groupId)) + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.credentialGroupOptionId, invalidatedOptionIds) + ) + ) + } + return toCredentialGroup(updated) + }) +} diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts new file mode 100644 index 00000000000..e706ee0ee34 --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts @@ -0,0 +1,25 @@ +export const SLACK_MANAGED_USER_SCOPES = [ + 'channels:history', + 'channels:read', + 'channels:write', + 'canvases:read', + 'canvases:write', + 'chat:write', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'groups:write', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'mpim:write', + 'reactions:read', + 'reactions:write', + 'users.profile:read', + 'users.profile:write', + 'users:read', + 'users:read.email', +] as const diff --git a/apps/sim/lib/credential-groups/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/slack-managed-users.test.ts new file mode 100644 index 00000000000..9e13d3ce11c --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-users.test.ts @@ -0,0 +1,399 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { attempts, redis } = vi.hoisted(() => { + const attempts = new Map() + return { + attempts, + redis: { + set: vi.fn(async (key: string, value: string) => { + if (attempts.has(key)) return null + attempts.set(key, value) + return 'OK' + }), + get: vi.fn(async (key: string) => attempts.get(key) ?? null), + eval: vi.fn(async (_script: string, _count: number, key: string) => { + const value = attempts.get(key) ?? null + attempts.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => redis })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: + value === 'encrypted-bot' + ? JSON.stringify({ + type: 'slack_custom_bot', + signingSecret: 'signing-secret', + botToken: 'xoxb-token', + teamId: 'T123', + }) + : Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.ai' })) + +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + consumeSlackManagedUsersAttempt, + createSlackManagedUsersAttempt, + exchangeAndConfigureSlackManagedUsers, + exchangeSlackUserAuthorization, + loadSlackManagedUsersAttempt, + verifySlackCustomBotAppIdentity, + verifySlackUserIdentity, +} from '@/lib/credential-groups/slack-managed-users' + +function slackResponse(value: Record): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('Slack managed-user authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + attempts.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('binds the bot token to Slack app and workspace identities', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ ok: true, team_id: 'T123', user_id: 'U123', bot_id: 'B123' }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, bot: { id: 'B123', app_id: 'A123' } })) + vi.stubGlobal('fetch', fetchMock) + + await expect(verifySlackCustomBotAppIdentity('xoxb-token')).resolves.toEqual({ + appId: 'A123', + teamId: 'T123', + }) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/bots.info', + expect.objectContaining({ body: new URLSearchParams({ bot: 'B123' }) }) + ) + }) + + it('encrypts setup secrets and consumes the short-lived state once', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: '22222222-2222-4222-8222-222222222222', + updatedAt: new Date('2026-08-12T00:00:00Z'), + }, + ]) + .mockResolvedValueOnce([ + { + id: '11111111-1111-4111-8111-111111111111', + name: 'Support bot', + updatedAt: new Date('2026-08-12T00:00:00Z'), + encryptedServiceAccountKey: 'encrypted-bot', + }, + ]) + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + slackResponse({ ok: true, team_id: 'T123', user_id: 'U123', bot_id: 'B123' }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, bot: { app_id: 'A123' } })) + ) + + const created = await createSlackManagedUsersAttempt({ + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + clientId: 'client-id', + clientSecret: 'client-secret', + }) + + expect(created.authorizationUrl).toContain('team=T123') + expect(created.authorizationUrl).toContain('user_scope=channels%3Ahistory') + expect([...attempts.values()][0]).not.toContain('client-secret') + await expect(loadSlackManagedUsersAttempt(created.state)).resolves.toMatchObject({ + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientSecret: 'client-secret', + }) + await expect(consumeSlackManagedUsersAttempt(created.state)).resolves.toMatchObject({ + clientId: 'client-id', + }) + await expect(consumeSlackManagedUsersAttempt(created.state)).resolves.toBeNull() + }) + + it('returns an actionable error when the custom bot lacks users:read', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', bot_id: 'B123' })) + .mockResolvedValueOnce( + slackResponse({ ok: false, error: 'missing_scope', needed: 'users:read' }) + ) + ) + + await expect(verifySlackCustomBotAppIdentity('xoxb-token')).rejects.toThrow( + 'Add the users:read bot scope' + ) + }) + + it('stores Slack OAuth client configuration on the Credential Group', async () => { + const updatedAt = new Date('2026-08-12T00:00:00Z') + queueTableRows(schemaMock.credentialGroup, [ + { + id: '22222222-2222-4222-8222-222222222222', + workspaceId: 'workspace-1', + name: 'Support accounts', + options: [], + encryptedProviderConfiguration: null, + updatedAt, + }, + ]) + queueTableRows(schemaMock.credential, [ + { + id: '11111111-1111-4111-8111-111111111111', + updatedAt, + encryptedServiceAccountKey: 'encrypted-bot', + }, + ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: '11111111-1111-4111-8111-111111111111' }]) + .mockResolvedValueOnce([{ id: '22222222-2222-4222-8222-222222222222' }]) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: SLACK_MANAGED_USER_SCOPES.join(','), + }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', user_id: 'U123' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user: { id: 'U123', profile: { email: 'theo@sim.ai' } }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, revoked: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + exchangeAndConfigureSlackManagedUsers({ + attempt: { + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + credentialGroupUpdatedAt: updatedAt.getTime(), + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + slackBotCredentialUpdatedAt: updatedAt.getTime(), + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://sim.ai/callback', + createdAt: Date.now(), + }, + code: 'single-use-code', + }) + ).resolves.toMatchObject({ + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ authorizationAppId: null, managedOauthScopeVersion: null }) + ) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + encryptedProviderConfiguration: expect.any(String), + options: [ + expect.objectContaining({ + provider: 'slack', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }), + ], + }) + ) + expect(JSON.stringify(dbChainMockFns.set.mock.calls[1])).not.toContain('client-secret') + }) + + it('requires Slack to attest a user token, app, team, user, and scopes', async () => { + const fetchMock = vi.fn().mockResolvedValue( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: 'users:read,users:read.email', + }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await exchangeSlackUserAuthorization({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'single-use-code', + redirectUri: 'https://sim.ai/callback', + }) + + expect(result).toMatchObject({ + appId: 'A123', + teamId: 'T123', + userId: 'U123', + accessToken: 'xoxp-token', + tokenType: 'user', + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://slack.com/api/oauth.v2.access', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }), + }) + ) + }) + + it('fails closed when Slack omits the user token type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + scope: 'users:read', + }, + }) + ) + ) + + await expect( + exchangeSlackUserAuthorization({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'single-use-code', + redirectUri: 'https://sim.ai/callback', + }) + ).rejects.toThrow('Slack returned an incomplete authorization') + }) + + it('revokes the setup token and stores nothing when client credentials target another app', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A999', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: SLACK_MANAGED_USER_SCOPES.join(','), + }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, revoked: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + exchangeAndConfigureSlackManagedUsers({ + attempt: { + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + credentialGroupUpdatedAt: new Date('2026-08-12T00:00:00Z').getTime(), + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + slackBotCredentialUpdatedAt: new Date('2026-08-12T00:00:00Z').getTime(), + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://sim.ai/callback', + createdAt: Date.now(), + }, + code: 'single-use-code', + }) + ).rejects.toThrow('different Slack app or workspace') + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/auth.revoke', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer xoxp-token' }), + }) + ) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) + + it('verifies the token identity and reads cosmetic profile metadata', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', user_id: 'U123' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user: { + id: 'U123', + name: 'theo', + profile: { + email: 'theo@sim.ai', + display_name: 'Theo', + image_192: 'https://avatars.slack-edge.com/theo.png', + }, + }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + verifySlackUserIdentity({ + accessToken: 'xoxp-token', + expectedTeamId: 'T123', + expectedUserId: 'U123', + }) + ).resolves.toEqual({ + userId: 'U123', + teamId: 'T123', + email: 'theo@sim.ai', + displayName: 'Theo', + avatarUrl: 'https://avatars.slack-edge.com/theo.png', + username: 'theo', + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts new file mode 100644 index 00000000000..3d7368d6f3c --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -0,0 +1,759 @@ +import { Buffer } from 'node:buffer' +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { + decryptCredentialGroupProviderConfiguration, + encryptCredentialGroupProviderConfiguration, +} from '@/lib/credential-groups/provider-configuration' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { DbOrTx } from '@/lib/db/types' +import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' + +const logger = createLogger('SlackManagedUsers') +const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 2 as const +const MAX_SLACK_RESPONSE_BYTES = 64 * 1024 +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface SlackCustomBotSecret { + type: typeof SLACK_CUSTOM_BOT_SECRET_TYPE + signingSecret: string + botToken: string + teamId: string + botUserId?: string + teamName?: string + metadata?: Record +} + +interface StoredSlackManagedUsersAttempt { + version: typeof SLACK_MANAGED_USERS_ATTEMPT_VERSION + workspaceId: string + userId: string + credentialGroupId: string + credentialGroupUpdatedAt: number + slackBotCredentialId: string + slackBotCredentialUpdatedAt: number + expectedAppId: string + expectedTeamId: string + clientId: string + encryptedClientSecret: string + redirectUri: string + createdAt: number +} + +export interface SlackManagedUsersAttempt { + workspaceId: string + userId: string + credentialGroupId: string + credentialGroupUpdatedAt: number + slackBotCredentialId: string + slackBotCredentialUpdatedAt: number + expectedAppId: string + expectedTeamId: string + clientId: string + clientSecret: string + redirectUri: string + createdAt: number +} + +export interface SlackOAuthSuccess { + appId: string + teamId: string + teamName: string + userId: string + accessToken: string + scopes: string[] + tokenType: 'user' + expiresIn?: number + refreshToken?: string +} + +export interface VerifiedSlackUserIdentity { + userId: string + teamId: string + email: string + displayName?: string + avatarUrl?: string + username?: string +} + +export class SlackManagedUsersError extends Error { + constructor( + message: string, + readonly code: + | 'invalid_state' + | 'provider_error' + | 'invalid_client' + | 'invalid_response' + | 'missing_bot_scope' + | 'token_rotation_enabled' + | 'revoke_failed' + ) { + super(message) + this.name = 'SlackManagedUsersError' + } +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) throw new Error('Slack managed-user setup requires Redis') + return redis +} + +function attemptKey(state: string): string { + return `credential-group:slack-managed-users:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === SLACK_MANAGED_USERS_ATTEMPT_VERSION && + typeof candidate.workspaceId === 'string' && + typeof candidate.userId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.credentialGroupUpdatedAt === 'number' && + typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.slackBotCredentialUpdatedAt === 'number' && + typeof candidate.expectedAppId === 'string' && + typeof candidate.expectedTeamId === 'string' && + typeof candidate.clientId === 'string' && + typeof candidate.encryptedClientSecret === 'string' && + typeof candidate.redirectUri === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +function parseSlackCustomBotSecret(value: unknown): SlackCustomBotSecret { + if (!value || typeof value !== 'object') { + throw new Error('Slack custom bot secret is malformed') + } + const candidate = value as Record + if ( + candidate.type !== SLACK_CUSTOM_BOT_SECRET_TYPE || + typeof candidate.signingSecret !== 'string' || + typeof candidate.botToken !== 'string' || + typeof candidate.teamId !== 'string' + ) { + throw new Error('Slack custom bot secret is malformed') + } + return { + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + signingSecret: candidate.signingSecret, + botToken: candidate.botToken, + teamId: candidate.teamId, + ...(typeof candidate.botUserId === 'string' ? { botUserId: candidate.botUserId } : {}), + ...(typeof candidate.teamName === 'string' ? { teamName: candidate.teamName } : {}), + ...(candidate.metadata && typeof candidate.metadata === 'object' + ? { metadata: candidate.metadata as Record } + : {}), + } +} + +function stringField(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object') return null + const field = (value as Record)[key] + return typeof field === 'string' && field.length > 0 ? field : null +} + +async function readBoundedJson(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_SLACK_RESPONSE_BYTES) { + throw new SlackManagedUsersError('Slack returned an oversized response.', 'invalid_response') + } + const text = await response.text() + if (Buffer.byteLength(text, 'utf8') > MAX_SLACK_RESPONSE_BYTES) { + throw new SlackManagedUsersError('Slack returned an oversized response.', 'invalid_response') + } + try { + return JSON.parse(text) as unknown + } catch { + throw new SlackManagedUsersError('Slack returned an invalid response.', 'invalid_response') + } +} + +function parseSlackOAuthResponse(value: unknown): SlackOAuthSuccess { + if (!value || typeof value !== 'object') { + throw new SlackManagedUsersError('Slack returned an invalid response.', 'invalid_response') + } + const response = value as Record + if (response.ok !== true) { + const errorCode = stringField(response, 'error') + throw new SlackManagedUsersError( + errorCode === 'invalid_client_id' || errorCode === 'bad_client_secret' + ? 'Slack rejected the Client ID or Client Secret.' + : 'Slack could not verify this app.', + errorCode === 'invalid_client_id' || errorCode === 'bad_client_secret' + ? 'invalid_client' + : 'provider_error' + ) + } + + const appId = stringField(response, 'app_id') + const team = response.team + const teamId = stringField(team, 'id') + const teamName = stringField(team, 'name') + const authedUser = response.authed_user + const userId = stringField(authedUser, 'id') + const accessToken = stringField(authedUser, 'access_token') + const tokenType = stringField(authedUser, 'token_type') + const scope = stringField(authedUser, 'scope') + if ( + !appId?.startsWith('A') || + !teamId?.startsWith('T') || + !teamName || + !userId?.startsWith('U') || + !accessToken || + tokenType !== 'user' || + !scope + ) { + throw new SlackManagedUsersError( + 'Slack returned an incomplete authorization.', + 'invalid_response' + ) + } + + const expiresIn = + authedUser && typeof authedUser === 'object' + ? (authedUser as Record).expires_in + : undefined + const refreshToken = stringField(authedUser, 'refresh_token') ?? undefined + return { + appId, + teamId, + teamName, + userId, + accessToken, + scopes: scope + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + tokenType: 'user', + ...(typeof expiresIn === 'number' ? { expiresIn } : {}), + ...(refreshToken ? { refreshToken } : {}), + } +} + +export async function revokeSlackToken(token: string): Promise { + const response = await fetch('https://slack.com/api/auth.revoke', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + cache: 'no-store', + }) + const value = await readBoundedJson(response) + if ( + !response.ok || + !value || + typeof value !== 'object' || + (value as Record).ok !== true || + (value as Record).revoked !== true + ) { + throw new SlackManagedUsersError( + 'Slack issued a setup token but could not revoke it. Try again.', + 'revoke_failed' + ) + } +} + +async function callSlackApi(method: string, accessToken: string, body?: URLSearchParams) { + let response: Response + try { + response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ...(body ? { body } : {}), + cache: 'no-store', + }) + } catch (error) { + logger.error('Slack API verification failed', { method, error: getErrorMessage(error) }) + throw new SlackManagedUsersError('Slack could not verify the authorization.', 'provider_error') + } + const value = await readBoundedJson(response) + if ( + !response.ok || + !value || + typeof value !== 'object' || + (value as Record).ok !== true + ) { + const providerError = stringField(value, 'error') + if (method === 'bots.info' && providerError === 'missing_scope') { + throw new SlackManagedUsersError( + 'Add the users:read bot scope to this Slack app, reinstall it, and update the custom bot credential before enabling managed users.', + 'missing_bot_scope' + ) + } + throw new SlackManagedUsersError('Slack could not verify the authorization.', 'provider_error') + } + return value as Record +} + +export async function verifySlackCustomBotAppIdentity(botToken: string): Promise<{ + appId: string + teamId: string +}> { + const auth = await callSlackApi('auth.test', botToken) + const teamId = stringField(auth, 'team_id') + const botId = stringField(auth, 'bot_id') + if (!teamId?.startsWith('T') || !botId?.startsWith('B')) { + throw new SlackManagedUsersError( + 'Slack did not identify this token as an installed bot.', + 'invalid_response' + ) + } + const info = await callSlackApi('bots.info', botToken, new URLSearchParams({ bot: botId })) + const appId = stringField(info.bot, 'app_id') + if (!appId?.startsWith('A')) { + throw new SlackManagedUsersError( + 'Slack did not return the app for this custom bot.', + 'invalid_response' + ) + } + return { appId, teamId } +} + +export async function verifySlackUserIdentity(params: { + accessToken: string + expectedTeamId: string + expectedUserId: string +}): Promise { + const auth = await callSlackApi('auth.test', params.accessToken) + const teamId = stringField(auth, 'team_id') + const userId = stringField(auth, 'user_id') + if (teamId !== params.expectedTeamId || userId !== params.expectedUserId) { + throw new SlackManagedUsersError( + 'Slack returned a credential for another user or workspace.', + 'invalid_response' + ) + } + + const info = await callSlackApi( + 'users.info', + params.accessToken, + new URLSearchParams({ user: params.expectedUserId }) + ) + const user = info.user + if (!user || typeof user !== 'object') { + throw new SlackManagedUsersError( + 'Slack returned an incomplete user profile.', + 'invalid_response' + ) + } + const profile = (user as Record).profile + const email = stringField(profile, 'email') + if (!email) { + throw new SlackManagedUsersError( + 'Slack did not return the user email required by this invitation.', + 'invalid_response' + ) + } + const displayName = stringField(profile, 'display_name') ?? stringField(profile, 'real_name') + const avatarUrl = stringField(profile, 'image_192') ?? stringField(profile, 'image_72') + const username = stringField(user, 'name') + return { + userId, + teamId, + email, + ...(displayName ? { displayName } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + ...(username ? { username } : {}), + } +} + +export async function exchangeSlackUserAuthorization(params: { + clientId: string + clientSecret: string + code: string + redirectUri: string +}): Promise { + const basicAuth = Buffer.from(`${params.clientId}:${params.clientSecret}`, 'utf8').toString( + 'base64' + ) + const body = new URLSearchParams({ code: params.code, redirect_uri: params.redirectUri }) + let response: Response + try { + response = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + cache: 'no-store', + }) + } catch (error) { + logger.error('Slack OAuth exchange failed', { error: getErrorMessage(error) }) + throw new SlackManagedUsersError('Slack could not complete authorization.', 'provider_error') + } + return parseSlackOAuthResponse(await readBoundedJson(response)) +} + +export function getSlackManagedUsersRedirectUri(): string { + return `${getBaseUrl()}/api/credential-groups/slack-managed-users/callback` +} + +export async function createSlackManagedUsersAttempt(params: { + workspaceId: string + userId: string + credentialGroupId: string + slackBotCredentialId: string + clientId: string + clientSecret: string +}): Promise<{ state: string; authorizationUrl: string }> { + const [group] = await db + .select({ id: credentialGroup.id, updatedAt: credentialGroup.updatedAt }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + if (!group) throw new SlackManagedUsersError('Credential Group not found.', 'invalid_response') + const bot = await getSlackCustomBotCredential({ + workspaceId: params.workspaceId, + credentialId: params.slackBotCredentialId, + }) + if (!bot) throw new SlackManagedUsersError('Custom Slack bot not found.', 'invalid_response') + const identity = await verifySlackCustomBotAppIdentity(bot.botToken) + if (identity.teamId !== bot.teamId) { + throw new SlackManagedUsersError( + 'The custom bot token no longer belongs to its stored Slack workspace.', + 'invalid_response' + ) + } + const redis = requireRedis() + const state = generateId() + const redirectUri = getSlackManagedUsersRedirectUri() + const encryptedClientSecret = await encryptSecret(params.clientSecret) + const attempt: StoredSlackManagedUsersAttempt = { + version: SLACK_MANAGED_USERS_ATTEMPT_VERSION, + workspaceId: params.workspaceId, + userId: params.userId, + credentialGroupId: group.id, + credentialGroupUpdatedAt: group.updatedAt.getTime(), + slackBotCredentialId: bot.id, + slackBotCredentialUpdatedAt: bot.updatedAt.getTime(), + expectedAppId: identity.appId, + expectedTeamId: identity.teamId, + clientId: params.clientId, + encryptedClientSecret: encryptedClientSecret.encrypted, + redirectUri, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(state), + JSON.stringify(attempt), + 'PX', + SLACK_MANAGED_USERS_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Slack managed-user state collision') + + const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize') + authorizationUrl.searchParams.set('client_id', params.clientId) + authorizationUrl.searchParams.set('user_scope', SLACK_MANAGED_USER_SCOPES.join(',')) + authorizationUrl.searchParams.set('redirect_uri', redirectUri) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('team', identity.teamId) + return { state, authorizationUrl: authorizationUrl.toString() } +} + +export async function consumeSlackManagedUsersAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.eval(CONSUME_SCRIPT, 1, attemptKey(state)) + return parseSlackManagedUsersAttempt(raw) +} + +export async function loadSlackManagedUsersAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.get(attemptKey(state)) + return parseSlackManagedUsersAttempt(raw) +} + +async function parseSlackManagedUsersAttempt( + raw: unknown +): Promise { + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Slack managed-user state is malformed') + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Slack managed-user state is malformed') + if (Date.now() - parsed.createdAt > SLACK_MANAGED_USERS_ATTEMPT_TTL_MS) return null + const clientSecret = await decryptSecret(parsed.encryptedClientSecret) + return { + workspaceId: parsed.workspaceId, + userId: parsed.userId, + credentialGroupId: parsed.credentialGroupId, + credentialGroupUpdatedAt: parsed.credentialGroupUpdatedAt, + slackBotCredentialId: parsed.slackBotCredentialId, + slackBotCredentialUpdatedAt: parsed.slackBotCredentialUpdatedAt, + expectedAppId: parsed.expectedAppId, + expectedTeamId: parsed.expectedTeamId, + clientId: parsed.clientId, + clientSecret: clientSecret.decrypted, + redirectUri: parsed.redirectUri, + createdAt: parsed.createdAt, + } +} + +export async function exchangeAndConfigureSlackManagedUsers(params: { + attempt: SlackManagedUsersAttempt + code: string +}): Promise<{ + credentialGroupId: string + credentialGroupName: string + slackBotCredentialId: string + appId: string + teamId: string +}> { + const grant = await exchangeSlackUserAuthorization({ + clientId: params.attempt.clientId, + clientSecret: params.attempt.clientSecret, + code: params.code, + redirectUri: params.attempt.redirectUri, + }) + if (grant.expiresIn !== undefined || grant.refreshToken) { + await Promise.allSettled( + [grant.accessToken, grant.refreshToken] + .filter((token): token is string => Boolean(token)) + .map(revokeSlackToken) + ) + throw new SlackManagedUsersError( + 'Disable token rotation in the Slack app and try again.', + 'token_rotation_enabled' + ) + } + + try { + if ( + grant.appId !== params.attempt.expectedAppId || + grant.teamId !== params.attempt.expectedTeamId + ) { + throw new SlackManagedUsersError( + 'The Client ID and Client Secret belong to a different Slack app or workspace than the selected custom bot.', + 'invalid_response' + ) + } + if (!SLACK_MANAGED_USER_SCOPES.every((scope) => grant.scopes.includes(scope))) { + throw new SlackManagedUsersError( + 'Slack did not grant every permission required for managed users.', + 'invalid_response' + ) + } + await verifySlackUserIdentity({ + accessToken: grant.accessToken, + expectedTeamId: grant.teamId, + expectedUserId: grant.userId, + }) + } finally { + await revokeSlackToken(grant.accessToken) + } + const authorizationAppId = `slack:${grant.appId}:${grant.teamId}` + const now = new Date() + const scopeVersion = credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) + + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-managed-users:${params.attempt.credentialGroupId}`}, 0))` + ) + const [group] = await tx + .select() + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.attempt.credentialGroupId), + eq(credentialGroup.workspaceId, params.attempt.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group || group.updatedAt.getTime() !== params.attempt.credentialGroupUpdatedAt) { + throw new SlackManagedUsersError( + 'The Credential Group changed while Slack authorization was in progress. Start again.', + 'invalid_state' + ) + } + const [botRow] = await tx + .select({ + id: credential.id, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.id, params.attempt.slackBotCredentialId), + eq(credential.workspaceId, params.attempt.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) + ) + .limit(1) + if ( + !botRow?.encryptedServiceAccountKey || + botRow.updatedAt.getTime() !== params.attempt.slackBotCredentialUpdatedAt + ) { + throw new SlackManagedUsersError( + 'The custom bot changed while Slack authorization was in progress. Start again.', + 'invalid_state' + ) + } + const decrypted = await decryptSecret(botRow.encryptedServiceAccountKey) + const botSecret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) + if (botSecret.teamId !== grant.teamId) { + throw new SlackManagedUsersError( + 'The custom bot no longer belongs to the verified Slack workspace.', + 'invalid_state' + ) + } + const sanitizedBotSecret = await encryptSecret(JSON.stringify(botSecret)) + const [cleanedBot] = await tx + .update(credential) + .set({ + encryptedServiceAccountKey: sanitizedBotSecret.encrypted, + authorizationAppId: null, + managedOauthScopeVersion: null, + updatedAt: now, + }) + .where(eq(credential.id, botRow.id)) + .returning({ id: credential.id }) + if (!cleanedBot) throw new Error('Slack custom bot cleanup returned no row') + const currentConfiguration = await decryptCredentialGroupProviderConfiguration( + group.encryptedProviderConfiguration + ) + const encryptedConfiguration = await encryptCredentialGroupProviderConfiguration({ + ...currentConfiguration, + slack: { + slackBotCredentialId: botRow.id, + clientId: params.attempt.clientId, + clientSecret: params.attempt.clientSecret, + appId: grant.appId, + teamId: grant.teamId, + scopes: [...new Set(grant.scopes)], + verifiedAt: now.toISOString(), + }, + }) + const existingOption = group.options.find((option) => option.provider === 'slack') + const nextOption = { + id: existingOption?.id ?? generateId(), + provider: 'slack', + label: existingOption?.label ?? 'Slack', + slackBotCredentialId: botRow.id, + authorizationAppId, + requiredScopes: [...SLACK_MANAGED_USER_SCOPES], + scopeVersion, + required: existingOption?.required ?? true, + status: existingOption?.status ?? ('active' as const), + } + const options = existingOption + ? group.options.map((option) => (option.id === existingOption.id ? nextOption : option)) + : [...group.options, nextOption] + const [updated] = await tx + .update(credentialGroup) + .set({ + options, + encryptedProviderConfiguration: encryptedConfiguration, + updatedAt: now, + }) + .where(eq(credentialGroup.id, group.id)) + .returning({ id: credentialGroup.id }) + if (!updated) throw new Error('Credential Group Slack configuration update returned no row') + if ( + existingOption && + (existingOption.authorizationAppId !== authorizationAppId || + existingOption.scopeVersion !== scopeVersion) + ) { + const enrollmentIds = tx + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, group.id)) + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: now }) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + eq(credential.credentialGroupOptionId, existingOption.id) + ) + ) + } + return { + credentialGroupId: group.id, + credentialGroupName: group.name, + slackBotCredentialId: botRow.id, + appId: grant.appId, + teamId: grant.teamId, + } + }) +} + +export async function getSlackCustomBotCredential(params: { + workspaceId: string + credentialId: string + executor?: DbOrTx +}): Promise<{ + id: string + name: string + updatedAt: Date + botToken: string + teamId: string + teamName?: string +} | null> { + const executor = params.executor ?? db + const [row] = await executor + .select({ + id: credential.id, + name: credential.displayName, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID), + eq(credential.id, params.credentialId) + ) + ) + .limit(1) + if (!row) return null + if (!row.encryptedServiceAccountKey) throw new Error('Slack custom bot secret is missing') + const decrypted = await decryptSecret(row.encryptedServiceAccountKey) + const secret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) + return { + id: row.id, + name: row.name, + updatedAt: row.updatedAt, + botToken: secret.botToken, + teamId: secret.teamId, + teamName: secret.teamName, + } +} diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts new file mode 100644 index 00000000000..d6633c862b7 --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -0,0 +1,239 @@ +import { normalizeEmail } from '@sim/utils/string' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupOAuthError, + CredentialGroupProviderConfigurationError, + credentialGroupScopePolicyVersion, +} from '@/lib/credential-groups/provider-adapter' +import { getSlackCredentialGroupConfiguration } from '@/lib/credential-groups/provider-configuration' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + exchangeSlackUserAuthorization, + getSlackCustomBotCredential, + revokeSlackToken, + verifySlackUserIdentity, +} from '@/lib/credential-groups/slack-managed-users' +import type { DbOrTx } from '@/lib/db/types' + +const PROVIDER = 'slack' as const + +async function getSlackPolicy(params: { + workspaceId: string + credentialGroupId: string + slackBotCredentialId?: string + executor?: DbOrTx +}): Promise< + CredentialGroupProviderPolicy & { + slackBotCredentialId: string + clientId: string + clientSecret: string + appId: string + teamId: string + } +> { + const managed = await getSlackCredentialGroupConfiguration({ + workspaceId: params.workspaceId, + credentialGroupId: params.credentialGroupId, + ...(params.executor ? { executor: params.executor } : {}), + }) + if (!managed) { + throw new CredentialGroupProviderConfigurationError('Configure Slack on this Credential Group') + } + if (params.slackBotCredentialId && managed.slackBotCredentialId !== params.slackBotCredentialId) { + throw new CredentialGroupProviderConfigurationError( + 'The selected custom Slack bot does not match this Credential Group configuration' + ) + } + const app = await getSlackCustomBotCredential({ + workspaceId: params.workspaceId, + credentialId: managed.slackBotCredentialId, + ...(params.executor ? { executor: params.executor } : {}), + }) + if (!app) { + throw new CredentialGroupProviderConfigurationError( + 'The selected custom Slack bot is unavailable' + ) + } + if (app.teamId !== managed.teamId) { + throw new CredentialGroupProviderConfigurationError( + 'The custom Slack bot no longer belongs to the configured Slack workspace' + ) + } + const service = getCredentialGroupProviderService(PROVIDER) + const requiredScopes = [...SLACK_MANAGED_USER_SCOPES] + const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes) + if (!requiredScopes.every((scope) => managed.scopes.includes(scope))) { + throw new CredentialGroupProviderConfigurationError( + 'Managed-user permissions changed. Reconfigure Slack on this Credential Group.' + ) + } + return { + provider: PROVIDER, + providerId: service.providerId, + authorizationAppId: `slack:${managed.appId}:${managed.teamId}`, + requiredScopes, + scopeVersion, + slackBotCredentialId: managed.slackBotCredentialId, + clientId: managed.clientId, + clientSecret: managed.clientSecret, + appId: managed.appId, + teamId: managed.teamId, + } +} + +/** + * Slack tools still request the legacy canonical bot bundle when they do not declare + * operation-level scopes. Managed user grants translate only that exact fallback to the + * managed-user policy; explicit tool scopes remain exact requirements. + */ +function hasRequiredSlackScopes(grantedScopes: string[], requiredScopes: string[]): boolean { + const granted = new Set(grantedScopes) + const canonicalBotScopes = getCredentialGroupProviderService(PROVIDER).scopes + const isCanonicalFallback = + requiredScopes.length === canonicalBotScopes.length && + requiredScopes.every((scope) => canonicalBotScopes.includes(scope)) + const effectiveRequiredScopes = isCanonicalFallback ? SLACK_MANAGED_USER_SCOPES : requiredScopes + return effectiveRequiredScopes.every((scope) => granted.has(scope)) +} + +export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter = { + provider: PROVIDER, + requiresRefreshToken: false, + async getPolicy(option, context) { + if (!context.credentialGroupId) { + throw new CredentialGroupProviderConfigurationError('Credential Group context is required') + } + const slackBotCredentialId = option?.slackBotCredentialId + return getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + ...(slackBotCredentialId ? { slackBotCredentialId } : {}), + ...(context.executor ? { executor: context.executor } : {}), + }) + }, + async prepareAuthorization(context, policy) { + const currentPolicy = await getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: context.option.slackBotCredentialId, + }) + if (currentPolicy.authorizationAppId !== policy.authorizationAppId) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + return { + redirectUri, + buildAuthorizationUrl: ({ state }) => { + const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize') + authorizationUrl.searchParams.set('client_id', currentPolicy.clientId) + authorizationUrl.searchParams.set('user_scope', policy.requiredScopes.join(',')) + authorizationUrl.searchParams.set('redirect_uri', redirectUri) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('team', currentPolicy.teamId) + return authorizationUrl.toString() + }, + } + }, + async exchangeAndVerify({ context, attempt, code, policy }) { + const currentPolicy = await getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: context.option.slackBotCredentialId, + }) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + if ( + currentPolicy.authorizationAppId !== policy.authorizationAppId || + attempt.redirectUri !== redirectUri + ) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + + let grant: Awaited> + try { + grant = await exchangeSlackUserAuthorization({ + clientId: currentPolicy.clientId, + clientSecret: currentPolicy.clientSecret, + code, + redirectUri: attempt.redirectUri, + }) + } catch { + throw new CredentialGroupOAuthError( + 'Slack could not complete authorization. Please try again.', + 502 + ) + } + + if (grant.expiresIn !== undefined || grant.refreshToken) { + await Promise.allSettled( + [grant.accessToken, grant.refreshToken] + .filter((token): token is string => Boolean(token)) + .map(revokeSlackToken) + ) + throw new CredentialGroupOAuthError( + 'Slack token rotation was enabled after this app was configured. Disable it and try again.', + 409 + ) + } + + try { + if ( + grant.appId !== currentPolicy.appId || + grant.teamId !== currentPolicy.teamId || + !hasRequiredSlackScopes(grant.scopes, policy.requiredScopes) + ) { + throw new CredentialGroupOAuthError( + 'All requested Slack permissions are required to connect this account.', + 403 + ) + } + const identity = await verifySlackUserIdentity({ + accessToken: grant.accessToken, + expectedTeamId: grant.teamId, + expectedUserId: grant.userId, + }) + const email = normalizeEmail(identity.email) + if (email !== context.email) { + throw new CredentialGroupOAuthError( + `Sign in with ${context.email} to complete this invitation.`, + 403 + ) + } + + return { + providerId: policy.providerId, + providerSubjectId: identity.userId, + providerTenantId: identity.teamId, + displayName: email, + metadata: { + email, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + ...(identity.username ? { username: identity.username } : {}), + }, + accessToken: grant.accessToken, + grantedScopes: [...new Set(grant.scopes)], + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + } + } catch (error) { + await revokeSlackToken(grant.accessToken) + if (error instanceof CredentialGroupOAuthError) throw error + throw new CredentialGroupOAuthError('Slack could not verify the granted access.', 502) + } + }, + hasRequiredScopes: hasRequiredSlackScopes, + async refreshToken() { + throw new Error('Slack managed credentials do not use token refresh') + }, + isTerminalRefreshError() { + return false + }, +} diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts new file mode 100644 index 00000000000..9f06cef72af --- /dev/null +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { createHash } from 'node:crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' + +const { mockGetToken, mockVerifyIdentity } = vi.hoisted(() => ({ + mockGetToken: vi.fn(), + mockVerifyIdentity: vi.fn(), +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.example.com', +})) + +vi.mock('@/lib/auth/connectors/providers', () => ({ + getConnectorProviderConfig: (providerId: string) => + providerId === 'google-calendar' + ? { + providerId, + clientId: 'client-1', + clientSecret: 'secret-1', + authorizationUrl: 'https://accounts.example.com/authorize', + tokenUrl: 'https://accounts.example.com/token', + accessType: 'offline', + scopes: ['calendar.read', 'profile'], + getToken: mockGetToken, + managedOAuth: { + additionalScopes: ['openid'], + requiresRefreshToken: true, + pkce: true, + prompt: 'consent select_account', + authorizationUrlParams: { include_granted_scopes: 'false' }, + getAuthorizationAppId: (clientId: string) => `google:${clientId}`, + verifyIdentity: mockVerifyIdentity, + hasRequiredScopes: (granted: string[], required: string[]) => + required.every((scope) => granted.includes(scope)), + isTerminalRefreshError: (errorCode: string | undefined) => + errorCode === 'invalid_grant', + }, + } + : undefined, +})) + +import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' + +const adapter = createStandardOAuthCredentialGroupProviderAdapter('google-calendar') + +function buildContext(): CredentialGroupOAuthContext { + return { + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + email: 'person@example.com', + enrollmentStatus: 'in_progress', + option: { + id: 'option-1', + provider: 'google-calendar', + label: 'Google Calendar', + authorizationAppId: 'google:client-1', + requiredScopes: ['calendar.read', 'profile', 'openid'], + scopeVersion: 1, + required: true, + status: 'active', + }, + options: [], + } +} + +function buildAttempt(scopeVersion: number): CredentialGroupOAuthAttempt { + return { + state: 'state-1', + provider: 'google-calendar', + nonceHash: createHash('sha256').update('nonce-1').digest('hex'), + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:client-1', + scopeVersion, + requiredScopes: ['calendar.read', 'profile', 'openid'], + redirectUri: 'https://sim.example.com/api/credential-groups/oauth/google-calendar/callback', + codeVerifier: 'verifier-1', + invitationToken: 'invitation-1', + createdAt: Date.now(), + } +} + +describe('standard OAuth Credential Group provider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetToken.mockResolvedValue({ + tokenType: 'Bearer', + accessToken: 'access-1', + refreshToken: 'refresh-1', + accessTokenExpiresAt: new Date('2026-08-14T01:00:00Z'), + }) + mockVerifyIdentity.mockResolvedValue({ + providerSubjectId: 'google-sub-1', + providerTenantId: 'example.com', + email: 'person@example.com', + emailVerified: true, + displayName: 'Person', + avatarUrl: 'https://example.com/avatar.png', + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + }) + + it('builds authorization from the existing connector configuration', async () => { + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const prepared = await adapter.prepareAuthorization(context, policy) + const authorizationUrl = new URL( + await prepared.buildAuthorizationUrl({ state: 'state-1', nonce: 'nonce-1' }) + ) + + expect(policy).toMatchObject({ + provider: 'google-calendar', + providerId: 'google-calendar', + authorizationAppId: 'google:client-1', + requiredScopes: ['calendar.read', 'profile', 'openid'], + }) + expect(prepared.codeVerifier).toHaveLength(86) + expect(authorizationUrl.origin).toBe('https://accounts.example.com') + expect(authorizationUrl.searchParams.get('client_id')).toBe('client-1') + expect(authorizationUrl.searchParams.get('state')).toBe('state-1') + expect(authorizationUrl.searchParams.get('nonce')).toBe('nonce-1') + expect(authorizationUrl.searchParams.get('login_hint')).toBe('person@example.com') + expect(authorizationUrl.searchParams.get('include_granted_scopes')).toBe('false') + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') + }) + + it('persists a verified provider identity and returned scopes', async () => { + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const grant = await adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + + expect(mockGetToken).toHaveBeenCalledWith({ + code: 'code-1', + redirectURI: 'https://sim.example.com/api/credential-groups/oauth/google-calendar/callback', + codeVerifier: 'verifier-1', + }) + expect(grant).toMatchObject({ + providerId: 'google-calendar', + providerSubjectId: 'google-sub-1', + providerTenantId: 'example.com', + displayName: 'person@example.com', + accessToken: 'access-1', + refreshToken: 'refresh-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + metadata: { + email: 'person@example.com', + displayName: 'Person', + avatarUrl: 'https://example.com/avatar.png', + }, + }) + }) + + it('rejects a different invited email', async () => { + mockVerifyIdentity.mockResolvedValueOnce({ + providerSubjectId: 'google-sub-2', + providerTenantId: null, + email: 'other@example.com', + emailVerified: true, + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).rejects.toMatchObject({ statusCode: 403 }) + }) +}) diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts new file mode 100644 index 00000000000..4f814d072b8 --- /dev/null +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -0,0 +1,347 @@ +import { randomBytes } from 'node:crypto' +import { + applyDefaultAccessTokenExpiry, + createAuthorizationURL, + type OAuth2Tokens, + validateAuthorizationCode, +} from '@better-auth/core/oauth2' +import { normalizeEmail } from '@sim/utils/string' +import type { ConnectorProviderConfig } from '@/lib/auth/connectors/managed-oauth' +import { getConnectorProviderConfig } from '@/lib/auth/connectors/providers' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialGroupOAuthNonceMatches } from '@/lib/credential-groups/oauth-state' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupOAuthError, + CredentialGroupProviderConfigurationError, + credentialGroupScopePolicyVersion, +} from '@/lib/credential-groups/provider-adapter' +import type { CredentialGroupStandardOAuthProvider } from '@/lib/credential-groups/providers' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { refreshOAuthToken } from '@/lib/oauth' + +const OAUTH_DISCOVERY_TIMEOUT_MS = 10_000 +const OAUTH_DISCOVERY_MAX_BYTES = 256 * 1024 + +interface OAuthEndpoints { + authorizationEndpoint: string + tokenEndpoint: string +} + +interface CurrentStandardOAuthProvider { + connector: ConnectorProviderConfig + policy: CredentialGroupProviderPolicy +} + +function staticParams( + value: ConnectorProviderConfig['authorizationUrlParams'], + label: string +): Record { + if (typeof value === 'function') { + throw new CredentialGroupProviderConfigurationError( + `${label} cannot depend on an authenticated Sim request` + ) + } + return value ?? {} +} + +async function resolveOAuthEndpoints( + connector: ConnectorProviderConfig, + providerName: string +): Promise { + if (connector.discoveryUrl) { + let response: Response + try { + response = await fetch(connector.discoveryUrl, { + headers: connector.discoveryHeaders, + signal: AbortSignal.timeout(OAUTH_DISCOVERY_TIMEOUT_MS), + }) + } catch { + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + let document: unknown + try { + document = await readResponseJsonWithLimit(response, { + maxBytes: OAUTH_DISCOVERY_MAX_BYTES, + label: `${providerName} OAuth discovery response`, + }) + } catch { + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + if (!document || typeof document !== 'object') { + throw new CredentialGroupOAuthError(`${providerName} OAuth configuration is invalid.`, 503) + } + const discovery = document as Record + if ( + typeof discovery.authorization_endpoint !== 'string' || + typeof discovery.token_endpoint !== 'string' + ) { + throw new CredentialGroupOAuthError(`${providerName} OAuth configuration is invalid.`, 503) + } + return { + authorizationEndpoint: discovery.authorization_endpoint, + tokenEndpoint: discovery.token_endpoint, + } + } + + if (!connector.authorizationUrl || !connector.tokenUrl) { + throw new CredentialGroupProviderConfigurationError( + `${providerName} OAuth endpoints are not configured` + ) + } + return { + authorizationEndpoint: connector.authorizationUrl, + tokenEndpoint: connector.tokenUrl, + } +} + +function getCurrentProvider( + provider: CredentialGroupStandardOAuthProvider +): CurrentStandardOAuthProvider { + const service = getCredentialGroupProviderService(provider) + const connector = getConnectorProviderConfig(service.providerId) + if (!connector?.managedOAuth) { + throw new CredentialGroupProviderConfigurationError( + `Managed ${service.name} authorization is not configured` + ) + } + const requiredScopes = [ + ...new Set([...(connector.scopes ?? []), ...connector.managedOAuth.additionalScopes]), + ] + if (requiredScopes.length === 0) { + throw new CredentialGroupProviderConfigurationError( + `Managed ${service.name} authorization has no scope policy` + ) + } + return { + connector, + policy: { + provider, + providerId: service.providerId, + authorizationAppId: connector.managedOAuth.getAuthorizationAppId(connector.clientId), + requiredScopes, + scopeVersion: credentialGroupScopePolicyVersion(requiredScopes), + }, + } +} + +function assertCurrentPolicy( + expected: CredentialGroupProviderPolicy, + current: CredentialGroupProviderPolicy +): void { + if ( + expected.provider !== current.provider || + expected.providerId !== current.providerId || + expected.authorizationAppId !== current.authorizationAppId || + expected.scopeVersion !== current.scopeVersion + ) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } +} + +function generatePkceVerifier(): string { + return randomBytes(64).toString('base64url') +} + +async function exchangeAuthorizationCode(params: { + connector: ConnectorProviderConfig + code: string + codeVerifier?: string + redirectUri: string + tokenEndpoint: string +}): Promise { + const { connector, code, codeVerifier, redirectUri, tokenEndpoint } = params + const tokens = connector.getToken + ? await connector.getToken({ code, redirectURI: redirectUri, codeVerifier }) + : await validateAuthorizationCode({ + headers: connector.authorizationHeaders, + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: connector.clientId, + clientSecret: connector.clientSecret, + redirectURI: redirectUri, + }, + tokenEndpoint, + authentication: connector.authentication, + additionalParams: staticParams(connector.tokenUrlParams, 'OAuth token parameters'), + }) + return applyDefaultAccessTokenExpiry(tokens, connector.accessTokenExpiresIn) +} + +export function createStandardOAuthCredentialGroupProviderAdapter( + provider: CredentialGroupStandardOAuthProvider +): CredentialGroupProviderAdapter { + return { + provider, + get requiresRefreshToken() { + return getCurrentProvider(provider).connector.managedOAuth!.requiresRefreshToken + }, + async getPolicy() { + return getCurrentProvider(provider).policy + }, + async prepareAuthorization(context, policy) { + const current = getCurrentProvider(provider) + assertCurrentPolicy(policy, current.policy) + const managed = current.connector.managedOAuth! + const endpoints = await resolveOAuthEndpoints( + current.connector, + getCredentialGroupProviderService(provider).name + ) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${provider}/callback` + const codeVerifier = managed.pkce ? generatePkceVerifier() : undefined + return { + redirectUri, + ...(codeVerifier ? { codeVerifier } : {}), + buildAuthorizationUrl: async ({ state, nonce }) => { + const authorizationUrl = await createAuthorizationURL({ + id: current.connector.providerId, + options: { + clientId: current.connector.clientId, + clientSecret: current.connector.clientSecret, + redirectURI: redirectUri, + }, + authorizationEndpoint: endpoints.authorizationEndpoint, + state, + ...(codeVerifier ? { codeVerifier } : {}), + scopes: policy.requiredScopes, + redirectURI: redirectUri, + prompt: managed.prompt ?? current.connector.prompt, + accessType: current.connector.accessType, + responseType: current.connector.responseType, + responseMode: current.connector.responseMode, + loginHint: context.email, + additionalParams: { + ...staticParams( + current.connector.authorizationUrlParams, + 'OAuth authorization parameters' + ), + ...managed.authorizationUrlParams, + nonce, + }, + }) + return authorizationUrl.toString() + }, + } + }, + async exchangeAndVerify({ context, attempt, code, policy }) { + const current = getCurrentProvider(provider) + assertCurrentPolicy(policy, current.policy) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${provider}/callback` + if (attempt.redirectUri !== redirectUri) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const managed = current.connector.managedOAuth! + if (managed.pkce && !attempt.codeVerifier) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const service = getCredentialGroupProviderService(provider) + const endpoints = await resolveOAuthEndpoints(current.connector, service.name) + let tokens: OAuth2Tokens + try { + tokens = await exchangeAuthorizationCode({ + connector: current.connector, + code, + ...(attempt.codeVerifier ? { codeVerifier: attempt.codeVerifier } : {}), + redirectUri: attempt.redirectUri, + tokenEndpoint: endpoints.tokenEndpoint, + }) + } catch { + throw new CredentialGroupOAuthError( + `${service.name} could not complete authorization. Please try again.`, + 502 + ) + } + if (tokens.tokenType !== 'Bearer' || !tokens.accessToken) { + throw new CredentialGroupOAuthError( + `${service.name} returned an incomplete authorization.`, + 502 + ) + } + let identity: Awaited> + try { + identity = await managed.verifyIdentity({ + tokens, + clientId: current.connector.clientId, + }) + } catch { + throw new CredentialGroupOAuthError( + `${service.name} returned an invalid identity token.`, + 502 + ) + } + if ( + !identity.emailVerified || + !identity.nonce || + !credentialGroupOAuthNonceMatches(identity.nonce, attempt.nonceHash) + ) { + throw new CredentialGroupOAuthError( + `${service.name} returned an invalid identity token.`, + 502 + ) + } + const email = normalizeEmail(identity.email) + if (email !== context.email) { + throw new CredentialGroupOAuthError( + `Sign in with ${context.email} to complete this invitation.`, + 403 + ) + } + if (!managed.hasRequiredScopes(identity.grantedScopes, policy.requiredScopes)) { + throw new CredentialGroupOAuthError( + `All requested ${service.name} permissions are required to connect this account.`, + 403 + ) + } + return { + providerId: policy.providerId, + providerSubjectId: identity.providerSubjectId, + providerTenantId: identity.providerTenantId, + displayName: email, + metadata: { + email, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + }, + accessToken: tokens.accessToken, + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), + grantedScopes: identity.grantedScopes, + accessTokenExpiresAt: tokens.accessTokenExpiresAt ?? null, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt ?? null, + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + return getCurrentProvider(provider).connector.managedOAuth!.hasRequiredScopes( + grantedScopes, + requiredScopes + ) + }, + async refreshToken(refreshToken) { + return refreshOAuthToken(getCurrentProvider(provider).policy.providerId, refreshToken) + }, + isTerminalRefreshError(errorCode) { + return getCurrentProvider(provider).connector.managedOAuth!.isTerminalRefreshError(errorCode) + }, + } +} diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts new file mode 100644 index 00000000000..7038b7f2eb3 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -0,0 +1,13 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' +import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' + +export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' + +export const managedOAuthCredentialDelegationPolicy = { + audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: ManagedOAuthCredentialApplicationContext + ) => principal.resourceScope?.credentialId === context.credentialId, +} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts new file mode 100644 index 00000000000..95c85426c0d --- /dev/null +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts @@ -0,0 +1,41 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' + +export class InvalidManagedOAuthDelegationError extends Error { + constructor() { + super('Managed credential execution requires valid workflow delegation') + this.name = 'InvalidManagedOAuthDelegationError' + } +} + +/** Authenticates and binds an executor delegation to one managed credential ID. */ +export async function authenticateManagedOAuthDelegation( + authorization: string, + credentialId: string +): Promise { + if (!authorization.startsWith('Bearer ')) throw new InvalidManagedOAuthDelegationError() + + try { + const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + return await bindInternalExecutorDelegation(claims, { + audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, + resourceScope: { credentialId }, + }) + } catch (error) { + if ( + error instanceof InvalidInternalDelegationTokenError || + error instanceof InvalidInternalDelegationBindingError + ) { + throw new InvalidManagedOAuthDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..3f1fad25074 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -7,4 +7,11 @@ export const credentialOperations = { workspaceApiKey: 'allow', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + useManagedOAuth: defineWorkspaceOperation({ + id: 'credentials.managed_oauth.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), } as const diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts new file mode 100644 index 00000000000..0a3e2b24b73 --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + resolvePermission: vi.fn(), + resolveToken: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-oauth', () => ({ + loadManagedOAuthCredentialApplicationContext: mocks.loadContext, + resolveManagedOAuthToken: mocks.resolveToken, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CREDENTIAL_ACCESSED: 'credential.accessed' }, + AuditResourceType: { CREDENTIAL: 'credential' }, + recordAudit: mocks.recordAudit, +})) + +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' + +const context = { + credentialId: 'credential-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const input = { + credentialId: 'credential-1', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + toolId: 'gmail_read', +} + +function executorPrincipal(credentialId = 'credential-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } +} + +describe('resolveManagedOAuthCredentialToken', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveToken.mockResolvedValue({ accessToken: 'access-token', refreshed: false }) + }) + + it('rejects unsupported principals before loading the credential', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + resolveManagedOAuthCredentialToken.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadContext).not.toHaveBeenCalled() + }) + + it('rejects a delegation scoped to another credential', async () => { + await expect( + resolveManagedOAuthCredentialToken.execute({ + principal: executorPrincipal('credential-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveToken).not.toHaveBeenCalled() + }) + + it('resolves the token only after current workspace authorization', async () => { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal: executorPrincipal(), + input, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.resolveToken).toHaveBeenCalledWith({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }) + expect(result).toEqual({ accessToken: 'access-token', refreshed: false }) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts new file mode 100644 index 00000000000..3213f8731f8 --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts @@ -0,0 +1,47 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { managedOAuthCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedOAuthCredentialApplicationContext, + type ResolvedManagedOAuthToken, + resolveManagedOAuthToken, +} from '@/lib/credentials/managed-oauth' + +export interface ResolveManagedOAuthTokenInput { + credentialId: string + expectedProviderId: string + requiredScopes: string[] + toolId: string +} + +export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedOAuth, + resolveContext: async ({ input }: { input: ResolveManagedOAuthTokenInput }) => { + const context = await loadManagedOAuthCredentialApplicationContext(input.credentialId) + if (!context) throw new OrchestrationError('not_found', 'Managed credential not found') + return context + }, + authorizationOptions: { delegation: managedOAuthCredentialDelegationPolicy }, + execute: async ({ input, context }): Promise => + resolveManagedOAuthToken({ + credentialId: context.credentialId, + workspaceId: context.workspaceId, + expectedProviderId: input.expectedProviderId, + requiredScopes: input.requiredScopes, + }), + projectAudit({ input, context }) { + return { + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Accessed managed OAuth credential for provider ${input.expectedProviderId}`, + metadata: { + provider: input.expectedProviderId, + credentialType: 'managed_oauth', + toolId: input.toolId, + }, + } + }, +}) diff --git a/apps/sim/lib/credentials/managed-oauth.test.ts b/apps/sim/lib/credentials/managed-oauth.test.ts new file mode 100644 index 00000000000..1df5b3543e7 --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBilling: vi.fn(), + isAvailable: vi.fn(), + getAdapter: vi.fn(), + decryptSecret: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getBilling, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: mocks.isAvailable, +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapterByProviderId: mocks.getAdapter, +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mocks.decryptSecret, + encryptSecret: vi.fn(), +})) + +import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' + +describe('managed OAuth token resolution', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getBilling.mockResolvedValue({ plan: 'enterprise' }) + mocks.isAvailable.mockResolvedValue(true) + mocks.decryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-oauth-token-set', + version: 1, + tokenType: 'Bearer', + accessToken: 'xoxp-slack-token', + }), + }) + mocks.getAdapter.mockReturnValue({ + getPolicy: vi.fn().mockResolvedValue({ + authorizationAppId: 'slack:A123:T123', + scopeVersion: 1, + }), + hasRequiredScopes: vi.fn().mockReturnValue(true), + }) + }) + + it('uses a non-expiring Slack access token without entering refresh', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + providerId: 'slack', + authorizationAppId: 'slack:A123:T123', + managedOauthScopeVersion: 1, + managedOauthStatus: 'active', + grantedScopes: ['chat:write'], + encryptedOauthTokenSet: 'encrypted-token-set', + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }, + ]) + + await expect( + resolveManagedOAuthToken({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'slack', + requiredScopes: ['chat:write'], + }) + ).resolves.toEqual({ accessToken: 'xoxp-slack-token', refreshed: false }) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts new file mode 100644 index 00000000000..196711eab8b --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -0,0 +1,441 @@ +import { db } from '@sim/db' +import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, sql } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + type CredentialGroupProviderAdapter, + CredentialGroupProviderConfigurationError, + type CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapterByProviderId } from '@/lib/credential-groups/provider-registry' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('ManagedOAuthCredential') + +const MANAGED_OAUTH_TOKEN_SET_TYPE = 'managed-oauth-token-set' as const +const MANAGED_OAUTH_TOKEN_SET_VERSION = 1 as const +const ACCESS_TOKEN_REFRESH_WINDOW_MS = 30_000 + +export type ManagedOAuthCredentialErrorCode = + | 'MANAGED_CREDENTIAL_NOT_FOUND' + | 'MANAGED_CREDENTIAL_UNAVAILABLE' + | 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH' + | 'MANAGED_CREDENTIAL_REVOKED' + | 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + | 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE' + | 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET' + | 'MANAGED_CREDENTIAL_REFRESH_FAILED' + +export class ManagedOAuthCredentialError extends Error { + constructor( + readonly code: ManagedOAuthCredentialErrorCode, + message: string, + readonly statusCode: 401 | 403 | 404 | 500 | 502 | 503 + ) { + super(message) + this.name = 'ManagedOAuthCredentialError' + } +} + +export interface ManagedOAuthTokenSet { + type: typeof MANAGED_OAUTH_TOKEN_SET_TYPE + version: typeof MANAGED_OAUTH_TOKEN_SET_VERSION + tokenType: 'Bearer' + accessToken: string + refreshToken?: string + idToken?: string +} + +export interface ResolvedManagedOAuthToken { + accessToken: string + idToken?: string + refreshed: boolean +} + +type DbOrTx = typeof db | Parameters[0]>[0] + +interface ResolveManagedOAuthTokenParams { + credentialId: string + workspaceId: string + expectedProviderId: string + requiredScopes: string[] +} + +export interface ManagedOAuthCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialId: string +} + +function isManagedOAuthTokenSet(value: unknown): value is ManagedOAuthTokenSet { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.type === MANAGED_OAUTH_TOKEN_SET_TYPE && + candidate.version === MANAGED_OAUTH_TOKEN_SET_VERSION && + candidate.tokenType === 'Bearer' && + typeof candidate.accessToken === 'string' && + candidate.accessToken.length > 0 && + (candidate.refreshToken === undefined || + (typeof candidate.refreshToken === 'string' && candidate.refreshToken.length > 0)) && + (candidate.idToken === undefined || + (typeof candidate.idToken === 'string' && candidate.idToken.length > 0)) + ) +} + +/** Encrypts the versioned token envelope written by managed OAuth callbacks and refreshes. */ +export async function encryptManagedOAuthTokenSet(tokenSet: { + accessToken: string + refreshToken?: string + idToken?: string +}): Promise { + const accessToken = tokenSet.accessToken.trim() + const refreshToken = tokenSet.refreshToken?.trim() + const idToken = tokenSet.idToken?.trim() + if (!accessToken) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed OAuth access token is empty', + 500 + ) + } + + const envelope: ManagedOAuthTokenSet = { + type: MANAGED_OAUTH_TOKEN_SET_TYPE, + version: MANAGED_OAUTH_TOKEN_SET_VERSION, + tokenType: 'Bearer', + accessToken, + ...(refreshToken ? { refreshToken } : {}), + ...(idToken ? { idToken } : {}), + } + return (await encryptSecret(JSON.stringify(envelope))).encrypted +} + +/** Decrypts and strictly validates a managed OAuth token envelope. */ +export async function decryptManagedOAuthTokenSet( + encryptedTokenSet: string +): Promise { + try { + const { decrypted } = await decryptSecret(encryptedTokenSet) + const parsed: unknown = JSON.parse(decrypted) + if (!isManagedOAuthTokenSet(parsed)) throw new Error('Invalid managed OAuth token envelope') + return parsed + } catch (error) { + logger.error('Failed to decrypt managed OAuth token set', { + error: getErrorMessage(error), + }) + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential token data is invalid', + 500 + ) + } +} + +async function getManagedCredential(exec: DbOrTx, credentialId: string, workspaceId?: string) { + const [row] = await exec + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + authorizationAppId: credential.authorizationAppId, + managedOauthScopeVersion: credential.managedOauthScopeVersion, + managedOauthStatus: credential.managedOauthStatus, + grantedScopes: credential.grantedScopes, + encryptedOauthTokenSet: credential.encryptedOauthTokenSet, + accessTokenExpiresAt: credential.accessTokenExpiresAt, + refreshTokenExpiresAt: credential.refreshTokenExpiresAt, + credentialGroupId: credentialGroupEnrollment.credentialGroupId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_oauth'), + workspaceId ? eq(credential.workspaceId, workspaceId) : undefined + ) + ) + .limit(1) + return row ?? null +} + +/** Resolves the canonical workspace context for authorization without exposing token material. */ +export async function loadManagedOAuthCredentialApplicationContext( + credentialId: string +): Promise { + const row = await getManagedCredential(db, credentialId) + if (!row) return null + + const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + if (!workspaceContext) return null + return { ...workspaceContext, credentialId: row.id } +} + +async function assertManagedCredentialUsable( + row: NonNullable>>, + expectedProviderId: string, + requiredScopes: string[] +): Promise { + if (row.providerId !== expectedProviderId) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH', + 'Managed credential belongs to a different provider', + 403 + ) + } + if (row.managedOauthStatus === 'revoked') { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REVOKED', + 'Managed credential has been revoked', + 401 + ) + } + if (row.managedOauthStatus !== 'active') { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be authorized again', + 401 + ) + } + if (!row.authorizationAppId || !row.encryptedOauthTokenSet || !row.grantedScopes?.length) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential metadata is incomplete', + 500 + ) + } + + const adapter = getCredentialGroupProviderAdapterByProviderId(row.providerId) + let policy: CredentialGroupProviderPolicy + try { + policy = await adapter.getPolicy(undefined, { + workspaceId: row.workspaceId, + credentialGroupId: row.credentialGroupId, + authorizationAppId: row.authorizationAppId, + }) + } catch (error) { + if (!(error instanceof CredentialGroupProviderConfigurationError)) throw error + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential authorization app is unavailable', + 401 + ) + } + if ( + row.authorizationAppId !== policy.authorizationAppId || + row.managedOauthScopeVersion !== policy.scopeVersion + ) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential was authorized with a different OAuth app', + 401 + ) + } + + if (!adapter.hasRequiredScopes(row.grantedScopes, requiredScopes)) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE', + 'Managed credential is missing one or more required scopes', + 403 + ) + } + return adapter +} + +function hasFreshAccessToken(accessTokenExpiresAt: Date | null, now: Date): boolean { + return ( + accessTokenExpiresAt === null || + accessTokenExpiresAt.getTime() > now.getTime() + ACCESS_TOKEN_REFRESH_WINDOW_MS + ) +} + +async function markManagedCredentialNeedsReauth( + exec: DbOrTx, + credentialId: string, + updatedAt: Date +): Promise { + await exec + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt }) + .where(and(eq(credential.id, credentialId), eq(credential.managedOauthStatus, 'active'))) +} + +/** Resolves a managed credential ID into a usable token without exposing it to list APIs. */ +export async function resolveManagedOAuthToken( + params: ResolveManagedOAuthTokenParams +): Promise { + const initial = await getManagedCredential(db, params.credentialId, params.workspaceId) + if (!initial) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ) + } + + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(initial.workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_UNAVAILABLE', + 'Managed credentials are not available for this workspace', + 403 + ) + } + + try { + await assertManagedCredentialUsable(initial, params.expectedProviderId, params.requiredScopes) + } catch (error) { + if ( + error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + ) { + await markManagedCredentialNeedsReauth(db, initial.id, new Date()) + } + throw error + } + const initialTokenSet = await decryptManagedOAuthTokenSet(initial.encryptedOauthTokenSet!) + const now = new Date() + if (hasFreshAccessToken(initial.accessTokenExpiresAt, now)) { + return { + accessToken: initialTokenSet.accessToken, + ...(initialTokenSet.idToken ? { idToken: initialTokenSet.idToken } : {}), + refreshed: false, + } + } + + const refreshOutcome = await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`managed-oauth:${params.credentialId}`}, 0))` + ) + const current = await getManagedCredential(tx, params.credentialId, params.workspaceId) + if (!current) { + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ), + } + } + + let adapter: CredentialGroupProviderAdapter + try { + adapter = await assertManagedCredentialUsable( + current, + params.expectedProviderId, + params.requiredScopes + ) + } catch (error) { + if ( + error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + ) { + await markManagedCredentialNeedsReauth(tx, current.id, new Date()) + } + return { + error: + error instanceof ManagedOAuthCredentialError + ? error + : new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential metadata is invalid', + 500 + ), + } + } + + const currentTokenSet = await decryptManagedOAuthTokenSet(current.encryptedOauthTokenSet!) + const lockedAt = new Date() + if (hasFreshAccessToken(current.accessTokenExpiresAt, lockedAt)) { + return { + token: { + accessToken: currentTokenSet.accessToken, + ...(currentTokenSet.idToken ? { idToken: currentTokenSet.idToken } : {}), + refreshed: false, + }, + } + } + + if ( + !currentTokenSet.refreshToken || + (current.refreshTokenExpiresAt && current.refreshTokenExpiresAt <= lockedAt) + ) { + await markManagedCredentialNeedsReauth(tx, current.id, lockedAt) + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be authorized again', + 401 + ), + } + } + + const refreshed = await adapter.refreshToken(currentTokenSet.refreshToken) + if (!refreshed.ok) { + const terminal = adapter.isTerminalRefreshError(refreshed.errorCode) + if (terminal) { + await markManagedCredentialNeedsReauth(tx, current.id, new Date()) + } + return { + error: new ManagedOAuthCredentialError( + terminal ? 'MANAGED_CREDENTIAL_NEEDS_REAUTH' : 'MANAGED_CREDENTIAL_REFRESH_FAILED', + terminal + ? 'Managed credential needs to be authorized again' + : 'Managed credential refresh failed', + terminal ? 401 : 502 + ), + } + } + + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + idToken: currentTokenSet.idToken, + }) + const refreshedAt = new Date() + const accessTokenExpiresAt = new Date(refreshedAt.getTime() + refreshed.expiresIn * 1000) + const [updated] = await tx + .update(credential) + .set({ + encryptedOauthTokenSet, + accessTokenExpiresAt, + lastRefreshedAt: refreshedAt, + updatedAt: refreshedAt, + }) + .where(and(eq(credential.id, current.id), eq(credential.managedOauthStatus, 'active'))) + .returning({ id: credential.id }) + if (!updated) { + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential changed while its token was refreshing', + 401 + ), + } + } + + return { + token: { + accessToken: refreshed.accessToken, + ...(currentTokenSet.idToken ? { idToken: currentTokenSet.idToken } : {}), + refreshed: true, + }, + } + }) + + if ('error' in refreshOutcome && refreshOutcome.error) throw refreshOutcome.error + if ('token' in refreshOutcome && refreshOutcome.token) return refreshOutcome.token + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REFRESH_FAILED', + 'Managed credential refresh returned no token', + 500 + ) +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index f0dabc6795b..ff34fd0a244 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -1,11 +1,22 @@ import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credential, environment, webhook, workspaceEnvironment } from '@sim/db/schema' +import { + credential, + credentialGroup, + environment, + webhook, + workspaceEnvironment, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { decryptSecret } from '@/lib/core/security/encryption' +import { listSlackCredentialGroupConfigurationsForBot } from '@/lib/credential-groups/provider-configuration' +import { + SlackManagedUsersError, + verifySlackCustomBotAppIdentity, +} from '@/lib/credential-groups/slack-managed-users' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { @@ -169,6 +180,9 @@ export async function performUpdateCredential( if (!access.credential) { return { success: false, error: 'Credential not found', errorCode: 'not_found' } } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } if (!access.hasWorkspaceAccess || !access.isAdmin) { return { success: false, @@ -250,6 +264,41 @@ export async function performUpdateCredential( : null try { + const slackConfigurations = + providerId === SLACK_CUSTOM_BOT_PROVIDER_ID + ? await listSlackCredentialGroupConfigurationsForBot({ + workspaceId: access.credential.workspaceId, + slackBotCredentialId: access.credential.id, + }) + : [] + if (slackConfigurations.length > 0) { + if (!params.botToken) { + throw new ServiceAccountSecretError( + 'Bot token is required to reconnect a managed-user Slack app' + ) + } + try { + const identity = await verifySlackCustomBotAppIdentity(params.botToken) + if ( + slackConfigurations.some( + (configuration) => + identity.appId !== configuration.appId || identity.teamId !== configuration.teamId + ) + ) { + throw new ServiceAccountSecretError( + 'This bot token belongs to a different Slack app or workspace. Create a new custom bot credential for a different Slack app.' + ) + } + } catch (error) { + if (error instanceof ServiceAccountSecretError) throw error + if (error instanceof SlackManagedUsersError) { + throw new ServiceAccountSecretError(error.message) + } + throw new ServiceAccountSecretError( + 'Could not verify that the replacement bot token belongs to the configured Slack app' + ) + } + } const secret = await verifyAndBuildServiceAccountSecret(providerId, { signingSecret: params.signingSecret, botToken: params.botToken, @@ -387,6 +436,9 @@ export async function performDeleteCredential( if (!access.credential) { return { success: false, error: 'Credential not found', errorCode: 'not_found' } } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } if (!access.hasWorkspaceAccess || !access.isAdmin) { return { success: false, @@ -402,6 +454,31 @@ export async function performDeleteCredential( } } + if (access.credential.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const [binding] = await db + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, access.credential.workspaceId), + sql`EXISTS ( + SELECT 1 + FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'slackBotCredentialId' = ${access.credential.id} + AND option->>'status' = 'active' + )` + ) + ) + .limit(1) + if (binding) { + return { + success: false, + error: 'Remove this custom Slack bot from its Credential Groups before deleting it.', + errorCode: 'conflict', + } + } + } + if (access.credential.type === 'env_personal' && access.credential.envKey) { const ownerUserId = access.credential.envOwnerUserId if (!ownerUserId) { diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index cc5dfb3a368..e5ff19c7d1a 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -1,9 +1,62 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' -import { listWorkspacePrincipalCredentials } from '@/lib/credentials/queries' +import { + listVisibleWorkspaceCredentials, + listWorkspacePrincipalCredentials, +} from '@/lib/credentials/queries' + +describe('listVisibleWorkspaceCredentials', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('always excludes managed OAuth credentials from selector-backed listings', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await listVisibleWorkspaceCredentials({ + workspaceId: 'workspace-1', + userId: 'user-1', + workspaceAccess: { canAdmin: true }, + }) + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) + + it('does not expose Credential Group configuration on a custom Slack bot', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Support bot', + description: null, + providerId: 'slack-custom-bot', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + encryptedServiceAccountKey: 'encrypted', + memberRole: null, + }, + ]) + + const { data } = await listVisibleWorkspaceCredentials({ + workspaceId: 'workspace-1', + userId: 'user-1', + workspaceAccess: { canAdmin: true }, + }) + const [result] = data + + expect(result).not.toHaveProperty('managedOAuthConfigurationStatus') + expect(result).not.toHaveProperty('authorizationAppId') + expect(result).not.toHaveProperty('managedOauthScopeVersion') + }) +}) describe('listWorkspacePrincipalCredentials', () => { beforeEach(() => { diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 607145b80a7..92122ebf37f 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import { type CursorKey, @@ -119,7 +119,10 @@ export async function listVisibleWorkspaceCredentials(params: { limit, } = params - const whereClauses = [eq(credential.workspaceId, workspaceId)] + const whereClauses = [ + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth'), + ] if (types?.length) whereClauses.push(inArray(credential.type, types)) if (providerId) whereClauses.push(eq(credential.providerId, providerId)) const ownedEnvSecretsClause = params.ownedEnvSecretsOnly diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 7a1cf470d4b..84eaf0fc674 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -114,6 +114,17 @@ export async function resolveOAuthAccountId( } } + if (credentialRow.type === 'managed_oauth') { + return { + accountId: '', + credentialId: credentialRow.id, + credentialType: 'managed_oauth', + workspaceId: credentialRow.workspaceId, + providerId: credentialRow.providerId ?? undefined, + usedCredentialTable: true, + } + } + if (credentialRow.type !== 'oauth' || !credentialRow.accountId) { return null } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 0ba237f5fa2..125f3de697c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -2003,7 +2003,6 @@ export async function refreshOAuthToken( hasClientId: !!config.clientId, hasClientSecret: !!config.clientSecret, hasRefreshToken: !!refreshToken, - refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none', }) return { ok: false, diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 4ee18823f8b..298ccd29592 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -9,6 +9,7 @@ import type { AuthResult } from '@/lib/auth/hybrid' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getCredential, + type ResolvedCredential, refreshTokenIfNeeded, resolveOAuthAccountId, resolveServiceAccountToken, @@ -45,6 +46,8 @@ export interface ResolveCredentialTokenInput { */ callerUserId?: string auditRequest?: CredentialAuditRequest + /** Reuses a credential lookup already performed by the route's managed-OAuth dispatch. */ + resolvedCredential?: ResolvedCredential | null } export type ResolveCredentialTokenResult = @@ -187,7 +190,9 @@ export async function resolveCredentialToken( * on the other, so they resolve together — this runs per credentialed tool call. */ const [resolved, authz] = await Promise.all([ - resolveOAuthAccountId(credentialId), + input.resolvedCredential === undefined + ? resolveOAuthAccountId(credentialId) + : input.resolvedCredential, authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), ]) diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index c822757165b..c0d31b0d5c7 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -334,25 +334,45 @@ export interface PostHogEventMap { } credential_connected: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id: string } credential_deleted: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id: string } credential_shared: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' role: 'admin' | 'member' workspace_id: string } credential_unshared: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' workspace_id: string } @@ -733,7 +753,12 @@ export interface PostHogEventMap { /** A stored credential's plaintext secret was deliberately retrieved via the token API. */ credential_used: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id?: string } diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index d350c5a1763..4b01ec40ece 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -1,6 +1,7 @@ import { cache } from 'react' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -27,6 +28,7 @@ async function resolveWorkspaceHostContextForViewer( ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ isMember: false, isAdmin: false }), ]) + const credentialGroupsAvailable = await isCredentialGroupsAvailable(ownerBilling) return { workspace: { @@ -42,6 +44,9 @@ async function resolveWorkspaceHostContextForViewer( isHostOrganizationMember: hostOrganizationAccess.isMember, isHostOrganizationAdmin: hostOrganizationAccess.isAdmin, }, + features: { + credentialGroups: credentialGroupsAvailable, + }, } } diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index f31b7ba2b19..73d77475f54 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}}},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}}},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}}},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}}},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}}},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}}},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}}},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}}},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}}},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}}},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}}},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}}},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}}},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}}},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}}},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}}},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}}},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}}},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}}},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}}},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}}},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}}},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}}},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}}},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}}},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}}},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}}},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}}},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}}},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}}},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}}},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}}},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}}},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}}},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}}},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}}},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}}},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}}},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}}},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}}},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}}},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}}},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}}},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}}},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}}},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}}},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}}},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}}},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}}},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}}},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}}},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}}},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}}},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}}},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}}},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}}},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}}},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}}},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}}},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}}},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}}},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}}},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}}},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}}},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}}},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}}},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}}},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}}},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}}},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}}},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}}},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}}},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}}},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}}},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}}},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}}},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}}},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}}},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}}},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}}},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}}},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}}},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}}},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}}},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}}},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}}},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}}},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}}},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}}},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}}},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}}},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}}},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}}},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}}},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}}},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}}},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}}},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}}},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}}},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}}},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}}},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"}},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"}},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"}},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}}},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}}},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"}}},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in (defaults to first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"}}},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"}}},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,