From 18cd918a08acce00337086b98d78831b2389aaf6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 19 Sep 2026 10:56:30 -0700 Subject: [PATCH 1/2] fix(slack-search): reuse manifest installation in setup --- apps/docs/content/docs/search/slack.mdx | 4 +- .../knowledge/slack/setup/connect/route.ts | 19 + .../api/knowledge/slack/setup/route.test.ts | 35 +- .../organization-search-slack.test.tsx | 18 +- .../slack-search-setup-wizard.tsx | 61 +- .../slack-managed-users-access.test.tsx | 57 ++ .../queries/organization-accounts.test.tsx | 2 +- .../hooks/queries/organization-accounts.ts | 2 +- apps/sim/hooks/queries/slack-search.test.tsx | 115 +++ apps/sim/hooks/queries/slack-search.ts | 33 +- .../hooks/queries/utils/slack-search-keys.ts | 10 + apps/sim/lib/api/contracts/knowledge/slack.ts | 18 + .../lib/knowledge/application/operations.ts | 9 + .../application/slack-search/setup.test.ts | 183 ++++ .../application/slack-search/setup.ts | 815 ++++++++++-------- apps/sim/lib/slack-search/oauth-state.test.ts | 25 + 16 files changed, 986 insertions(+), 420 deletions(-) create mode 100644 apps/sim/app/api/knowledge/slack/setup/connect/route.ts create mode 100644 apps/sim/hooks/queries/slack-search.test.tsx create mode 100644 apps/sim/hooks/queries/utils/slack-search-keys.ts diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 4d540e11521..be46d622a9b 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -56,7 +56,7 @@ Open **Settings → Sources → Add source** and select **Slack**. Complete **Se ### Configure an app in Slack -Select **Install Sim Search** to open setup. In **Create Slack app**, select **Create app** and choose the target workspace. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled. +Select **Install Sim Search** to open setup. In **Create Slack app**, select **Create app**, choose the target workspace, and complete Slack's app creation and installation flow. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled and complete any required Slack administrator approval. You can also open this wizard from **Settings → Sim Search in Slack → Set up**. @@ -64,7 +64,7 @@ Return to Sim and select **Continue**. In **Slack app credentials**, paste **Cli Sim Search in Slack setup with placeholders for Client ID, Client Secret, and Signing Secret -Select **Continue**, then **Install in Slack**. Approve the installation in Slack. Sim saves the bot connection and opens **Settings → Sim Search in Slack**. Complete any required Slack administrator approval before continuing. +Select **Continue**. In **Connect installed Slack app**, paste the **Bot User OAuth Token** from the app's **OAuth & Permissions** page, then select **Connect app**. If Slack requests updated permissions, approve them there first. Sim validates and saves the existing bot connection without starting another installation. diff --git a/apps/sim/app/api/knowledge/slack/setup/connect/route.ts b/apps/sim/app/api/knowledge/slack/setup/connect/route.ts new file mode 100644 index 00000000000..2a9d8ee35c0 --- /dev/null +++ b/apps/sim/app/api/knowledge/slack/setup/connect/route.ts @@ -0,0 +1,19 @@ +import { connectCustomSlackSearchContract } from '@/lib/api/contracts/knowledge/slack' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { connectCustomSlackSearch } from '@/lib/knowledge/application/slack-search/setup' + +export const POST = defineInternalJsonRoute({ + contract: connectCustomSlackSearchContract, + auth: internalSessionAuth, + operation: knowledgeOperations.connectCustomSlackInstallation, + rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: connectCustomSlackSearch, +}) diff --git a/apps/sim/app/api/knowledge/slack/setup/route.test.ts b/apps/sim/app/api/knowledge/slack/setup/route.test.ts index 49c21d524ce..249f5b1aacf 100644 --- a/apps/sim/app/api/knowledge/slack/setup/route.test.ts +++ b/apps/sim/app/api/knowledge/slack/setup/route.test.ts @@ -2,10 +2,14 @@ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn() })) +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn(), connect: vi.fn() })) vi.mock('@/lib/knowledge/application/slack-search/setup', async () => { const { knowledgeOperations } = await import('@/lib/knowledge/application/operations') return { + connectCustomSlackSearch: { + operation: knowledgeOperations.connectCustomSlackInstallation, + execute: mocks.connect, + }, prepareSlackSearchSetup: { operation: knowledgeOperations.prepareSlackInstallation, execute: mocks.prepare, @@ -19,9 +23,15 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => { import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST as start } from '@/app/api/knowledge/slack/oauth/route' +import { POST as connect } from '@/app/api/knowledge/slack/setup/connect/route' import { POST as prepare } from '@/app/api/knowledge/slack/setup/route' -const input = { organizationId: 'organization-1', name: 'Sim Search', description: 'Search' } +const input = { + organizationId: 'organization-1', + name: 'Sim Search', + description: 'Search', + botToken: 'xoxb-existing', +} beforeEach(() => { vi.clearAllMocks() @@ -34,6 +44,7 @@ beforeEach(() => { describe.each([ ['prepare', prepare, mocks.prepare], ['OAuth', start, mocks.start], + ['connect installed app', connect, mocks.connect], ] as const)('Slack %s route errors', (_name, route, execute) => { it('returns application validation errors', async () => { execute.mockRejectedValue( @@ -59,3 +70,23 @@ describe.each([ expect(execute).not.toHaveBeenCalled() }) }) + +it('connects an installed app with the current session and returns no install URL', async () => { + mocks.connect.mockResolvedValueOnce({ organizationId: input.organizationId }) + const response = await connect(createMockRequest('POST', input)) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ organizationId: input.organizationId }) + expect(mocks.connect).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin', sessionId: 'session' }, + input, + }) + ) + expect(mocks.start).not.toHaveBeenCalled() +}) + +it.each(['', ' ', undefined])('requires an existing bot token: %s', async (botToken) => { + const response = await connect(createMockRequest('POST', { ...input, botToken })) + expect(response.status).toBe(400) + expect(mocks.connect).not.toHaveBeenCalled() +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx index a350cb46405..0122377ac84 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ configure: vi.fn(), remove: vi.fn(), install: vi.fn(), + connect: vi.fn(), refetch: vi.fn(), copy: vi.fn(), removeError: null as Error | null, @@ -25,6 +26,7 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, })) vi.mock('@/hooks/queries/slack-search', () => ({ + useConnectCustomSlackSearch: () => ({ mutate: mocks.connect, isPending: false, reset: vi.fn() }), useSlackSearchInstallations: mocks.list, useSlackSearchManifest: mocks.manifest, useConfigureSlackSearch: () => ({ mutate: mocks.configure, isPending: false }), @@ -395,8 +397,17 @@ describe('Slack Search settings and shared wizard', () => { document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]') ).toHaveLength(3) await click('Continue') - await click('Reconnect in Slack') - expect(mocks.install).toHaveBeenCalledWith( + expect(button('Connect app')).toBeDisabled() + await act(async () => { + const input = document.querySelector('input[placeholder="xoxb-..."]')! + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'xoxb-installed' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await click('Connect app') + expect(mocks.connect).toHaveBeenCalledWith( expect.objectContaining({ installationId: 'installation-1', organizationId: 'org-1', @@ -404,7 +415,8 @@ describe('Slack Search settings and shared wizard', () => { }), expect.any(Object) ) - expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret') + expect(mocks.connect.mock.calls[0][0]).not.toHaveProperty('clientSecret') + expect(mocks.install).not.toHaveBeenCalled() }) it('keeps the update action available when clipboard access fails', async () => { diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index 3cc991913e9..bbc10320c88 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -16,7 +16,11 @@ import { SLACK_SEARCH_DEFAULT_DESCRIPTION, SLACK_SEARCH_DEFAULT_NAME, } from '@/lib/slack-search/manifest' -import { useSlackSearchManifest, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search' +import { + useConnectCustomSlackSearch, + useSlackSearchManifest, + useStartSlackSearchOAuth, +} from '@/hooks/queries/slack-search' interface SlackSearchSetupWizardProps { organizationId: string @@ -40,14 +44,16 @@ export function SlackSearchSetupWizard({ const description = SLACK_SEARCH_DEFAULT_DESCRIPTION const prepare = useSlackSearchManifest(organizationId, name) const oauth = useStartSlackSearchOAuth() - const [step, setStep] = useState<'manifest' | 'credentials' | 'install'>('manifest') + const connect = useConnectCustomSlackSearch() + const [step, setStep] = useState<'manifest' | 'credentials' | 'token'>('manifest') const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') const [signingSecret, setSigningSecret] = useState('') + const [botToken, setBotToken] = useState('') const [configurationCopied, setConfigurationCopied] = useState(false) const [copyError, setCopyError] = useState(null) - const error = prepare.error ?? oauth.error ?? copyError - const busy = oauth.isPending + const error = prepare.error ?? oauth.error ?? connect.error ?? copyError + const busy = oauth.isPending || connect.isPending const configuredAppId = appId ?? prepare.data?.existingApp?.appId async function copyConfiguration() { @@ -83,20 +89,21 @@ export function SlackSearchSetupWizard({ if (step === 'manifest') { setStep('credentials') } else if (step === 'credentials') { - setStep('install') + setStep('token') } else { - oauth.mutate( + connect.mutate( { organizationId, installationId, name, description, + botToken: botToken.trim(), ...(clientId.trim() ? { clientId: clientId.trim() } : {}), ...(clientSecret.trim() ? { clientSecret: clientSecret.trim() } : {}), ...(signingSecret.trim() ? { signingSecret: signingSecret.trim() } : {}), }, { - onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl), + onSuccess: onClose, } ) } @@ -196,9 +203,7 @@ export function SlackSearchSetupWizard({ : 'Create Slack app' : step === 'credentials' ? 'Slack app credentials' - : installationId - ? 'Reconnect in Slack' - : 'Install in Slack' + : 'Connect installed Slack app' return ( )} {step === 'credentials' && ( @@ -268,13 +273,22 @@ export function SlackSearchSetupWizard({ /> )} - {step === 'install' && ( -

- {installationId - ? 'Approve the updated permissions for' - : 'Choose your workspace and approve'}{' '} - {name} in Slack. -

+ {step === 'token' && ( + <> +

+ Copy the Bot User OAuth Token from OAuth & Permissions in your installed Slack + app. If Slack requests updated permissions, approve them there first. +

+ + )} {error?.message} @@ -319,19 +333,22 @@ export function SlackSearchSetupWizard({ disabled: busy, onClick: () => { oauth.reset() - setStep(step === 'install' ? 'credentials' : 'manifest') + connect.reset() + setStep(step === 'token' ? 'credentials' : 'manifest') }, } } primaryAction={{ - label: busy ? 'Connecting…' : step === 'install' ? title : 'Continue', + label: busy ? 'Connecting…' : step === 'token' ? 'Connect app' : 'Continue', onClick: advance, disabled: busy || (step === 'manifest' ? Boolean(configuredAppId && !configurationCopied) - : !installationId && - (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), + : step === 'token' + ? !botToken.trim() + : !installationId && + (!clientId.trim() || !clientSecret.trim() || !signingSecret.trim())), }} />
diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx index 1b07c233ca3..03eb34d9bf3 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ refetchApps: vi.fn(), manifest: vi.fn(), install: vi.fn(), + connect: vi.fn(), accounts: vi.fn(), refetchAccounts: vi.fn(), })) @@ -31,6 +32,7 @@ vi.mock('@/hooks/queries/scoped-credentials', () => ({ })) vi.mock('@/hooks/queries/slack-search', () => ({ + useConnectCustomSlackSearch: () => ({ mutate: mocks.connect, isPending: false, reset: vi.fn() }), useSlackSearchInstallations: mocks.apps, useSlackSearchManifest: mocks.manifest, useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }), @@ -389,6 +391,61 @@ describe('Slack member access selection', () => { expect(mocks.onOpenChange).not.toHaveBeenCalled() }) + it('finishes manifest setup using the installed bot token without installing the app again', async () => { + await render(undefined, [], 'org-1') + await clickButton('Install Sim Search') + expect(document.querySelector('a')?.href).toBe('https://api.slack.com/apps') + await clickButton('Continue') + await fill('Paste your Slack app’s client ID', 'fixture-client') + await fill('Paste your Slack app’s client secret', 'fixture-secret') + await fill('Paste your Slack app’s signing secret', 'fixture-signing') + await clickButton('Continue') + expect(document.body.textContent).toContain('Connect installed Slack app') + expect(document.body.textContent).not.toContain('Install in Slack') + await fill('xoxb-...', 'xoxb-already-installed') + await clickButton('Connect app') + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith( + { + organizationId: 'org-1', + installationId: undefined, + name: 'Sim Search', + description: expect.any(String), + clientId: 'fixture-client', + clientSecret: 'fixture-secret', + signingSecret: 'fixture-signing', + botToken: 'xoxb-already-installed', + }, + expect.any(Object) + ) + /** The mutation refreshes installations before closing the nested wizard. */ + mocks.apps.mockReturnValue({ + isSuccess: true, + isPending: false, + error: null, + data: { + installations: [ + { + id: 'installed', + appId: 'A1', + teamId: 'T1', + teamName: 'Test workspace', + appKind: 'custom', + enabled: true, + needsValidation: false, + }, + ], + bots: [], + }, + }) + await act(async () => mocks.connect.mock.calls[0][1].onSuccess()) + expect(document.body.textContent).toContain('Installed in Test workspace') + expect(document.body.textContent).not.toContain('Install Sim Search first') + expect(document.body.textContent).not.toContain('Connect installed Slack app') + expect(mocks.install).not.toHaveBeenCalled() + expect(mocks.start).not.toHaveBeenCalled() + expect(window.open).not.toHaveBeenCalled() + }) + it('waits for the installed app lookup instead of offering a duplicate installation', async () => { mocks.apps.mockReturnValue({ isPending: true, isSuccess: false, data: undefined, error: null }) await render(undefined, [], 'org-1') diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index 9752436158c..4367515e13c 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -24,10 +24,10 @@ import { useRevokeOrganizationAccountEnrollment, useUpdateOrganizationAccounts, } from '@/hooks/queries/organization-accounts' -import { slackSearchKeys } from '@/hooks/queries/slack-search' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { selectorKeys, selectorQueryRoots } from '@/hooks/queries/utils/selector-keys' +import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' describe('personal account disconnect', () => { it.each([true, false])( diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 4efb5095aea..2973aeef112 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -39,11 +39,11 @@ import { updateOrganizationAccountWorkspaceAccessContract, } from '@/lib/api/contracts/organization-accounts' import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' -import { slackSearchKeys } from '@/hooks/queries/slack-search' import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' +import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 diff --git a/apps/sim/hooks/queries/slack-search.test.tsx b/apps/sim/hooks/queries/slack-search.test.tsx new file mode 100644 index 00000000000..c99dd723d66 --- /dev/null +++ b/apps/sim/hooks/queries/slack-search.test.tsx @@ -0,0 +1,115 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + connectCustomSlackSearchContract, + listSlackSearchContract, + type SlackSearchList, +} from '@/lib/api/contracts/knowledge/slack' + +const mocks = vi.hoisted(() => ({ request: vi.fn() })) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) + +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { + useConnectCustomSlackSearch, + useSlackSearchInstallations, +} from '@/hooks/queries/slack-search' +import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' + +let root: Root +let client: QueryClient +let connection: ReturnType + +function Probe() { + connection = useConnectCustomSlackSearch() + useSlackSearchInstallations('org-1') + return null +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.request.mockReset() + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + root = createRoot(document.createElement('div')) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + client.clear() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it('refreshes the installed app before completing the wizard and invalidates its setup data', async () => { + const input = { + organizationId: 'org-1', + name: 'Sim Search', + description: 'Search', + clientId: 'client', + clientSecret: 'secret', + signingSecret: 'signing', + botToken: 'xoxb-installed', + } + const empty: SlackSearchList = { sharedAppAvailable: false, installations: [], bots: [] } + const installed: SlackSearchList = { + ...empty, + installations: [ + { + id: 'installed', + credentialId: 'credential', + appId: 'A1', + teamId: 'T1', + teamName: 'Team', + appKind: 'custom', + enabled: true, + needsValidation: false, + lastOutcome: null, + lastEventAt: null, + }, + ], + } + let finishRefresh!: (value: SlackSearchList) => void + const refresh = new Promise((resolve) => { + finishRefresh = resolve + }) + mocks.request + .mockResolvedValueOnce(empty) + .mockResolvedValueOnce({ organizationId: 'org-1' }) + .mockReturnValueOnce(refresh) + const manifestKey = slackSearchKeys.manifest('org-1', 'Sim Search') + const accountsKey = organizationAccountsKeys.detail('org-1') + const otherKey = slackSearchKeys.list('org-2') + for (const key of [manifestKey, accountsKey, otherKey]) client.setQueryData(key, {}) + await act(async () => + root.render( + + + + ) + ) + const completed = vi.fn() + await act(async () => { + connection.mutate(input, { onSuccess: completed }) + await vi.advanceTimersByTimeAsync(1) + }) + expect(mocks.request).toHaveBeenNthCalledWith(2, connectCustomSlackSearchContract, { + body: input, + }) + expect(completed).not.toHaveBeenCalled() + expect(client.getQueryData(slackSearchKeys.list('org-1'))).toEqual(empty) + await act(async () => { + finishRefresh(installed) + await vi.advanceTimersByTimeAsync(1) + }) + expect(completed).toHaveBeenCalledOnce() + expect(client.getQueryData(slackSearchKeys.list('org-1'))).toEqual(installed) + expect(client.getQueryState(manifestKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(accountsKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false) + expect(mocks.request).toHaveBeenCalledTimes(3) + expect(mocks.request.mock.calls[2][0]).toBe(listSlackSearchContract) +}) diff --git a/apps/sim/hooks/queries/slack-search.ts b/apps/sim/hooks/queries/slack-search.ts index a6766834257..98eb62e41d8 100644 --- a/apps/sim/hooks/queries/slack-search.ts +++ b/apps/sim/hooks/queries/slack-search.ts @@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ConfigureSlackSearchBody, + type ConnectCustomSlackSearchBody, configureSlackSearchContract, + connectCustomSlackSearchContract, listSlackSearchContract, prepareSlackSearchContract, removeSlackSearchContract, @@ -15,18 +17,10 @@ import { SLACK_SEARCH_DEFAULT_DESCRIPTION, SLACK_SEARCH_DEFAULT_NAME, } from '@/lib/slack-search/manifest' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' export const SLACK_SEARCH_STALE_TIME = 30_000 -export const slackSearchKeys = { - all: ['slack-search'] as const, - lists: () => [...slackSearchKeys.all, 'list'] as const, - list: (organizationId?: string) => [...slackSearchKeys.lists(), organizationId ?? ''] as const, - manifests: () => [...slackSearchKeys.all, 'manifest'] as const, - organizationManifests: (organizationId: string) => - [...slackSearchKeys.manifests(), organizationId] as const, - manifest: (organizationId: string, name: string) => - [...slackSearchKeys.organizationManifests(organizationId), name] as const, -} export function useSlackSearchManifest(organizationId: string, name = SLACK_SEARCH_DEFAULT_NAME) { return useQuery({ @@ -48,6 +42,25 @@ export function useStartSlackSearchOAuth() { }) } +export function useConnectCustomSlackSearch() { + const client = useQueryClient() + return useMutation({ + mutationFn: (body: ConnectCustomSlackSearchBody) => + requestJson(connectCustomSlackSearchContract, { body }), + onSuccess: async (_result, input) => { + await Promise.all([ + client.invalidateQueries({ queryKey: slackSearchKeys.list(input.organizationId) }), + client.invalidateQueries({ + queryKey: slackSearchKeys.organizationManifests(input.organizationId), + }), + client.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(input.organizationId), + }), + ]) + }, + }) +} + export function useSlackSearchInstallations(organizationId?: string) { return useQuery({ queryKey: slackSearchKeys.list(organizationId), diff --git a/apps/sim/hooks/queries/utils/slack-search-keys.ts b/apps/sim/hooks/queries/utils/slack-search-keys.ts new file mode 100644 index 00000000000..8801e76d0ad --- /dev/null +++ b/apps/sim/hooks/queries/utils/slack-search-keys.ts @@ -0,0 +1,10 @@ +export const slackSearchKeys = { + all: ['slack-search'] as const, + lists: () => [...slackSearchKeys.all, 'list'] as const, + list: (organizationId?: string) => [...slackSearchKeys.lists(), organizationId ?? ''] as const, + manifests: () => [...slackSearchKeys.all, 'manifest'] as const, + organizationManifests: (organizationId: string) => + [...slackSearchKeys.manifests(), organizationId] as const, + manifest: (organizationId: string, name: string) => + [...slackSearchKeys.organizationManifests(organizationId), name] as const, +} diff --git a/apps/sim/lib/api/contracts/knowledge/slack.ts b/apps/sim/lib/api/contracts/knowledge/slack.ts index 71751e603c4..6dd0bdb8885 100644 --- a/apps/sim/lib/api/contracts/knowledge/slack.ts +++ b/apps/sim/lib/api/contracts/knowledge/slack.ts @@ -91,6 +91,24 @@ export const startSlackSearchOAuthContract = defineRouteContract({ response: { mode: 'json', schema: z.object({ authorizationUrl: z.string().url().max(4000) }) }, }) +export const connectCustomSlackSearchBodySchema = startSlackSearchOAuthBodySchema + .omit({ mode: true }) + .extend({ + botToken: z + .string() + .trim() + .min(1, 'Bot User OAuth Token is required') + .max(2000) + .startsWith('xoxb-', 'Use a Bot User OAuth Token (xoxb-) with token rotation disabled.'), + }) +export type ConnectCustomSlackSearchBody = z.input +export const connectCustomSlackSearchContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/slack/setup/connect', + body: connectCustomSlackSearchBodySchema, + response: { mode: 'json', schema: z.object({ organizationId: organizationIdSchema }) }, +}) + export const slackSearchOAuthCallbackQuerySchema = z.object({ state: z.string().max(200).optional(), code: z.string().min(1).max(2000).optional(), diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 09f8225cb62..481b8c249bc 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -201,6 +201,15 @@ export const knowledgeOperations = { principalKinds: ['session'], }) ), + connectCustomSlackInstallation: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.slack.connect_custom', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), completeSlackInstallation: defineKnowledgeOperation( defineWorkspaceOperation({ id: 'knowledge.slack.oauth.complete', diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts index 4cad2f2677d..f6f716c5a3f 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts @@ -69,8 +69,10 @@ vi.mock('@/lib/internal/slack/search-client', () => ({ SlackSearchProviderError: class extends Error {}, })) +import { SlackSearchConfigurationError } from '@/lib/internal/slack/search-client' import { completeSlackSearchSetup, + connectCustomSlackSearch, prepareSlackSearchSetup, startSlackSearchSetup, } from '@/lib/knowledge/application/slack-search/setup' @@ -737,3 +739,184 @@ describe('shared app completion', () => { expect(m.audit).not.toHaveBeenCalled() }) }) + +describe('connect an app already installed through a Slack manifest', () => { + const input = { + organizationId: 'org1', + name: 'Sim Search', + description: 'Search', + clientId: 'client', + clientSecret: 'client-secret', + signingSecret: 'signing-secret', + botToken: 'xoxb-installed-token', + } + const connect = () => connectCustomSlackSearch.execute({ principal, input }) + + it('verifies and saves the existing bot without a second install or OAuth state', async () => { + await expect(connect()).resolves.toEqual({ organizationId: 'org1' }) + expect(m.verify).toHaveBeenCalledExactlyOnceWith(input.botToken, expect.any(AbortSignal)) + expect(m.store).not.toHaveBeenCalled() + expect(m.consume).not.toHaveBeenCalled() + expect(m.exchange).not.toHaveBeenCalled() + expect(m.revoke).not.toHaveBeenCalled() + expect(db.transaction).toHaveBeenCalledOnce() + const rows = m.values.mock.calls.map(([value]) => value) + expect(rows[0]).toMatchObject({ + id: 'A1', + kind: 'custom', + organizationId: 'org1', + clientId: 'client', + encryptedClientSecret: 'encrypted:client-secret', + encryptedSigningSecret: 'encrypted:signing-secret', + }) + expect(rows[1]).toMatchObject({ organizationId: 'org1', workspaceId: null, createdBy: 'admin' }) + expect(rows[1].encryptedServiceAccountKey).toContain(input.botToken) + expect(rows[2]).toMatchObject({ ...identity, organizationId: 'org1', enabled: true }) + expect(m.audit).toHaveBeenCalledOnce() + }) + + it('rejects non-admins before verifying a token', async () => { + m.membership.mockResolvedValue([{ role: 'member' }]) + await expect(connect()).rejects.toThrow('administrator') + expect(m.verify).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + }) + + it('rejects non-session principals before loading protected context', async () => { + await expect( + connectCustomSlackSearch.execute({ + principal: { kind: 'personal_api_key', userId: 'admin', keyId: 'key' }, + input, + }) + ).rejects.toThrow('cannot perform operation') + expect(db.select).not.toHaveBeenCalled() + expect(m.verify).not.toHaveBeenCalled() + }) + + it('rechecks admin access after token verification', async () => { + m.verify.mockImplementationOnce(async () => { + m.membership.mockResolvedValue([{ role: 'member' }]) + return identity + }) + await expect(connect()).rejects.toThrow('administrator') + expect(db.transaction).not.toHaveBeenCalled() + }) + + it('does not save or revoke an invalid pre-existing bot token', async () => { + m.verify.mockRejectedValueOnce(new Error('Invalid bot token')) + await expect(connect()).rejects.toThrow('Invalid bot token') + expect(db.transaction).not.toHaveBeenCalled() + expect(m.revoke).not.toHaveBeenCalled() + expect(m.audit).not.toHaveBeenCalled() + }) + + it.each(['xoxp-user', 'xapp-app', 'xoxe.xoxb-rotating'])( + 'rejects unsupported tokens before contacting Slack: %s', + async (botToken) => { + await expect( + connectCustomSlackSearch.execute({ principal, input: { ...input, botToken } }) + ).rejects.toThrow('token rotation disabled') + expect(m.verify).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + } + ) + + it('reports missing permissions without saving or starting a new installation', async () => { + m.verify.mockRejectedValueOnce(new SlackSearchConfigurationError('Missing required scopes')) + await expect(connect()).rejects.toMatchObject({ + code: 'validation', + message: 'Missing required scopes', + }) + expect(db.transaction).not.toHaveBeenCalled() + expect(m.store).not.toHaveBeenCalled() + expect(m.exchange).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'reconnects the same app only while its revision is unchanged (changed: %s)', + async (changed) => { + const installation = { + id: 'installation1', + revision: 'revision1', + credentialId: 'credential1', + slackAppId: 'A1', + appId: 'A1', + teamId: 'T1', + organizationId: 'org1', + } + const app = { + id: 'A1', + kind: 'custom', + organizationId: 'org1', + revision: 'app-revision', + clientId: 'client', + encryptedClientSecret: 'encrypted:secret', + encryptedSigningSecret: 'encrypted:signing', + } + m.membership + .mockResolvedValueOnce([{ role: 'admin' }]) + .mockResolvedValueOnce([installation]) + .mockResolvedValueOnce([app]) + m.rows + .mockResolvedValueOnce([app]) + .mockResolvedValueOnce([ + { ...installation, revision: changed ? 'changed' : installation.revision }, + ]) + const result = connectCustomSlackSearch.execute({ + principal, + input: { + organizationId: 'org1', + name: 'Sim Search', + description: 'Search', + installationId: installation.id, + botToken: input.botToken, + }, + }) + if (changed) { + await expect(result).rejects.toThrow('changed during setup') + expect(m.values).not.toHaveBeenCalled() + } else { + await expect(result).resolves.toEqual({ organizationId: 'org1' }) + expect(m.insert).not.toHaveBeenCalledWith(credential) + expect(m.insert).not.toHaveBeenCalledWith(slackSearchInstallation) + expect(m.set).toHaveBeenCalledWith( + expect.objectContaining({ slackAppId: 'A1', enabled: true }) + ) + } + expect(m.exchange).not.toHaveBeenCalled() + expect(m.consume).not.toHaveBeenCalled() + } + ) + + it.each([ + { kind: 'custom', organizationId: 'other-org' }, + { kind: 'shared', organizationId: null }, + ])('does not claim an app owned by $kind / $organizationId', async (app) => { + m.rows.mockResolvedValueOnce([app]) + await expect(connect()).rejects.toThrow('another installation owner') + expect(m.values).not.toHaveBeenCalled() + expect(m.revoke).not.toHaveBeenCalled() + }) + + it('does not create another installation for an already connected app', async () => { + m.rows.mockResolvedValueOnce([]).mockResolvedValueOnce([{ organizationId: 'org1' }]) + await expect(connect()).rejects.toThrow('already connected') + expect(m.values).not.toHaveBeenCalled() + }) + + it('rejects a token from a different app than member indexing', async () => { + m.memberApps.mockResolvedValue([ + { + configuration: { + slack: { + appId: 'AOTHER', + teamId: 'T1', + scopes: [], + }, + }, + }, + ]) + await expect(connect()).rejects.toThrow('same Slack app and workspace') + expect(db.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index c6995a8567f..ddba6367b54 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { credential, slackApp, slackSearchInstallation } from '@sim/db/schema' import { generateId } from '@sim/utils/id' @@ -33,6 +34,7 @@ import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack import { createSlackSearchManifest, SLACK_SEARCH_CALLBACK_PATH } from '@/lib/slack-search/manifest' import { consumeSlackSearchOAuthAttempt, + type SlackSearchOAuthAttempt, storeSlackSearchOAuthAttempt, } from '@/lib/slack-search/oauth-state' import { readSharedSlackSearchApp } from '@/lib/slack-search/shared-app' @@ -101,112 +103,121 @@ export const prepareSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ }, }) +async function prepareInstallationAttempt( + principal: SessionPrincipal, + input: StartInput +): Promise { + const origin = getBaseUrl() + const member = await existingMemberApp(input.organizationId) + const [installation] = input.installationId + ? await db + .select() + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.id, input.installationId), + eq(slackSearchInstallation.organizationId, input.organizationId) + ) + ) + .limit(1) + : [] + if (input.installationId && !installation) + throw new OrchestrationError('not_found', 'Slack Search installation not found') + const [savedApp] = installation?.slackAppId + ? await db + .select() + .from(slackApp) + .where(and(eq(slackApp.id, installation.slackAppId))) + .limit(1) + : [] + const shared = input.mode === 'shared' + if (installation?.slackAppId && !savedApp) + throw new OrchestrationError('conflict', 'Slack app configuration is missing') + const transitioning = shared && installation && savedApp?.kind !== 'shared' + if (savedApp?.kind === 'shared' && !shared) + throw new OrchestrationError( + 'conflict', + 'Remove the existing installation before switching Slack apps' + ) + if (savedApp?.kind === 'custom' && savedApp.organizationId !== input.organizationId) + throw new OrchestrationError('forbidden', 'Slack app ownership changed') + const sharedApp = shared ? await readSharedSlackSearchApp(input.organizationId) : null + const app = shared ? sharedApp : savedApp + if (shared && (!app || input.clientId || input.clientSecret || input.signingSecret)) + throw new OrchestrationError( + 'validation', + 'Shared Slack app setup is unavailable or contains custom credentials' + ) + if (shared && installation && member.app && member.app.teamId !== installation.teamId) + throw new OrchestrationError( + 'conflict', + 'Install Sim Search in the Slack workspace used for member indexing' + ) + const clientId = input.clientId ?? app?.clientId + if (!clientId) throw new OrchestrationError('validation', 'Slack Client ID is required') + let appCredentials: + | { sharedApp: { id: string; revision: string } } + | { encryptedClientSecret: string; encryptedSigningSecret: string } + if (sharedApp) { + appCredentials = { sharedApp: { id: sharedApp.id, revision: sharedApp.revision } } + } else { + const encryptedClientSecret = input.clientSecret + ? (await encryptSecret(input.clientSecret)).encrypted + : savedApp?.encryptedClientSecret + const encryptedSigningSecret = input.signingSecret + ? (await encryptSecret(input.signingSecret)).encrypted + : savedApp?.encryptedSigningSecret + if (!encryptedClientSecret || !encryptedSigningSecret) + throw new OrchestrationError( + 'validation', + 'Client ID, Client Secret, and Signing Secret are required for a new Slack app' + ) + appCredentials = { encryptedClientSecret, encryptedSigningSecret } + } + const redirectUri = new URL(SLACK_SEARCH_CALLBACK_PATH, origin).href + const installationSnapshot = installation + ? { + id: installation.id, + revision: installation.revision, + credentialId: installation.credentialId, + appId: installation.appId, + teamId: installation.teamId, + ...(savedApp ? { appRevision: savedApp.revision } : {}), + } + : undefined + return { + ...appCredentials, + userId: principal.userId, + sessionId: principal.sessionId, + organizationId: input.organizationId, + name: input.name, + description: input.description, + ...(member.app ? { memberApp: member.app } : {}), + clientId, + redirectUri, + createdAt: Date.now(), + ...(installationSnapshot + ? transitioning + ? { customInstallation: installationSnapshot } + : { installation: installationSnapshot } + : {}), + } +} + export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.startSlackInstallation, resolveContext: ({ input }: { input: StartInput }) => resolveKnowledgeOrganizationContext(input), async execute({ principal, input, context }) { if (principal.kind !== 'session') throw new Error('Slack setup requires a browser session') await requireOrganizationSearchAvailable(context.organizationId) - const origin = getBaseUrl() - const member = await existingMemberApp(context.organizationId) - const [installation] = input.installationId - ? await db - .select() - .from(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.id, input.installationId), - eq(slackSearchInstallation.organizationId, context.organizationId) - ) - ) - .limit(1) - : [] - if (input.installationId && !installation) - throw new OrchestrationError('not_found', 'Slack Search installation not found') - const [savedApp] = installation?.slackAppId - ? await db - .select() - .from(slackApp) - .where(and(eq(slackApp.id, installation.slackAppId))) - .limit(1) - : [] - const shared = input.mode === 'shared' - if (installation?.slackAppId && !savedApp) - throw new OrchestrationError('conflict', 'Slack app configuration is missing') - const transitioning = shared && installation && savedApp?.kind !== 'shared' - if (savedApp?.kind === 'shared' && !shared) - throw new OrchestrationError( - 'conflict', - 'Remove the existing installation before switching Slack apps' - ) - if (savedApp?.kind === 'custom' && savedApp.organizationId !== context.organizationId) - throw new OrchestrationError('forbidden', 'Slack app ownership changed') - const sharedApp = shared ? await readSharedSlackSearchApp(context.organizationId) : null - const app = shared ? sharedApp : savedApp - if (shared && (!app || input.clientId || input.clientSecret || input.signingSecret)) - throw new OrchestrationError( - 'validation', - 'Shared Slack app setup is unavailable or contains custom credentials' - ) - if (shared && installation && member.app && member.app.teamId !== installation.teamId) - throw new OrchestrationError( - 'conflict', - 'Install Sim Search in the Slack workspace used for member indexing' - ) - const clientId = input.clientId ?? app?.clientId - if (!clientId) throw new OrchestrationError('validation', 'Slack Client ID is required') - let appCredentials: - | { sharedApp: { id: string; revision: string } } - | { encryptedClientSecret: string; encryptedSigningSecret: string } - if (sharedApp) { - appCredentials = { sharedApp: { id: sharedApp.id, revision: sharedApp.revision } } - } else { - const encryptedClientSecret = input.clientSecret - ? (await encryptSecret(input.clientSecret)).encrypted - : savedApp?.encryptedClientSecret - const encryptedSigningSecret = input.signingSecret - ? (await encryptSecret(input.signingSecret)).encrypted - : savedApp?.encryptedSigningSecret - if (!encryptedClientSecret || !encryptedSigningSecret) - throw new OrchestrationError( - 'validation', - 'Client ID, Client Secret, and Signing Secret are required for a new Slack app' - ) - appCredentials = { encryptedClientSecret, encryptedSigningSecret } - } - const redirectUri = new URL(SLACK_SEARCH_CALLBACK_PATH, origin).href - const installationSnapshot = installation - ? { - id: installation.id, - revision: installation.revision, - credentialId: installation.credentialId, - appId: installation.appId, - teamId: installation.teamId, - ...(savedApp ? { appRevision: savedApp.revision } : {}), - } - : undefined - const state = await storeSlackSearchOAuthAttempt({ - ...appCredentials, - userId: principal.userId, - sessionId: principal.sessionId, - organizationId: context.organizationId, - name: input.name, - description: input.description, - ...(member.app ? { memberApp: member.app } : {}), - clientId, - redirectUri, - createdAt: Date.now(), - ...(installationSnapshot - ? transitioning - ? { customInstallation: installationSnapshot } - : { installation: installationSnapshot } - : {}), - }) + const attempt = await prepareInstallationAttempt(principal, input) + const state = await storeSlackSearchOAuthAttempt(attempt) + const installation = attempt.installation ?? attempt.customInstallation const url = new URL('https://slack.com/oauth/v2/authorize') url.search = new URLSearchParams({ - client_id: clientId, - scope: (shared ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES).join(','), - redirect_uri: redirectUri, + client_id: attempt.clientId, + scope: (attempt.sharedApp ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES).join(','), + redirect_uri: attempt.redirectUri, state, ...(installation ? { team: installation.teamId } : {}), }).toString() @@ -247,6 +258,288 @@ async function revokeUninstalledSharedGrant( } } +/** Persists verified installs under the same ownership and revision locks for both setup paths. */ +async function saveInstallation( + attempt: SlackSearchOAuthAttempt, + identity: Awaited>, + botToken: string, + userId: string +) { + if ( + attempt.installation && + (attempt.installation.appId !== identity.appId || + attempt.installation.teamId !== identity.teamId) + ) + throw new OrchestrationError('conflict', 'Reconnect the same Slack app and workspace') + if ( + attempt.customInstallation && + (attempt.customInstallation.teamId !== identity.teamId || + attempt.customInstallation.appId === identity.appId) + ) + throw new OrchestrationError('conflict', 'Install Sim Search in the same Slack workspace') + if ( + attempt.memberApp && + ((!attempt.sharedApp && attempt.memberApp.appId !== identity.appId) || + attempt.memberApp.teamId !== identity.teamId) + ) + throw new OrchestrationError( + 'conflict', + 'Install the same Slack app and workspace used for member indexing' + ) + const { encrypted: encryptedToken } = await encryptSecret( + JSON.stringify({ + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + botToken, + teamId: identity.teamId, + botUserId: identity.botUserId, + teamName: identity.teamName, + }) + ) + await db.transaction(async (tx) => { + /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${identity.teamId}`}, 0))` + ) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${identity.appId}`}, 0))` + ) + const [existingApp] = await tx + .select() + .from(slackApp) + .where(eq(slackApp.id, identity.appId)) + .for('update') + .limit(1) + if ( + attempt.sharedApp + ? existingApp && (existingApp.kind !== 'shared' || existingApp.organizationId !== null) + : existingApp && + (existingApp.kind !== 'custom' || existingApp.organizationId !== attempt.organizationId) + ) + throw new OrchestrationError( + 'conflict', + 'This Slack app belongs to another installation owner' + ) + if ( + !attempt.sharedApp && + attempt.installation?.appRevision && + existingApp?.revision !== attempt.installation.appRevision + ) + throw new OrchestrationError( + 'conflict', + 'The Slack app credentials changed during setup. Start again.' + ) + const [existing] = await tx + .select() + .from(slackSearchInstallation) + .where( + attempt.installation + ? eq(slackSearchInstallation.id, attempt.installation.id) + : and( + eq(slackSearchInstallation.appId, identity.appId), + eq(slackSearchInstallation.teamId, identity.teamId) + ) + ) + .for('update') + .limit(1) + if ( + attempt.installation && + (!existing || + existing.revision !== attempt.installation.revision || + existing.credentialId !== attempt.installation.credentialId) + ) + throw new OrchestrationError( + 'conflict', + 'This installation changed during setup. Start setup again.' + ) + if (existing && (!attempt.installation || existing.organizationId !== attempt.organizationId)) + throw new OrchestrationError( + 'conflict', + 'This app is already connected. Use Reconnect on its existing installation.' + ) + const [customInstallation] = attempt.customInstallation + ? await tx + .select() + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.id, attempt.customInstallation.id), + eq(slackSearchInstallation.organizationId, attempt.organizationId) + ) + ) + .for('update') + .limit(1) + : [] + if ( + attempt.customInstallation && + (!customInstallation || + customInstallation.organizationId !== attempt.organizationId || + customInstallation.revision !== attempt.customInstallation.revision || + customInstallation.credentialId !== attempt.customInstallation.credentialId || + customInstallation.appId !== attempt.customInstallation.appId || + customInstallation.teamId !== identity.teamId) + ) + throw new OrchestrationError( + 'conflict', + 'The custom bot changed during setup. Start setup again.' + ) + const [active] = await tx + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.teamId, identity.teamId), + eq(slackSearchInstallation.enabled, true), + existing ? ne(slackSearchInstallation.id, existing.id) : undefined, + customInstallation ? ne(slackSearchInstallation.id, customInstallation.id) : undefined + ) + ) + .limit(1) + if (active) + throw new OrchestrationError( + 'conflict', + 'This Slack workspace already has an active Search installation' + ) + if (attempt.sharedApp) { + const currentApp = await readSharedSlackSearchApp(attempt.organizationId) + if ( + currentApp?.id !== attempt.sharedApp.id || + currentApp.revision !== attempt.sharedApp.revision + ) + throw new OrchestrationError('conflict', 'Shared Slack app configuration changed') + /** A concurrent failed setup may have revoked an uncommitted grant while we waited. */ + const current = await verifySlackSearchBot( + botToken, + AbortSignal.timeout(10_000), + SLACK_SHARED_SEARCH_BOT_SCOPES + ) + if ( + current.appId !== identity.appId || + current.teamId !== identity.teamId || + current.botUserId !== identity.botUserId + ) + throw new OrchestrationError( + 'validation', + 'Slack installation identity changed during setup' + ) + } + const appRevision = attempt.sharedApp?.revision ?? generateId() + if (attempt.sharedApp) { + /** The row supplies foreign-key identity only; shared secrets remain in the environment. */ + await tx + .insert(slackApp) + .values({ + id: identity.appId, + kind: 'shared', + organizationId: null, + revision: appRevision, + }) + .onConflictDoNothing() + } else { + const appValues = { + id: identity.appId, + kind: 'custom' as const, + organizationId: attempt.organizationId, + clientId: attempt.clientId, + encryptedClientSecret: attempt.encryptedClientSecret, + encryptedSigningSecret: attempt.encryptedSigningSecret, + revision: appRevision, + updatedAt: new Date(), + } + await tx + .insert(slackApp) + .values(appValues) + .onConflictDoUpdate({ target: slackApp.id, set: appValues }) + } + const member = await existingMemberApp(attempt.organizationId, tx) + if ( + member.app?.appId !== attempt.memberApp?.appId || + member.app?.teamId !== attempt.memberApp?.teamId + ) + throw new OrchestrationError('conflict', 'Slack source configuration changed during setup') + /** A bot transition leaves existing personal grants and indexing configuration untouched. */ + const preserveMemberApp = attempt.sharedApp && member.app && member.app.appId !== identity.appId + if (!preserveMemberApp) + await adoptOrganizationSlackMemberApp( + tx, + attempt.organizationId, + identity.appId, + identity.teamId, + attempt.clientId + ) + if (attempt.sharedApp && !preserveMemberApp) + await configureSharedSlackMemberApp(tx, { + organizationId: attempt.organizationId, + userId, + appId: identity.appId, + teamId: identity.teamId, + }) + const credentialId = existing?.credentialId ?? generateId() + const credentialValues = { + slackAppId: identity.appId, + displayName: attempt.name, + description: attempt.description, + encryptedServiceAccountKey: encryptedToken, + updatedAt: new Date(), + } + if (existing) { + const [updated] = await tx + .update(credential) + .set(credentialValues) + .where( + and( + eq(credential.id, credentialId), + eq(credential.organizationId, attempt.organizationId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) + ) + .returning({ id: credential.id }) + if (!updated) + throw new OrchestrationError( + 'conflict', + 'Slack bot credential no longer belongs to this organization' + ) + } else { + await tx.insert(credential).values({ + id: credentialId, + organizationId: attempt.organizationId, + workspaceId: null, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + createdBy: userId, + ...credentialValues, + }) + } + const installationValues = { + ...identity, + slackAppId: identity.appId, + credentialVersion: slackBotCredentialVersion(encryptedToken, appRevision), + enabled: true, + revision: generateId(), + lastOutcome: null, + lastEventAt: null, + updatedAt: new Date(), + } + if (customInstallation) + await tx + .update(slackSearchInstallation) + .set({ enabled: false, revision: generateId(), updatedAt: new Date() }) + .where(eq(slackSearchInstallation.id, customInstallation.id)) + if (existing) + await tx + .update(slackSearchInstallation) + .set(installationValues) + .where(eq(slackSearchInstallation.id, existing.id)) + else + await tx.insert(slackSearchInstallation).values({ + id: generateId(), + organizationId: attempt.organizationId, + credentialId, + ...installationValues, + }) + }) +} + export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.completeSlackInstallation, async resolveContext({ @@ -324,292 +617,12 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ 'validation', 'Slack returned an inconsistent installation identity' ) - if ( - attempt.installation && - (attempt.installation.appId !== identity.appId || - attempt.installation.teamId !== identity.teamId) - ) - throw new OrchestrationError('conflict', 'Reconnect the same Slack app and workspace') - if ( - attempt.customInstallation && - (attempt.customInstallation.teamId !== identity.teamId || - attempt.customInstallation.appId === identity.appId) - ) - throw new OrchestrationError('conflict', 'Install Sim Search in the same Slack workspace') - if ( - attempt.memberApp && - ((!attempt.sharedApp && attempt.memberApp.appId !== identity.appId) || - attempt.memberApp.teamId !== identity.teamId) - ) - throw new OrchestrationError( - 'conflict', - 'Install the same Slack app and workspace used for member indexing' - ) - const { encrypted: encryptedToken } = await encryptSecret( - JSON.stringify({ - type: SLACK_CUSTOM_BOT_SECRET_TYPE, - botToken: grant.access_token, - teamId: identity.teamId, - botUserId: identity.botUserId, - teamName: identity.teamName, - }) - ) await authorizeOrganizationOperation( principal, knowledgeOperations.completeSlackInstallation.organizationOperation, context ) - await db.transaction(async (tx) => { - /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${identity.teamId}`}, 0))` - ) - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${identity.appId}`}, 0))` - ) - const [existingApp] = await tx - .select() - .from(slackApp) - .where(eq(slackApp.id, identity.appId)) - .for('update') - .limit(1) - if ( - attempt.sharedApp - ? existingApp && (existingApp.kind !== 'shared' || existingApp.organizationId !== null) - : existingApp && - (existingApp.kind !== 'custom' || - existingApp.organizationId !== context.organizationId) - ) - throw new OrchestrationError( - 'conflict', - 'This Slack app belongs to another installation owner' - ) - if ( - !attempt.sharedApp && - attempt.installation?.appRevision && - existingApp?.revision !== attempt.installation.appRevision - ) - throw new OrchestrationError( - 'conflict', - 'The Slack app credentials changed during setup. Start again.' - ) - const [existing] = await tx - .select() - .from(slackSearchInstallation) - .where( - attempt.installation - ? eq(slackSearchInstallation.id, attempt.installation.id) - : and( - eq(slackSearchInstallation.appId, identity.appId), - eq(slackSearchInstallation.teamId, identity.teamId) - ) - ) - .for('update') - .limit(1) - if ( - attempt.installation && - (!existing || - existing.revision !== attempt.installation.revision || - existing.credentialId !== attempt.installation.credentialId) - ) - throw new OrchestrationError( - 'conflict', - 'This installation changed during setup. Start setup again.' - ) - if ( - existing && - (!attempt.installation || existing.organizationId !== context.organizationId) - ) - throw new OrchestrationError( - 'conflict', - 'This app is already connected. Use Reconnect on its existing installation.' - ) - const [customInstallation] = attempt.customInstallation - ? await tx - .select() - .from(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.id, attempt.customInstallation.id), - eq(slackSearchInstallation.organizationId, context.organizationId) - ) - ) - .for('update') - .limit(1) - : [] - if ( - attempt.customInstallation && - (!customInstallation || - customInstallation.organizationId !== context.organizationId || - customInstallation.revision !== attempt.customInstallation.revision || - customInstallation.credentialId !== attempt.customInstallation.credentialId || - customInstallation.appId !== attempt.customInstallation.appId || - customInstallation.teamId !== identity.teamId) - ) - throw new OrchestrationError( - 'conflict', - 'The custom bot changed during setup. Start setup again.' - ) - const [active] = await tx - .select({ id: slackSearchInstallation.id }) - .from(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.teamId, identity.teamId), - eq(slackSearchInstallation.enabled, true), - existing ? ne(slackSearchInstallation.id, existing.id) : undefined, - customInstallation ? ne(slackSearchInstallation.id, customInstallation.id) : undefined - ) - ) - .limit(1) - if (active) - throw new OrchestrationError( - 'conflict', - 'This Slack workspace already has an active Search installation' - ) - if (attempt.sharedApp) { - const currentApp = await readSharedSlackSearchApp(context.organizationId) - if ( - currentApp?.id !== attempt.sharedApp.id || - currentApp.revision !== attempt.sharedApp.revision - ) - throw new OrchestrationError('conflict', 'Shared Slack app configuration changed') - /** A concurrent failed setup may have revoked an uncommitted grant while we waited. */ - const current = await verifySlackSearchBot( - grant.access_token, - AbortSignal.timeout(10_000), - SLACK_SHARED_SEARCH_BOT_SCOPES - ) - if ( - current.appId !== identity.appId || - current.teamId !== identity.teamId || - current.botUserId !== identity.botUserId - ) - throw new OrchestrationError( - 'validation', - 'Slack installation identity changed during setup' - ) - } - const appRevision = attempt.sharedApp?.revision ?? generateId() - if (attempt.sharedApp) { - /** The row supplies foreign-key identity only; shared secrets remain in the environment. */ - await tx - .insert(slackApp) - .values({ - id: identity.appId, - kind: 'shared', - organizationId: null, - revision: appRevision, - }) - .onConflictDoNothing() - } else { - const appValues = { - id: identity.appId, - kind: 'custom' as const, - organizationId: context.organizationId, - clientId: attempt.clientId, - encryptedClientSecret: attempt.encryptedClientSecret, - encryptedSigningSecret: attempt.encryptedSigningSecret, - revision: appRevision, - updatedAt: new Date(), - } - await tx - .insert(slackApp) - .values(appValues) - .onConflictDoUpdate({ target: slackApp.id, set: appValues }) - } - const member = await existingMemberApp(context.organizationId, tx) - if ( - member.app?.appId !== attempt.memberApp?.appId || - member.app?.teamId !== attempt.memberApp?.teamId - ) - throw new OrchestrationError( - 'conflict', - 'Slack source configuration changed during setup' - ) - /** A bot transition leaves existing personal grants and indexing configuration untouched. */ - const preserveMemberApp = - attempt.sharedApp && member.app && member.app.appId !== identity.appId - if (!preserveMemberApp) - await adoptOrganizationSlackMemberApp( - tx, - context.organizationId, - identity.appId, - identity.teamId, - attempt.clientId - ) - if (attempt.sharedApp && !preserveMemberApp) - await configureSharedSlackMemberApp(tx, { - organizationId: context.organizationId, - userId: principal.userId, - appId: identity.appId, - teamId: identity.teamId, - }) - const credentialId = existing?.credentialId ?? generateId() - const credentialValues = { - slackAppId: identity.appId, - displayName: attempt.name, - description: attempt.description, - encryptedServiceAccountKey: encryptedToken, - updatedAt: new Date(), - } - if (existing) { - const [updated] = await tx - .update(credential) - .set(credentialValues) - .where( - and( - eq(credential.id, credentialId), - eq(credential.organizationId, context.organizationId), - eq(credential.type, 'service_account'), - eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) - ) - ) - .returning({ id: credential.id }) - if (!updated) - throw new OrchestrationError( - 'conflict', - 'Slack bot credential no longer belongs to this organization' - ) - } else { - await tx.insert(credential).values({ - id: credentialId, - organizationId: context.organizationId, - workspaceId: null, - type: 'service_account', - providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, - createdBy: principal.userId, - ...credentialValues, - }) - } - const installationValues = { - ...identity, - slackAppId: identity.appId, - credentialVersion: slackBotCredentialVersion(encryptedToken, appRevision), - enabled: true, - revision: generateId(), - lastOutcome: null, - lastEventAt: null, - updatedAt: new Date(), - } - if (customInstallation) - await tx - .update(slackSearchInstallation) - .set({ enabled: false, revision: generateId(), updatedAt: new Date() }) - .where(eq(slackSearchInstallation.id, customInstallation.id)) - if (existing) - await tx - .update(slackSearchInstallation) - .set(installationValues) - .where(eq(slackSearchInstallation.id, existing.id)) - else - await tx.insert(slackSearchInstallation).values({ - id: generateId(), - organizationId: context.organizationId, - credentialId, - ...installationValues, - }) - }) + await saveInstallation(attempt, identity, grant.access_token, principal.userId) } catch (error) { if (attempt.sharedApp) await revokeUninstalledSharedGrant(grant) throw error @@ -629,3 +642,47 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ }, }), }) + +interface ConnectCustomInput extends Omit { + botToken: string +} + +/** Connects the bot already installed by Slack's manifest flow without issuing another grant. */ +export const connectCustomSlackSearch = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.connectCustomSlackInstallation, + resolveContext: ({ input }: { input: ConnectCustomInput }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input, context }) { + await requireOrganizationSearchAvailable(context.organizationId) + if (!input.botToken.startsWith('xoxb-')) + throw new OrchestrationError( + 'validation', + 'Use a Bot User OAuth Token (xoxb-) with token rotation disabled.' + ) + const attempt = await prepareInstallationAttempt(principal, { ...input, mode: 'custom' }) + let identity: Awaited> + try { + identity = await verifySlackSearchBot(input.botToken, AbortSignal.timeout(10_000)) + } catch (error) { + if ( + error instanceof SlackSearchConfigurationError || + error instanceof SlackSearchProviderError + ) + throw new OrchestrationError('validation', error.message) + throw error + } + await authorizeOrganizationOperation( + principal, + knowledgeOperations.connectCustomSlackInstallation.organizationOperation, + context + ) + await saveInstallation(attempt, identity, input.botToken, principal.userId) + return { organizationId: context.organizationId } + }, + projectAudit: ({ context }) => ({ + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: context.organizationId, + metadata: { setting: 'slack-search', connected: true }, + }), +}) diff --git a/apps/sim/lib/slack-search/oauth-state.test.ts b/apps/sim/lib/slack-search/oauth-state.test.ts index a7008d43ebf..5daeefce7d8 100644 --- a/apps/sim/lib/slack-search/oauth-state.test.ts +++ b/apps/sim/lib/slack-search/oauth-state.test.ts @@ -57,6 +57,31 @@ describe('Slack OAuth state', () => { 'already completed' ) }) + it.each([ + { ...principal, sessionId: 'foreign-session' }, + { ...principal, userId: 'foreign-user' }, + ])( + 'rejects a foreign browser context without consuming the original attempt: %s', + async (foreign) => { + redis.eval.mockResolvedValueOnce(null).mockResolvedValueOnce(JSON.stringify(attempt)) + await expect(consumeSlackSearchOAuthAttempt('state', foreign)).rejects.toThrow( + 'expired or was already completed' + ) + expect(redis.eval).toHaveBeenCalledWith( + expect.stringContaining('attempt.userId ~= ARGV[1] or attempt.sessionId ~= ARGV[2]'), + 1, + expect.any(String), + foreign.userId, + foreign.sessionId + ) + await expect(consumeSlackSearchOAuthAttempt('state', principal)).resolves.toEqual(attempt) + } + ) + it('rejects unknown state', async () => { + await expect(consumeSlackSearchOAuthAttempt('invalid', principal)).rejects.toThrow( + 'expired or was already completed' + ) + }) it('round-trips the custom installation snapshot in a single-use shared-app attempt', async () => { const { encryptedClientSecret, encryptedSigningSecret, ...common } = attempt const transition = { From 1155a4d02ee7daa4810451ec676077f67a1728c8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 19 Sep 2026 11:07:17 -0700 Subject: [PATCH 2/2] fix(slack-search): register token connection in operation test --- apps/sim/lib/knowledge/application/operations.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 0dabc3ef7fc..30eeeeb6791 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -27,6 +27,7 @@ describe('knowledge operation registry', () => { 'knowledge.github.installations.connect', 'knowledge.slack.prepare', 'knowledge.slack.oauth.start', + 'knowledge.slack.connect_custom', 'knowledge.slack.oauth.complete', 'knowledge.slack.list', 'knowledge.slack.configure',