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 bf010f0d34a..49c21d524ce 100644 --- a/apps/sim/app/api/knowledge/slack/setup/route.test.ts +++ b/apps/sim/app/api/knowledge/slack/setup/route.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => { } }) -import { createSlackSearchManifest } from '@/lib/slack-search/manifest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST as start } from '@/app/api/knowledge/slack/oauth/route' import { POST as prepare } from '@/app/api/knowledge/slack/setup/route' @@ -35,13 +35,13 @@ describe.each([ ['prepare', prepare, mocks.prepare], ['OAuth', start, mocks.start], ] as const)('Slack %s route errors', (_name, route, execute) => { - it('returns an actionable 400 for a non-HTTPS app URL', async () => { - execute.mockImplementation(() => - createSlackSearchManifest(input.name, input.description, 'http://localhost:3000') + it('returns application validation errors', async () => { + execute.mockRejectedValue( + new OrchestrationError('validation', 'Slack app credentials are required') ) const response = await route(createMockRequest('POST', input)) expect(response.status).toBe(400) - expect(await response.json()).toMatchObject({ error: expect.stringContaining('public HTTPS') }) + expect(await response.json()).toMatchObject({ error: 'Slack app credentials are required' }) expect(execute).toHaveBeenCalledOnce() }) 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 5c8679484e7..a350cb46405 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 @@ -1,5 +1,6 @@ /** @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { ToastProvider } from '@sim/emcn' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SlackSearchInstallationView } from '@/lib/api/contracts/knowledge/slack' @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), copy: vi.fn(), removeError: null as Error | null, + installError: null as Error | null, })) vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] })) vi.mock('@/components/settings/settings-panel', () => ({ @@ -32,7 +34,12 @@ vi.mock('@/hooks/queries/slack-search', () => ({ error: mocks.removeError, reset: vi.fn(), }), - useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }), + useStartSlackSearchOAuth: () => ({ + mutate: mocks.install, + isPending: false, + error: mocks.installError, + reset: vi.fn(), + }), })) import { OrganizationSearchSlack } from '@/app/o/[organizationId]/settings/components/organization-search-slack' @@ -41,6 +48,7 @@ const installation: SlackSearchInstallationView = { id: 'installation-1', credentialId: 'credential-1', appId: 'A1', + appKind: 'custom', teamId: 'T1', teamName: 'Test workspace', enabled: true, @@ -57,13 +65,16 @@ beforeEach(() => { vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } }) mocks.copy.mockReset().mockResolvedValue(undefined) mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } }) - mocks.list.mockReturnValue({ data: { installations: [], bots: [] } }) + mocks.list.mockReturnValue({ + data: { sharedAppAvailable: false, installations: [], bots: [] }, + }) mocks.manifest.mockReturnValue({ data: { manifest: '{}', existingApp: null, createAppUrl: 'https://api.slack.com/apps' }, isPending: false, refetch: mocks.refetch, }) mocks.removeError = null + mocks.installError = null container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -77,15 +88,23 @@ async function render(installed = false) { if (installed) { mocks.list.mockReturnValue({ data: { + sharedAppAvailable: false, installations: [installation], bots: [{ id: 'credential-1', displayName: 'Sim Search' }], }, }) } - await act(async () => root.render()) + await act(async () => + root.render( + + + + ) + ) } function button(label: string) { - const element = Array.from(document.querySelectorAll('button')).find( + const scope = document.querySelector('[role="dialog"]') ?? document + const element = Array.from(scope.querySelectorAll('button')).find( (element) => element.textContent?.trim() === label ) expect(element, label).toBeDefined() @@ -94,8 +113,8 @@ function button(label: string) { async function click(label: string) { await act(async () => button(label).click()) } -async function action(label: string) { - const trigger = container.querySelector('[aria-label="Sim Search actions"]')! +async function action(label: string, name = 'Sim Search (custom bot)') { + const trigger = container.querySelector(`[aria-label="${name} actions"]`)! await act(async () => { trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) }) @@ -107,6 +126,174 @@ async function action(label: string) { } describe('Slack Search settings and shared wizard', () => { + it.each([ + { state: 'no bots', installations: [] }, + { state: 'custom bots', installations: [installation] }, + ])( + 'installs the official app explicitly with $state and a custom source app', + async ({ installations }) => { + mocks.list.mockReturnValue({ + data: { sharedAppAvailable: true, installations, bots: [] }, + }) + mocks.manifest.mockReturnValue({ + data: { + manifest: '{}', + existingApp: { appId: 'A1', teamId: 'T1' }, + sharedAppId: 'A_SHARED', + createAppUrl: 'https://api.slack.com/apps', + }, + }) + await render() + if (installations.length) { + expect(container).toHaveTextContent('Reconnect required') + expect(container).toHaveTextContent('Sim Search (custom bot)') + await action('Install Sim Search') + } else { + await click('Install Sim Search') + } + expect(document.querySelector('[role="dialog"]')).toHaveTextContent( + 'Install the Sim Search app' + ) + expect(document.querySelectorAll('input')).toHaveLength(0) + expect(mocks.install).not.toHaveBeenCalled() + await click('Continue with Slack') + expect(mocks.install).toHaveBeenCalledExactlyOnceWith( + { + organizationId: 'org-1', + installationId: installations[0]?.id, + name: 'Sim Search', + description: expect.any(String), + mode: 'shared', + }, + expect.any(Object) + ) + mocks.installError = new Error('Slack authorization failed. Try again.') + await render() + expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent( + 'Slack authorization failed' + ) + expect(mocks.configure).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() + } + ) + + it('reconnects an installed official app without offering a duplicate installation', async () => { + mocks.list.mockReturnValue({ + data: { + sharedAppAvailable: true, + installations: [{ ...installation, appId: 'A_SHARED', appKind: 'shared' }], + bots: [{ id: 'credential-1', displayName: 'Sim Search' }], + }, + }) + mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } }) + await render() + expect(container).not.toHaveTextContent('Install Sim Search') + await action('Reconnect', 'Sim Search') + await click('Continue with Slack') + expect(mocks.install).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'shared', installationId: installation.id }), + expect.any(Object) + ) + }) + + it('does not switch to custom setup when shared installation becomes unavailable', async () => { + mocks.list.mockReturnValue({ + data: { sharedAppAvailable: true, installations: [installation], bots: [] }, + }) + mocks.manifest.mockReturnValue({ + data: { sharedAppId: null, existingApp: null }, + refetch: mocks.refetch, + }) + await render() + await action('Install Sim Search') + expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent( + 'Sim Search installation is unavailable' + ) + expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Create Slack app') + expect(button('Continue with Slack')).toBeDisabled() + expect(mocks.install).not.toHaveBeenCalled() + await click('Retry') + expect(mocks.refetch).toHaveBeenCalledOnce() + }) + + it('allows retrying shared setup after a preparation error with cached data', async () => { + mocks.list.mockReturnValue({ + data: { sharedAppAvailable: true, installations: [installation], bots: [] }, + }) + mocks.manifest.mockReturnValue({ + data: { sharedAppId: 'A_SHARED', existingApp: null }, + error: new Error('Could not load Slack setup'), + refetch: mocks.refetch, + }) + await render() + await action('Install Sim Search') + expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent( + 'Could not load Slack setup' + ) + expect(button('Continue with Slack')).toBeDisabled() + await click('Retry') + expect(mocks.refetch).toHaveBeenCalledOnce() + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('does not show official installation when it is unavailable', async () => { + await render(true) + expect(container).not.toHaveTextContent('Install Sim Search') + expect(container).toHaveTextContent('Open in Slack') + }) + + it('prompts the existing custom bot to reconnect when the feature becomes available', async () => { + await render(true) + expect(container).toHaveTextContent('Sim Search (custom bot)') + expect(container).toHaveTextContent('Enabled') + expect(container).not.toHaveTextContent('Reconnect required') + mocks.list.mockReturnValue({ + data: { sharedAppAvailable: true, installations: [installation], bots: [] }, + }) + mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } }) + await render() + expect(container).toHaveTextContent('Reconnect required') + expect(container).not.toHaveTextContent('Install Sim Search') + expect(mocks.install).not.toHaveBeenCalled() + expect(mocks.configure).not.toHaveBeenCalled() + await action('Install Sim Search') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent( + 'Install the Sim Search app' + ) + expect(button('Continue with Slack')).not.toBeDisabled() + await click('Cancel') + expect(mocks.install).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('shows the native app alongside the retained custom bot after installing', async () => { + mocks.list.mockReturnValue({ + data: { + sharedAppAvailable: true, + installations: [ + { ...installation, enabled: false }, + { + ...installation, + id: 'native-installation', + credentialId: 'native-credential', + appId: 'A_SHARED', + appKind: 'shared', + }, + ], + bots: [], + }, + }) + await render() + expect(container.querySelector('[aria-label="Sim Search (custom bot) actions"]')).not.toBeNull() + expect(container.querySelector('[aria-label="Sim Search actions"]')).not.toBeNull() + expect(container).toHaveTextContent('Disabled') + expect(container).toHaveTextContent('Enabled') + expect(container).not.toHaveTextContent('Reconnect required') + expect(container).not.toHaveTextContent('Install Sim Search') + expect(container.querySelectorAll('a[href*="slack.com/app_redirect"]')).toHaveLength(2) + expect(mocks.install).not.toHaveBeenCalled() + }) + it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => { await render() expect(container.querySelectorAll('button')).toHaveLength(1) @@ -124,13 +311,15 @@ describe('Slack Search settings and shared wizard', () => { it('shows setup errors and blocks progression until the manifest loads', async () => { mocks.manifest.mockReturnValue({ - error: new Error('Slack needs a public HTTPS URL to send messages to Sim.'), + error: new Error('Slack app configuration is unavailable.'), refetch: mocks.refetch, isPending: false, }) await render() await click('Set up') - expect(document.querySelector('[role="alert"]')).toHaveTextContent('public HTTPS') + expect(document.querySelector('[role="alert"]')).toHaveTextContent( + 'Slack app configuration is unavailable.' + ) expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1') expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Continue') await click('Retry') @@ -172,7 +361,7 @@ describe('Slack Search settings and shared wizard', () => { expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Loading Slack setup') if (mode === 'shared') { expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1') - await click('Install Sim Search') + await click('Continue with Slack') expect(mocks.install).toHaveBeenCalledWith( expect.objectContaining({ organizationId: 'org-1', mode: 'shared' }), expect.any(Object) diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx index 26426ebd5f2..e7d5920ae19 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipLink, ChipModalError, ChipTag } from '@sim/emcn' +import { useEffect, useRef, useState } from 'react' +import { Chip, ChipConfirmModal, ChipLink, ChipModalError, ChipTag, useToast } from '@sim/emcn' import { useQueryState } from 'nuqs' import { SlackIcon } from '@/components/icons' import { SlackSearchSetupWizard } from '@/components/integrations/slack-search-setup-wizard' @@ -26,6 +26,8 @@ import { /** Organization-owned Search bots are installed through the dedicated OAuth wizard. */ export function OrganizationSearchSlack() { + const setupToastShown = useRef(false) + const { toast } = useToast() const { organization, viewer } = useOrganizationContext() const installations = useSlackSearchInstallations(viewer.isAdmin ? organization.id : undefined) const configure = useConfigureSlackSearch() @@ -35,24 +37,33 @@ export function OrganizationSearchSlack() { slackSetupResultParam.parser ) const [wizard, setWizard] = useState<{ + mode?: 'custom' | 'shared' installationId?: string appId?: string initialName?: string } | null>(null) const [removeTarget, setRemoveTarget] = useState<{ id: string; name: string } | null>(null) + + useEffect(() => { + if (!viewer.isAdmin || setupResult !== 'complete' || setupToastShown.current) return + setupToastShown.current = true + toast.success('Slack connected') + void setSetupResult(null) + }, [viewer.isAdmin, setupResult, setSetupResult, toast]) + if (!viewer.isAdmin) return null const busy = configure.isPending || remove.isPending const bots = installations.data?.bots ?? [] + const canInstallSharedApp = !installations.error && installations.data?.sharedAppAvailable + const sharedTeams = new Set( + installations.data?.installations + .filter((installation) => installation.appKind === 'shared') + .map((installation) => installation.teamId) + ) return (
- {setupResult === 'complete' && ( -
-

Slack is connected and ready to use.

- void setSetupResult(null)}>Dismiss -
- )} {installations.error ? ( setWizard({})}> + setWizard(canInstallSharedApp ? { mode: 'shared' } : {})} + > {installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'} } /> ) : ( installations.data.installations.map((installation) => { - const name = - bots.find((bot) => bot.id === installation.credentialId)?.displayName ?? - installation.teamName - const connectionError = installation.needsValidation - ? 'Reconnect to verify the app’s credentials and permissions.' - : ['delivery_failed', 'assistant_or_delivery_failed'].includes( - installation.lastOutcome ?? '' - ) - ? 'The last reply failed. Check the Slack connection.' - : null + const custom = installation.appKind === 'custom' + const name = custom ? 'Sim Search (custom bot)' : 'Sim Search' + const needsInstall = + canInstallSharedApp && custom && !sharedTeams.has(installation.teamId) + const connectionError = needsInstall + ? null + : installation.needsValidation + ? 'Reconnect to verify the app’s credentials and permissions.' + : ['delivery_failed', 'assistant_or_delivery_failed'].includes( + installation.lastOutcome ?? '' + ) + ? 'The last reply failed. Check the Slack connection.' + : null return ( - {installation.needsValidation + {needsInstall || installation.needsValidation ? 'Reconnect required' : installation.enabled ? 'Enabled' @@ -123,13 +140,18 @@ export function OrganizationSearchSlack() { label={`${name} actions`} actions={[ { - label: 'Reconnect', + label: needsInstall ? 'Install Sim Search' : 'Reconnect', disabled: busy, onSelect: () => setWizard({ + mode: needsInstall ? 'shared' : installation.appKind, installationId: installation.id, appId: installation.appId, - initialName: name, + initialName: + custom && !needsInstall + ? bots.find((bot) => bot.id === installation.credentialId) + ?.displayName + : undefined, }), }, { diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index 3f33eee1fc3..3cc991913e9 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -20,6 +20,7 @@ import { useSlackSearchManifest, useStartSlackSearchOAuth } from '@/hooks/querie interface SlackSearchSetupWizardProps { organizationId: string + mode?: 'custom' | 'shared' installationId?: string appId?: string initialName?: string @@ -29,6 +30,7 @@ interface SlackSearchSetupWizardProps { /** App creation, credentials, and consent are one organization-specific setup flow. */ export function SlackSearchSetupWizard({ organizationId, + mode, installationId, appId, initialName, @@ -61,9 +63,12 @@ export function SlackSearchSetupWizard({ } } - const shared = Boolean( - prepare.data?.sharedAppId && (!configuredAppId || configuredAppId === prepare.data.sharedAppId) - ) + const shared = mode + ? mode === 'shared' + : Boolean( + prepare.data?.sharedAppId && + (!configuredAppId || configuredAppId === prepare.data.sharedAppId) + ) function installShared() { oauth.mutate( @@ -144,22 +149,40 @@ export function SlackSearchSetupWizard({ onOpenChange={(open) => { if (!open) onClose() }} - srTitle='Install Sim Search' + srTitle='Install the Sim Search app' + size='sm' > - Install Sim Search + Install the Sim Search app

- Choose your Slack workspace and approve Sim Search. + Add Sim Search to your Slack workspace to ask questions and get answers from your + connected sources.

- {error?.message} + + {error?.message ?? + (!prepare.data.sharedAppId + ? 'Sim Search installation is unavailable. Try again.' + : null)} +
void prepare.refetch(), + disabled: prepare.isFetching, + }, + ] + : undefined + } primaryAction={{ - label: busy ? 'Connecting…' : 'Install Sim Search', - disabled: busy, + label: busy ? 'Connecting…' : 'Continue with Slack', + disabled: busy || !prepare.data.sharedAppId || Boolean(prepare.error), onClick: installShared, }} /> diff --git a/apps/sim/lib/credential-groups/provider-configuration.test.ts b/apps/sim/lib/credential-groups/provider-configuration.test.ts index bd833a7f285..0d5c958b2ad 100644 --- a/apps/sim/lib/credential-groups/provider-configuration.test.ts +++ b/apps/sim/lib/credential-groups/provider-configuration.test.ts @@ -12,6 +12,7 @@ const shared = vi.hoisted(() => ({ flag: vi.fn(), })) vi.mock('@/lib/core/config/env', () => ({ env: shared.env })) +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: shared.flag })) vi.mock('@/lib/core/security/encryption', () => ({ @@ -39,7 +40,7 @@ const configuration = { beforeEach(() => { resetDbChainMock() shared.env.SLACK_SEARCH_APP_ID = '' - shared.flag.mockResolvedValue(true) + shared.flag.mockReset().mockResolvedValue(true) }) describe('organization Slack app references', () => { @@ -105,6 +106,39 @@ describe('organization Slack app references', () => { else await expect(result).rejects.toThrow('disabled or removed') } ) + it('keeps using the custom app for personal sources after a different native app is installed', async () => { + shared.env.SLACK_SEARCH_APP_ID = 'ANATIVE' + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + encryptedProviderConfiguration: + await encryptCredentialGroupProviderConfiguration(configuration), + }, + ]) + .mockResolvedValueOnce([ + { + id: 'A1', + kind: 'custom', + organizationId: 'org-1', + clientId: 'custom-client', + encryptedClientSecret: 'encrypted:custom-secret', + encryptedSigningSecret: 'encrypted:custom-signing', + }, + ]) + await expect( + getSlackCredentialGroupConfiguration({ + organizationId: 'org-1', + credentialGroupId: 'group-1', + }) + ).resolves.toMatchObject({ + appId: 'A1', + teamId: 'T1', + clientId: 'custom-client', + clientSecret: 'custom-secret', + }) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) + expect(shared.flag).not.toHaveBeenCalled() + }) it('fails when the referenced app is absent from the owning organization', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ diff --git a/apps/sim/lib/credential-groups/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/slack-managed-users.test.ts index 3e32054c599..e6aade21c41 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.test.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.test.ts @@ -34,6 +34,7 @@ const shared = vi.hoisted(() => ({ flag: vi.fn(), })) vi.mock('@/lib/core/config/env', () => ({ env: shared.env })) +vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: shared.flag })) vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => redis })) diff --git a/apps/sim/lib/knowledge/application/slack-search/authorization.test.ts b/apps/sim/lib/knowledge/application/slack-search/authorization.test.ts index 0835971096e..5e3cf6b52c3 100644 --- a/apps/sim/lib/knowledge/application/slack-search/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/authorization.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ installation: vi.fn(), credential: vi.fn(), availability: vi.fn(), + replacement: vi.fn(), })) vi.mock('@/lib/knowledge/application/slack-search/repository', () => ({ findSlackSearchInstallation: mocks.installation, @@ -14,8 +15,15 @@ vi.mock('@/lib/knowledge/application/slack-search/repository', () => ({ vi.mock('@/lib/knowledge/access/availability', () => ({ requireOrganizationSearchAvailable: mocks.availability, })) +vi.mock('@/lib/slack-search/shared-app', () => ({ + requireSlackSearchAppAvailable: vi.fn(), + findSharedSlackSearchInstallation: mocks.replacement, +})) -import { authorizeSlackSearchInstallation } from '@/lib/knowledge/application/slack-search/authorization' +import { + authorizeSlackSearchInstallation, + authorizeSlackSearchRedirect, +} from '@/lib/knowledge/application/slack-search/authorization' const principal: SlackInstallationPrincipal = { kind: 'slack_installation', @@ -41,6 +49,89 @@ beforeEach(() => { mocks.installation.mockResolvedValue(installation) mocks.credential.mockResolvedValue({ version: 'version1', botToken: 'secret' }) mocks.availability.mockResolvedValue(undefined) + mocks.replacement.mockResolvedValue(null) +}) + +describe('retired Slack bot handoff authorization', () => { + const replacement = { + ...installation, + id: 'shared-install', + credentialId: 'shared-credential', + appId: 'ASHARED', + credentialVersion: 'shared-version', + } + beforeEach(() => { + mocks.installation.mockResolvedValue({ ...installation, enabled: false }) + mocks.credential.mockImplementation(async (id) => + id === replacement.credentialId + ? { appKind: 'shared', version: replacement.credentialVersion } + : { appKind: 'custom', version: 'version1', botToken: 'custom-token' } + ) + mocks.replacement.mockResolvedValue(replacement) + }) + + it('authorizes only a handoff to the active shared installation for the same owner and team', async () => { + await expect(authorizeSlackSearchInstallation(principal)).resolves.toBeNull() + await expect(authorizeSlackSearchRedirect(principal)).resolves.toMatchObject({ + installation: { id: 'install1', enabled: false }, + replacement, + secret: { botToken: 'custom-token' }, + }) + expect(mocks.replacement).toHaveBeenCalledWith('org1') + expect(mocks.credential).toHaveBeenLastCalledWith('shared-credential', 'org1') + }) + + it.each([null, { ...installation, enabled: true }])( + 'ignores missing or active old bots: %j', + async (current) => { + mocks.installation.mockResolvedValue(current) + await expect(authorizeSlackSearchRedirect(principal)).resolves.toBeNull() + expect(mocks.replacement).not.toHaveBeenCalled() + } + ) + + it.each([ + null, + { ...replacement, organizationId: 'another-org' }, + { ...replacement, teamId: 'TOTHER' }, + { ...replacement, appId: 'A1' }, + ])('ignores unavailable or mismatched replacements: %j', async (target) => { + mocks.replacement.mockResolvedValue(target) + await expect(authorizeSlackSearchRedirect(principal)).resolves.toBeNull() + expect(mocks.credential).toHaveBeenCalledTimes(1) + }) + + it('does not redirect a disabled shared app', async () => { + mocks.credential.mockResolvedValue({ appKind: 'shared', version: 'version1' }) + await expect(authorizeSlackSearchRedirect(principal)).resolves.toBeNull() + expect(mocks.replacement).not.toHaveBeenCalled() + }) + + it.each([{ teamId: 'TOTHER' }, { appId: 'AOTHER' }, { credentialVersion: 'stale' }])( + 'rejects forged installation identity: %j', + async (change) => { + await expect(authorizeSlackSearchRedirect({ ...principal, ...change })).rejects.toThrow( + 'binding' + ) + expect(mocks.replacement).not.toHaveBeenCalled() + } + ) + + it('rejects old queued work after the installation changes', async () => { + await expect( + authorizeSlackSearchRedirect(principal, { installationId: 'install1', revision: 'old' }) + ).rejects.toThrow('binding') + expect(mocks.replacement).not.toHaveBeenCalled() + }) + + it('rejects revoked Search access and replacement credential rotation', async () => { + mocks.availability.mockRejectedValueOnce(new Error('Search disabled')) + await expect(authorizeSlackSearchRedirect(principal)).rejects.toThrow('Search disabled') + expect(mocks.credential).not.toHaveBeenCalled() + mocks.credential.mockResolvedValueOnce({ appKind: 'custom', version: 'version1' }) + mocks.credential.mockResolvedValueOnce({ appKind: 'shared', version: 'rotated' }) + await expect(authorizeSlackSearchRedirect(principal)).rejects.toThrow('revalidation') + }) }) describe('Slack Search installation authorization', () => { it('rejects human principals before protected lookup', async () => { diff --git a/apps/sim/lib/knowledge/application/slack-search/authorization.ts b/apps/sim/lib/knowledge/application/slack-search/authorization.ts index 37bc713ee26..7c495662add 100644 --- a/apps/sim/lib/knowledge/application/slack-search/authorization.ts +++ b/apps/sim/lib/knowledge/application/slack-search/authorization.ts @@ -4,8 +4,12 @@ import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/avail import { findSlackSearchInstallation, loadSlackSearchCredential, + type SlackSearchInstallation, } from '@/lib/knowledge/application/slack-search/repository' -import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' +import { + findSharedSlackSearchInstallation, + requireSlackSearchAppAvailable, +} from '@/lib/slack-search/shared-app' export function requireSlackInstallationPrincipal( principal: Principal @@ -29,6 +33,14 @@ export async function authorizeSlackSearchInstallation( requireSlackInstallationPrincipal(principal) const installation = await findSlackSearchInstallation(principal.credentialId) if (!installation || !installation.enabled) return null + return authorizeSlackSearchBinding(principal, installation, expected) +} + +async function authorizeSlackSearchBinding( + principal: SlackInstallationPrincipal, + installation: SlackSearchInstallation, + expected?: { installationId: string; revision: string } +) { if ( installation.appId !== principal.appId || installation.teamId !== principal.teamId || @@ -48,3 +60,30 @@ export async function authorizeSlackSearchInstallation( throw new OrchestrationError('forbidden', 'Slack bot requires revalidation') return { installation, secret } } + +/** A retired custom bot may only point to the active shared app in the same organization and team. */ +export async function authorizeSlackSearchRedirect( + principal: Principal, + expected?: { installationId: string; revision: string } +) { + requireSlackInstallationPrincipal(principal) + const installation = await findSlackSearchInstallation(principal.credentialId) + if (!installation || installation.enabled) return null + const context = await authorizeSlackSearchBinding(principal, installation, expected) + if (context.secret.appKind !== 'custom') return null + const replacement = await findSharedSlackSearchInstallation(installation.organizationId) + if ( + !replacement || + replacement.organizationId !== installation.organizationId || + replacement.teamId !== installation.teamId || + replacement.appId === installation.appId + ) + return null + const secret = await loadSlackSearchCredential( + replacement.credentialId, + installation.organizationId + ) + if (secret.version !== replacement.credentialVersion) + throw new OrchestrationError('forbidden', 'Slack bot requires revalidation') + return { ...context, replacement } +} diff --git a/apps/sim/lib/knowledge/application/slack-search/process-message.test.ts b/apps/sim/lib/knowledge/application/slack-search/process-message.test.ts index 98d1f2036bb..3a24709634e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/process-message.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/process-message.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ authorize: vi.fn(), + redirect: vi.fn(), persist: vi.fn(), dispatch: vi.fn(), assistant: vi.fn(), @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/knowledge/application/slack-search/authorization', () => ({ requireSlackInstallationPrincipal: vi.fn(), authorizeSlackSearchInstallation: mocks.authorize, + authorizeSlackSearchRedirect: mocks.redirect, })) vi.mock('@/lib/knowledge/application/slack-search/assistant', () => ({ runSlackSearchAssistant: mocks.assistant, @@ -62,10 +64,122 @@ beforeEach(() => { secret: { botToken: 'test-token' }, }) mocks.persist.mockResolvedValue('turn1') + mocks.redirect.mockResolvedValue(null) mocks.route.mockImplementation(async (_principal, { job }) => job) mocks.post.mockResolvedValue({ status: 200, data: { ok: true } }) }) +describe('retired bot handoff', () => { + const redirect = { + installation: { id: 'i1', revision: 'r1', botUserId: 'UBOT' }, + replacement: { appId: 'ASHARED' }, + secret: { botToken: 'old-bot-token' }, + } + const job: SlackSearchJob = { + installationId: 'i1', + revision: 'r1', + credentialId: 'c1', + credentialVersion: 'v1', + receivedAt: principal.receivedAt.getTime(), + redirectAppId: 'ASHARED', + message: { ...message, query: '' }, + } + beforeEach(() => { + mocks.authorize.mockResolvedValue(null) + mocks.redirect.mockResolvedValue(redirect) + }) + const respond = (overrides: Partial = {}) => + respondToSlackSearchMessage.execute({ + principal, + input: { + job: { ...job, ...overrides }, + turnId: 'turn1', + leaseId: 'lease1', + controller: new AbortController(), + }, + }) + + it('queues the handoff through the existing deduplicated turn path without saving the question', async () => { + await receiveSlackSearchMessage.execute({ principal, input: message }) + expect(mocks.persist).toHaveBeenCalledWith(job) + expect(mocks.persist.mock.invocationCallOrder[0]).toBeLessThan( + mocks.dispatch.mock.invocationCallOrder[0] + ) + expect(mocks.post).not.toHaveBeenCalled() + expect(mocks.assistant).not.toHaveBeenCalled() + }) + + it.each([undefined, '1700000000.000001'])( + 'links from the original DM thread: %s', + async (threadTs) => { + await respond({ message: { ...job.message, threadTs } }) + expect(mocks.redirect).toHaveBeenCalledWith( + principal, + expect.objectContaining({ revision: 'r1' }) + ) + expect(mocks.post).toHaveBeenCalledWith( + 'old-bot-token', + expect.objectContaining({ + channel: 'D1', + thread_ts: threadTs ?? message.messageTs, + text: expect.stringContaining('https://slack.com/app_redirect?app=ASHARED&team=T1'), + blocks: expect.arrayContaining([ + expect.objectContaining({ + elements: [ + expect.objectContaining({ + text: { type: 'plain_text', text: 'Open Sim Search' }, + url: 'https://slack.com/app_redirect?app=ASHARED&team=T1', + }), + ], + }), + ]), + }), + expect.any(AbortSignal) + ) + expect(mocks.route).not.toHaveBeenCalled() + expect(mocks.assistant).not.toHaveBeenCalled() + } + ) + + it('keeps a manually disabled bot quiet without an active replacement', async () => { + mocks.redirect.mockResolvedValue(null) + await receiveSlackSearchMessage.execute({ principal, input: message }) + expect(mocks.persist).not.toHaveBeenCalled() + }) + + it.each([{ channelId: 'C1' }, { userId: 'UBOT' }])( + 'ignores mentions and bot messages: %j', + async (change) => { + await receiveSlackSearchMessage.execute({ principal, input: { ...message, ...change } }) + expect(mocks.persist).not.toHaveBeenCalled() + } + ) + + it.each([null, { ...redirect, replacement: { appId: 'ADIFFERENT' } }])( + 'rechecks replacement availability before delivery: %j', + async (current) => { + mocks.redirect.mockResolvedValue(current) + await expect(respond()).rejects.toThrow('replacement changed') + expect(mocks.post).not.toHaveBeenCalled() + } + ) + + it('does not deliver after losing its durable claim', async () => { + mocks.lease.mockRejectedValueOnce(new Error('lease lost')) + await expect(respond()).rejects.toThrow('lease lost') + expect(mocks.post).not.toHaveBeenCalled() + }) + + it('does not retry failed or ambiguous sends', async () => { + mocks.post.mockResolvedValueOnce({ status: 200, data: { ok: false } }) + await expect(respond()).rejects.toThrow('handoff') + mocks.post.mockRejectedValueOnce(new Error('response lost')) + await expect(respond()).rejects.toThrow('response lost') + expect(mocks.post).toHaveBeenCalledTimes(2) + expect(mocks.assistant).not.toHaveBeenCalled() + }) +}) + describe('Slack Search question validation', () => { function respond(overrides: Partial = {}) { return respondToSlackSearchMessage.execute({ diff --git a/apps/sim/lib/knowledge/application/slack-search/process-message.ts b/apps/sim/lib/knowledge/application/slack-search/process-message.ts index e5b38ee3457..76848dd8729 100644 --- a/apps/sim/lib/knowledge/application/slack-search/process-message.ts +++ b/apps/sim/lib/knowledge/application/slack-search/process-message.ts @@ -5,6 +5,7 @@ import { postSlackMessage } from '@/lib/internal/slack/client' import { runSlackSearchAssistant } from '@/lib/knowledge/application/slack-search/assistant' import { authorizeSlackSearchInstallation, + authorizeSlackSearchRedirect, requireSlackInstallationPrincipal, } from '@/lib/knowledge/application/slack-search/authorization' import { routeSlackSearchMentionToDm } from '@/lib/knowledge/application/slack-search/mention' @@ -14,7 +15,7 @@ import { requireSlackSearchTurnLease, } from '@/lib/knowledge/application/slack-search/turns' import { SLACK_SEARCH_QUERY_TOO_LONG } from '@/lib/slack-search/constants' -import { slackSearchReply } from '@/lib/slack-search/messages' +import { renderSlackSearchRedirect, slackSearchReply } from '@/lib/slack-search/messages' import type { SlackSearchJob, SlackSearchMessage } from '@/lib/slack-search/types' import { slackSearchThreadTimestamp } from '@/lib/slack-search/types' @@ -51,7 +52,23 @@ export const receiveSlackSearchMessage: OperationUseCase< requireSlackInstallationPrincipal(principal) requireMessageBinding(principal, input) const context = await authorizeSlackSearchInstallation(principal) - if (!context || input.userId === context.installation.botUserId) return + if (!context) { + if (!input.channelId.startsWith('D') || input.command || input.origin) return + const redirect = await authorizeSlackSearchRedirect(principal) + if (!redirect || input.userId === redirect.installation.botUserId) return + const turnId = await persistSlackSearchTurn({ + installationId: redirect.installation.id, + revision: redirect.installation.revision, + credentialId: principal.credentialId, + credentialVersion: principal.credentialVersion, + receivedAt: principal.receivedAt.getTime(), + redirectAppId: redirect.replacement.appId, + message: { ...input, query: '', queryTooLong: false }, + }) + await dispatchSlackSearchTurn(turnId) + return turnId + } + if (input.userId === context.installation.botUserId) return const mention = !input.channelId.startsWith('D') const query = mention ? input.query.replaceAll(`<@${context.installation.botUserId}>`, '').trim() @@ -86,6 +103,24 @@ export const respondToSlackSearchMessage: OperationUseCase< async execute({ principal, input }) { requireSlackInstallationPrincipal(principal) requireMessageBinding(principal, input.job.message) + if (input.job.redirectAppId) { + const { job, controller } = input + if (!job.message.channelId.startsWith('D') || job.message.command || job.message.origin) + throw new OrchestrationError('forbidden', 'Slack app redirects require a direct message') + await requireSlackSearchTurnLease(input.turnId, input.leaseId) + const context = await authorizeSlackSearchRedirect(principal, job) + if (!context || context.replacement.appId !== job.redirectAppId) + throw new OrchestrationError('forbidden', 'Slack Search replacement changed') + controller.signal.throwIfAborted() + const response = await postSlackMessage( + context.secret.botToken, + renderSlackSearchRedirect(job.message, context.replacement.appId), + AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) + ) + if (response.status !== 200 || response.data.ok !== true) + throw new Error('Could not deliver the Slack app handoff') + return + } if (!input.job.message.queryTooLong && !input.job.message.query) throw new OrchestrationError( 'validation', 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 214f5f3a290..da736fa278a 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts @@ -1,5 +1,6 @@ /** @vitest-environment node */ import { db } from '@sim/db' +import { credential, slackSearchInstallation } from '@sim/db/schema' import { beforeEach, describe, expect, it, vi } from 'vitest' const m = vi.hoisted(() => ({ @@ -17,6 +18,10 @@ const m = vi.hoisted(() => ({ revoke: vi.fn(), validateGrant: vi.fn(), ensureGroup: vi.fn(), + memberApps: vi.fn(), + adoptMemberApp: vi.fn(), + insert: vi.fn(), + update: vi.fn(), })) vi.mock('@/lib/slack-search/shared-app', () => ({ readSharedSlackSearchApp: m.shared })) vi.mock('@sim/audit', () => ({ @@ -55,8 +60,8 @@ vi.mock('@/lib/internal/slack/oauth', () => ({ })) vi.mock('@/lib/credential-groups/service', () => ({ ensureWorkspaceAccountsGroup: m.ensureGroup })) vi.mock('@/lib/credential-groups/organization-slack-app', () => ({ - loadOrganizationSlackMemberApps: async () => [], - adoptOrganizationSlackMemberApp: vi.fn(), + loadOrganizationSlackMemberApps: m.memberApps, + adoptOrganizationSlackMemberApp: m.adoptMemberApp, })) vi.mock('@/lib/internal/slack/search-client', () => ({ verifySlackSearchBot: m.verify, @@ -98,6 +103,7 @@ beforeEach(() => { m.revoke.mockResolvedValue(undefined) m.validateGrant.mockReset() m.ensureGroup.mockResolvedValue({ id: 'accounts' }) + m.memberApps.mockReset().mockResolvedValue([]) m.baseUrl.mockReturnValue('https://sim.test') m.membership.mockResolvedValue([{ role: 'admin' }]) m.rows.mockReset().mockResolvedValue([]) @@ -138,28 +144,31 @@ beforeEach(() => { const tx = { execute: vi.fn(), select: () => txQuery, - insert: () => txQuery, - update: () => txQuery, + insert: m.insert.mockReturnValue(txQuery), + update: m.update.mockReturnValue(txQuery), } vi.mocked(db.transaction).mockImplementation(async (callback) => callback(tx as Parameters[0]>[0]) ) }) describe('Search OAuth installation', () => { - it('fails setup and OAuth with actionable validation before storing secrets on localhost', async () => { + it('prepares setup and starts OAuth using the configured app origin', async () => { m.baseUrl.mockReturnValue('http://localhost:3000') const input = { organizationId: 'org1', name: 'Sim Search', description: 'Search with sources' } - await expect(prepareSlackSearchSetup.execute({ principal, input })).rejects.toMatchObject({ - code: 'validation', - message: expect.stringContaining('public HTTPS'), - }) + await expect(prepareSlackSearchSetup.execute({ principal, input })).resolves.toHaveProperty( + 'manifest' + ) await expect( startSlackSearchSetup.execute({ principal, input: { ...input, clientId: 'client', clientSecret: 'secret', signingSecret: 'signing' }, }) - ).rejects.toMatchObject({ code: 'validation' }) - expect(m.store).not.toHaveBeenCalled() + ).resolves.toHaveProperty('authorizationUrl') + expect(m.store).toHaveBeenCalledWith( + expect.objectContaining({ + redirectUri: 'http://localhost:3000/api/knowledge/slack/oauth/callback', + }) + ) expect(m.exchange).not.toHaveBeenCalled() expect(db.transaction).not.toHaveBeenCalled() }) @@ -336,6 +345,256 @@ describe('shared app completion', () => { expect(JSON.stringify(stored)).not.toContain('environment-secret') }) + describe('custom bot transition', () => { + const customInstallation = { + id: 'custom-installation', + revision: 'custom-revision', + organizationId: 'org1', + credentialId: 'custom-credential', + appId: 'ACUSTOM', + slackAppId: 'ACUSTOM', + teamId: 'T1', + enabled: true, + } + const customApp = { + id: 'ACUSTOM', + kind: 'custom', + organizationId: 'org1', + revision: 'custom-app-revision', + } + const memberApp = { appId: 'ACUSTOM', teamId: 'T1' } + const input = { + organizationId: 'org1', + installationId: customInstallation.id, + mode: 'shared' as const, + name: 'Sim Search', + description: 'Search', + } + + beforeEach(() => { + m.memberApps.mockResolvedValue([ + { configuration: { slack: { ...memberApp, scopes: ['im:history'] } } }, + ]) + m.consume.mockResolvedValue({ + ...attempt, + sharedApp: { id: sharedApp.id, revision: sharedApp.revision }, + customInstallation, + memberApp, + }) + }) + + function queueTransitionRows(custom = customInstallation) { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([custom]) + .mockResolvedValueOnce([]) + } + + it('binds the old installation and workspace without changing anything before approval', async () => { + m.membership + .mockResolvedValueOnce([{ role: 'admin' }]) + .mockResolvedValueOnce([customInstallation]) + .mockResolvedValueOnce([customApp]) + const result = await startSlackSearchSetup.execute({ principal, input }) + const url = new URL(result.authorizationUrl) + expect(url.searchParams.get('team')).toBe('T1') + expect(url.searchParams.get('client_id')).toBe(sharedApp.clientId) + expect(url.searchParams.has('user_scope')).toBe(false) + expect(m.store).toHaveBeenCalledWith( + expect.objectContaining({ + customInstallation: { + id: customInstallation.id, + revision: customInstallation.revision, + credentialId: customInstallation.credentialId, + appId: customInstallation.appId, + teamId: 'T1', + appRevision: customApp.revision, + }, + memberApp, + }) + ) + expect(m.store.mock.calls[0][0]).not.toHaveProperty('installation') + expect(db.transaction).not.toHaveBeenCalled() + }) + + it('keeps the old row and credentials while activating a new bot in one transaction', async () => { + queueTransitionRows() + await complete() + expect(db.transaction).toHaveBeenCalledOnce() + expect(m.update).toHaveBeenCalledExactlyOnceWith(slackSearchInstallation) + expect(m.set).toHaveBeenCalledExactlyOnceWith({ + enabled: false, + revision: expect.not.stringMatching(customInstallation.revision), + updatedAt: expect.any(Date), + }) + expect(m.insert).toHaveBeenCalledWith(credential) + expect(m.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.not.stringMatching(customInstallation.credentialId), + organizationId: 'org1', + slackAppId: 'A1', + workspaceId: null, + }) + ) + expect(m.values).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: expect.not.stringMatching(customInstallation.id), + appId: 'A1', + teamId: 'T1', + enabled: true, + }) + ) + expect(m.adoptMemberApp).not.toHaveBeenCalled() + expect(m.ensureGroup).not.toHaveBeenCalled() + expect(m.revoke).not.toHaveBeenCalled() + expect(m.audit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ previousInstallationId: customInstallation.id }), + }) + ) + expect(m.set.mock.invocationCallOrder[0]).toBeLessThan( + m.values.mock.invocationCallOrder.at(-1)! + ) + }) + + it('refuses a foreign installation before issuing OAuth state', async () => { + m.membership.mockResolvedValueOnce([{ role: 'admin' }]).mockResolvedValueOnce([]) + await expect(startSlackSearchSetup.execute({ principal, input })).rejects.toThrow('not found') + expect(m.store).not.toHaveBeenCalled() + }) + + it('rechecks the rollout gate before disabling the old bot', async () => { + queueTransitionRows() + m.shared.mockResolvedValueOnce(sharedApp).mockResolvedValueOnce(null) + await expect(complete()).rejects.toThrow('configuration changed') + expect(m.update).not.toHaveBeenCalled() + expect(m.values).not.toHaveBeenCalled() + }) + + it('rejects another active workspace binding even during a custom transition', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([customInstallation]) + .mockResolvedValueOnce([{ id: 'another-installation' }]) + await expect(complete()).rejects.toThrow('already has an active Search installation') + expect(m.update).not.toHaveBeenCalled() + expect(m.values).not.toHaveBeenCalled() + }) + + it('rejects a duplicate completion without revoking the already installed native bot', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([{ id: 'native-installation', organizationId: 'org1' }]) + .mockResolvedValueOnce([{ id: 'native-installation' }]) + await expect(complete()).rejects.toThrow('already connected') + expect(m.update).not.toHaveBeenCalled() + expect(m.revoke).not.toHaveBeenCalled() + }) + + it('fails the transaction if saving the new installation fails after disabling the custom bot', async () => { + queueTransitionRows() + const baseValues = m.values.getMockImplementation()! + m.values.mockImplementation((value) => { + if (value.enabled === true) throw new Error('installation write failed') + return baseValues(value) + }) + await expect(complete()).rejects.toThrow('installation write failed') + expect(m.set).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + await expect(vi.mocked(db.transaction).mock.results[0].value).rejects.toThrow( + 'installation write failed' + ) + expect(m.audit).not.toHaveBeenCalled() + expect(m.revoke).toHaveBeenCalledWith('bot-token') + }) + + it('leaves the custom bot untouched when the admin cancels Slack consent', async () => { + await expect( + completeSlackSearchSetup.execute({ + principal, + input: { state: 'state', error: 'access_denied' }, + }) + ).rejects.toThrow('not authorized') + expect(m.exchange).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + }) + + it('rejects a transition to a different Slack workspace', async () => { + m.exchange.mockResolvedValue({ + app_id: 'A1', + team: { id: 'T2' }, + bot_user_id: 'UBOT', + access_token: 'bot-token', + }) + m.verify.mockResolvedValue({ ...identity, teamId: 'T2' }) + await expect(complete()).rejects.toThrow('same Slack workspace') + expect(m.update).not.toHaveBeenCalled() + expect(m.values).not.toHaveBeenCalled() + }) + + it.each([ + { revision: 'changed' }, + { organizationId: 'other-org' }, + { credentialId: 'another-credential' }, + { appId: 'another-app' }, + ])('rejects a stale or foreign custom installation: %j', async (changed) => { + queueTransitionRows({ ...customInstallation, ...changed }) + await expect(complete()).rejects.toThrow('custom bot changed') + expect(m.update).not.toHaveBeenCalled() + expect(m.values).not.toHaveBeenCalled() + }) + + it('refuses an installation removed while OAuth was open', async () => { + m.rows.mockResolvedValueOnce([sharedApp]).mockResolvedValueOnce([]).mockResolvedValueOnce([]) + await expect(complete()).rejects.toThrow('custom bot changed') + expect(m.update).not.toHaveBeenCalled() + }) + + it('refuses a changed personal source configuration without touching its grants', async () => { + queueTransitionRows() + m.memberApps.mockResolvedValue([]) + await expect(complete()).rejects.toThrow('source configuration changed') + expect(m.update).not.toHaveBeenCalled() + expect(m.adoptMemberApp).not.toHaveBeenCalled() + expect(m.ensureGroup).not.toHaveBeenCalled() + }) + + it('rejects a transition when the shared app is disabled', async () => { + m.shared.mockResolvedValue(null) + await expect(complete()).rejects.toThrow('configuration changed') + expect(m.exchange).not.toHaveBeenCalled() + expect(m.update).not.toHaveBeenCalled() + }) + + it('reconnects the native bot later without changing the custom source configuration', async () => { + const installed = { + id: 'native-installation', + revision: 'native-revision', + credentialId: 'native-credential', + appId: 'A1', + teamId: 'T1', + organizationId: 'org1', + } + m.consume.mockResolvedValue({ + ...attempt, + sharedApp: { id: sharedApp.id, revision: sharedApp.revision }, + installation: installed, + memberApp, + }) + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([installed]) + .mockResolvedValueOnce([]) + await complete() + expect(m.ensureGroup).not.toHaveBeenCalled() + expect(m.adoptMemberApp).not.toHaveBeenCalled() + expect(m.set).toHaveBeenCalledWith( + expect.objectContaining({ slackAppId: 'A1', enabled: true }) + ) + }) + }) + it('creates shared identity without app secrets and installs atomically without registration', async () => { m.rows .mockResolvedValueOnce([]) diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index b2e259a2a65..eda969c1afc 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -12,6 +12,7 @@ import { loadOrganizationSlackMemberApps, } from '@/lib/credential-groups/organization-slack-app' import { configureSharedSlackMemberApp } from '@/lib/credential-groups/shared-slack-app' +import type { DbOrTx } from '@/lib/db/types' import { exchangeSlackBotAuthorization, revokeSlackBotAuthorization, @@ -54,8 +55,8 @@ interface CompleteInput { error?: string } -async function existingMemberApp(organizationId: string) { - const rows = await loadOrganizationSlackMemberApps(organizationId) +async function existingMemberApp(organizationId: string, executor: DbOrTx = db) { + const rows = await loadOrganizationSlackMemberApps(organizationId, executor) const configurations = rows.flatMap((row) => row.configuration.slack ? [row.configuration.slack] : [] ) @@ -107,7 +108,6 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ if (principal.kind !== 'session') throw new Error('Slack setup requires a browser session') await requireOrganizationSearchAvailable(context.organizationId) const origin = getBaseUrl() - createSlackSearchManifest(input.name, input.description, origin) const member = await existingMemberApp(context.organizationId) const [installation] = input.installationId ? await db @@ -131,7 +131,10 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ .limit(1) : [] const shared = input.mode === 'shared' - if (savedApp && (savedApp.kind === 'shared') !== 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' @@ -145,10 +148,10 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ 'validation', 'Shared Slack app setup is unavailable or contains custom credentials' ) - if (shared && member.app && member.app.appId !== app?.id) + if (shared && installation && member.app && member.app.teamId !== installation.teamId) throw new OrchestrationError( 'conflict', - 'Remove the previous Slack source configuration before switching apps; members must reconnect' + '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') @@ -172,6 +175,16 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ 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, @@ -183,17 +196,10 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ clientId, redirectUri, createdAt: Date.now(), - ...(installation - ? { - installation: { - id: installation.id, - revision: installation.revision, - credentialId: installation.credentialId, - appId: installation.appId, - teamId: installation.teamId, - ...(app ? { appRevision: app.revision } : {}), - }, - } + ...(installationSnapshot + ? transitioning + ? { customInstallation: installationSnapshot } + : { installation: installationSnapshot } : {}), }) const url = new URL('https://slack.com/oauth/v2/authorize') @@ -264,6 +270,8 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ ) await requireOrganizationSearchAvailable(context.organizationId) const { attempt } = context + if (attempt.customInstallation && (!attempt.sharedApp || attempt.installation)) + throw new OrchestrationError('validation', 'Invalid Slack app transition') let clientSecret: string if (attempt.sharedApp) { const app = await readSharedSlackSearchApp() @@ -322,9 +330,16 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ 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.memberApp.appId !== identity.appId || attempt.memberApp.teamId !== identity.teamId) + ((!attempt.sharedApp && attempt.memberApp.appId !== identity.appId) || + attempt.memberApp.teamId !== identity.teamId) ) throw new OrchestrationError( 'conflict', @@ -409,6 +424,32 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ '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) @@ -416,7 +457,8 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ and( eq(slackSearchInstallation.teamId, identity.teamId), eq(slackSearchInstallation.enabled, true), - existing ? ne(slackSearchInstallation.id, existing.id) : undefined + existing ? ne(slackSearchInstallation.id, existing.id) : undefined, + customInstallation ? ne(slackSearchInstallation.id, customInstallation.id) : undefined ) ) .limit(1) @@ -476,14 +518,27 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ .values(appValues) .onConflictDoUpdate({ target: slackApp.id, set: appValues }) } - await adoptOrganizationSlackMemberApp( - tx, - context.organizationId, - identity.appId, - identity.teamId, - attempt.clientId + const member = await existingMemberApp(context.organizationId, tx) + if ( + member.app?.appId !== attempt.memberApp?.appId || + member.app?.teamId !== attempt.memberApp?.teamId ) - if (attempt.sharedApp) + 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, @@ -537,6 +592,11 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ 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) @@ -560,6 +620,12 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ action: AuditAction.ORGANIZATION_UPDATED, resourceType: AuditResourceType.ORGANIZATION, resourceId: context.organizationId, - metadata: { setting: 'slack-search', connected: true }, + metadata: { + setting: 'slack-search', + connected: true, + ...(context.attempt.customInstallation + ? { previousInstallationId: context.attempt.customInstallation.id } + : {}), + }, }), }) diff --git a/apps/sim/lib/knowledge/application/slack-search/turns.test.ts b/apps/sim/lib/knowledge/application/slack-search/turns.test.ts new file mode 100644 index 00000000000..e022cf091b0 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/turns.test.ts @@ -0,0 +1,113 @@ +/** @vitest-environment node */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + enqueue: vi.fn(), + sender: vi.fn(), + findChat: vi.fn(), + resolveChat: vi.fn(), +})) +vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.enqueue })) +vi.mock('@/lib/knowledge/application/slack-search/chat', () => ({ + requireSlackSearchConversationSender: mocks.sender, + findSlackSearchChatRecord: mocks.findChat, + resolveSlackSearchChatRecord: mocks.resolveChat, +})) + +import { persistSlackSearchTurn } from '@/lib/knowledge/application/slack-search/turns' +import { slackSearchConversationKey } from '@/lib/slack-search/conversation' +import type { SlackSearchJob } from '@/lib/slack-search/types' + +const installation = { + id: 'old-installation', + organizationId: 'org1', + enabled: false, + revision: 'switched', + credentialVersion: 'version1', +} +const job: SlackSearchJob = { + installationId: installation.id, + revision: installation.revision, + credentialId: 'credential1', + credentialVersion: installation.credentialVersion, + receivedAt: Date.now(), + redirectAppId: 'ASHARED', + message: { + appId: 'ACUSTOM', + teamId: 'T1', + eventId: 'Ev1', + userId: 'U1', + channelId: 'D1', + messageTs: '1800000000.1', + query: '', + queryTooLong: false, + }, +} +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.findChat.mockResolvedValue(null) +}) + +describe('durable retired-bot replies', () => { + it('persists a handoff without reading or creating private chat history', async () => { + queueTableRows(schemaMock.slackSearchInstallation, [installation]) + queueTableRows(schemaMock.slackSearchTurn, []) + queueTableRows(schemaMock.slackSearchTurn, [{ count: 0 }]) + const id = await persistSlackSearchTurn(job) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id, payload: job }) + ) + expect(mocks.enqueue).toHaveBeenCalledWith( + expect.anything(), + 'slack-search.turn', + { turnId: id }, + { id: `slack-search-turn:${id}` } + ) + expect(mocks.sender).not.toHaveBeenCalled() + expect(mocks.findChat).not.toHaveBeenCalled() + expect(mocks.resolveChat).not.toHaveBeenCalled() + }) + + it('returns the existing turn for a duplicate Slack event without another write', async () => { + queueTableRows(schemaMock.slackSearchInstallation, [installation]) + queueTableRows(schemaMock.slackSearchTurn, [ + { + id: 'existing-turn', + conversationKey: slackSearchConversationKey(installation.id, 'D1', '1800000000.1'), + payload: job, + }, + ]) + await expect(persistSlackSearchTurn(job)).resolves.toBe('existing-turn') + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mocks.enqueue).not.toHaveBeenCalled() + }) + + it.each([ + null, + { ...installation, enabled: true }, + { ...installation, revision: 'changed' }, + { ...installation, credentialVersion: 'rotated' }, + ])('rejects removed, re-enabled, or changed old installations: %j', async (current) => { + queueTableRows(schemaMock.slackSearchInstallation, current ? [current] : []) + await expect(persistSlackSearchTurn(job)).rejects.toThrow('binding changed') + expect(mocks.enqueue).not.toHaveBeenCalled() + }) + + it('still rejects regular search turns for disabled installations', async () => { + queueTableRows(schemaMock.slackSearchInstallation, [installation]) + await expect(persistSlackSearchTurn({ ...job, redirectAppId: undefined })).rejects.toThrow( + 'binding changed' + ) + expect(mocks.enqueue).not.toHaveBeenCalled() + }) + + it('retains the per-thread pending limit for handoff replies', async () => { + queueTableRows(schemaMock.slackSearchInstallation, [installation]) + queueTableRows(schemaMock.slackSearchTurn, []) + queueTableRows(schemaMock.slackSearchTurn, [{ count: 20 }]) + await expect(persistSlackSearchTurn(job)).rejects.toThrow('twenty queued questions') + expect(mocks.enqueue).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/turns.ts b/apps/sim/lib/knowledge/application/slack-search/turns.ts index 2c9cf165fa4..4adaf86dbb3 100644 --- a/apps/sim/lib/knowledge/application/slack-search/turns.ts +++ b/apps/sim/lib/knowledge/application/slack-search/turns.ts @@ -40,7 +40,8 @@ export async function persistSlackSearchTurn(job: SlackSearchJob, expectedUserId .for('update') .limit(1) if ( - !installation?.enabled || + !installation || + (job.redirectAppId ? installation.enabled : !installation.enabled) || installation.revision !== job.revision || installation.credentialVersion !== job.credentialVersion ) @@ -77,16 +78,18 @@ export async function persistSlackSearchTurn(job: SlackSearchJob, expectedUserId } if (duplicate && duplicate.conversationKey !== conversationKey) throw new OrchestrationError('forbidden', 'Slack event conversation changed') - if (conversation) await requireSlackSearchConversationSender(tx, conversation) - const chat = !conversation - ? null - : expectedUserId - ? await resolveSlackSearchChatRecord(tx, { - organizationId: installation.organizationId, - userId: expectedUserId, - conversation, - }) - : await findSlackSearchChatRecord(tx, conversation) + if (conversation && !job.redirectAppId) + await requireSlackSearchConversationSender(tx, conversation) + const chat = + !conversation || job.redirectAppId + ? null + : expectedUserId + ? await resolveSlackSearchChatRecord(tx, { + organizationId: installation.organizationId, + userId: expectedUserId, + conversation, + }) + : await findSlackSearchChatRecord(tx, conversation) if ( chat && (chat.organizationId !== installation.organizationId || diff --git a/apps/sim/lib/slack-search/manifest.test.ts b/apps/sim/lib/slack-search/manifest.test.ts index 6f434da4f74..68bf1a3804c 100644 --- a/apps/sim/lib/slack-search/manifest.test.ts +++ b/apps/sim/lib/slack-search/manifest.test.ts @@ -1,6 +1,5 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { createSharedSlackSearchManifest, createSlackSearchManifest, @@ -85,10 +84,11 @@ describe('Search app manifest', () => { expect(manifest.features.agent_view).toEqual({ agent_description: 'Search with sources' }) expect(manifest.display_information.name).toBe('Sim Search') }) - it('requires HTTPS before directing the admin to Slack', () => { - expect(() => - createSlackSearchManifest('Sim Search', 'Search', 'http://localhost:3003') - ).toThrow(OrchestrationError) + it('uses the configured origin without blocking local setup', () => { + const manifest = createSlackSearchManifest('Sim Search', 'Search', 'http://localhost:3000') + expect(manifest.settings.event_subscriptions.request_url).toBe( + 'http://localhost:3000/api/webhooks/slack' + ) }) }) diff --git a/apps/sim/lib/slack-search/manifest.ts b/apps/sim/lib/slack-search/manifest.ts index f8135416f95..c8ce9b5bd2e 100644 --- a/apps/sim/lib/slack-search/manifest.ts +++ b/apps/sim/lib/slack-search/manifest.ts @@ -1,4 +1,3 @@ -import { OrchestrationError } from '@/lib/core/orchestration/types' import { SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH, SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, @@ -20,12 +19,6 @@ export function createSlackSearchManifest( existingUserScopes: readonly string[] = [] ) { const url = new URL(origin) - if (url.protocol !== 'https:') { - throw new OrchestrationError( - 'validation', - 'Slack needs a public HTTPS URL to send messages to Sim. Configure this instance with a public HTTPS app URL, then retry setup. Localhost is not reachable from Slack.' - ) - } const webhookUrl = new URL(SLACK_SEARCH_WEBHOOK_PATH, url).href return { display_information: { name, description }, diff --git a/apps/sim/lib/slack-search/messages.ts b/apps/sim/lib/slack-search/messages.ts index 1093b86454f..9b37b0ca725 100644 --- a/apps/sim/lib/slack-search/messages.ts +++ b/apps/sim/lib/slack-search/messages.ts @@ -2,6 +2,7 @@ import { truncate } from '@sim/utils/string' import type { SlackJsonObject, SlackMessage } from '@/lib/internal/slack/client' import type { KnowledgeSearchItem } from '@/lib/knowledge/application/search' import type { SlackSearchMessage } from '@/lib/slack-search/types' +import { slackSearchThreadTimestamp } from '@/lib/slack-search/types' function sourceUrl(result: KnowledgeSearchItem, organizationId: string, baseUrl: string): string { if (result.sourceUrl) { @@ -39,6 +40,35 @@ export function slackSearchReply( } } +/** The destination is a verified installation, never a URL supplied by a message or model. */ +export function renderSlackSearchRedirect( + message: SlackSearchMessage, + appId: string +): SlackMessage { + const url = new URL('https://slack.com/app_redirect') + url.searchParams.set('app', appId) + url.searchParams.set('team', message.teamId) + const text = 'This bot has moved. Continue your conversation in the Sim Search app.' + return slackSearchReply( + { ...message, threadTs: slackSearchThreadTimestamp(message) }, + `${text} ${url.href}`, + [ + { type: 'section', text: { type: 'plain_text', text } }, + { + type: 'actions', + elements: [ + { + type: 'button', + action_id: 'sim_search.open_replacement', + text: { type: 'plain_text', text: 'Open Sim Search' }, + url: url.href, + }, + ], + }, + ] + ) +} + /** Presents the first five documents, preserving the ranking of their best returned chunks. */ export function renderSlackSearchResults( message: SlackSearchMessage, diff --git a/apps/sim/lib/slack-search/oauth-state.test.ts b/apps/sim/lib/slack-search/oauth-state.test.ts index 3bf69859e8f..a7008d43ebf 100644 --- a/apps/sim/lib/slack-search/oauth-state.test.ts +++ b/apps/sim/lib/slack-search/oauth-state.test.ts @@ -57,6 +57,28 @@ describe('Slack OAuth state', () => { 'already completed' ) }) + it('round-trips the custom installation snapshot in a single-use shared-app attempt', async () => { + const { encryptedClientSecret, encryptedSigningSecret, ...common } = attempt + const transition = { + ...common, + sharedApp: { id: 'ASHARED', revision: 'env-revision' }, + customInstallation: { + id: 'old-installation', + revision: 'old-revision', + credentialId: 'old-credential', + appId: 'ACUSTOM', + teamId: 'T1', + }, + memberApp: { appId: 'ACUSTOM', teamId: 'T1' }, + } + await storeSlackSearchOAuthAttempt(transition) + expect(JSON.parse(redis.set.mock.calls[0][1])).toEqual(transition) + redis.eval.mockResolvedValueOnce(redis.set.mock.calls[0][1]) + await expect(consumeSlackSearchOAuthAttempt('state', principal)).resolves.toEqual(transition) + await expect(consumeSlackSearchOAuthAttempt('state', principal)).rejects.toThrow( + 'already completed' + ) + }) it('rejects an expired attempt even when storage returns it', async () => { redis.eval.mockResolvedValueOnce( JSON.stringify({ ...attempt, createdAt: Date.now() - 601_000 }) diff --git a/apps/sim/lib/slack-search/oauth-state.ts b/apps/sim/lib/slack-search/oauth-state.ts index 7074500722e..27836bc061e 100644 --- a/apps/sim/lib/slack-search/oauth-state.ts +++ b/apps/sim/lib/slack-search/oauth-state.ts @@ -6,6 +6,14 @@ import { getRedisClient } from '@/lib/core/config/redis' import { OrchestrationError } from '@/lib/core/orchestration/types' const TTL_SECONDS = 600 +const installationSnapshotSchema = z.object({ + id: z.string().min(1), + revision: z.string().min(1), + credentialId: z.string().min(1), + appId: z.string().min(1), + teamId: z.string().min(1), + appRevision: z.string().optional(), +}) const attemptSchema = z .object({ userId: z.string().min(1), @@ -17,16 +25,8 @@ const attemptSchema = z clientId: z.string().min(1), redirectUri: z.string().url(), createdAt: z.number(), - installation: z - .object({ - id: z.string(), - revision: z.string(), - credentialId: z.string(), - appId: z.string(), - teamId: z.string(), - appRevision: z.string().optional(), - }) - .optional(), + installation: installationSnapshotSchema.optional(), + customInstallation: installationSnapshotSchema.optional(), }) .and( z.union([ diff --git a/apps/sim/lib/slack-search/shared-app.test.ts b/apps/sim/lib/slack-search/shared-app.test.ts index 9bce83397a7..2563b257915 100644 --- a/apps/sim/lib/slack-search/shared-app.test.ts +++ b/apps/sim/lib/slack-search/shared-app.test.ts @@ -5,6 +5,7 @@ import { queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const m = vi.hoisted(() => ({ + hosted: true, flag: vi.fn(), env: { SLACK_SEARCH_APP_ID: 'A1', @@ -14,6 +15,11 @@ const m = vi.hoisted(() => ({ }, })) vi.mock('@/lib/core/config/env', () => ({ env: m.env })) +vi.mock('@/lib/core/config/env-flags', () => ({ + get isHosted() { + return m.hosted + }, +})) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: m.flag })) import { @@ -25,6 +31,7 @@ import { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + m.hosted = true Object.assign(m.env, { SLACK_SEARCH_APP_ID: 'A1', SLACK_SEARCH_CLIENT_ID: 'client', @@ -34,6 +41,17 @@ beforeEach(() => { m.flag.mockResolvedValue(true) }) describe('shared Slack rollout', () => { + it('requires a hosted deployment even when configured and enabled', async () => { + m.hosted = false + await expect(readSharedSlackSearchApp()).resolves.toBeNull() + await expect(requireSlackSearchAppAvailable('A1')).rejects.toThrow('unavailable') + expect(m.flag).not.toHaveBeenCalled() + }) + it('preserves custom bot handling on self-hosted deployments', async () => { + m.hosted = false + queueTableRows(slackApp, [{ kind: 'custom' }]) + await expect(requireSlackSearchAppAvailable('CUSTOM')).resolves.toBeUndefined() + }) it.each([false, true])('requires both flag and configured app (flag=%s)', async (flag) => { m.flag.mockResolvedValue(flag) if (flag) m.env.SLACK_SEARCH_APP_ID = '' diff --git a/apps/sim/lib/slack-search/shared-app.ts b/apps/sim/lib/slack-search/shared-app.ts index 9431b4565ab..277279a5e6c 100644 --- a/apps/sim/lib/slack-search/shared-app.ts +++ b/apps/sim/lib/slack-search/shared-app.ts @@ -1,13 +1,14 @@ import { db } from '@sim/db' import { slackApp, slackSearchInstallation } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' +import { isHosted } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' /** Called only inside authorized installation/member operations; never returns secrets to a surface. */ export async function readSharedSlackSearchApp() { - if (!(await isFeatureEnabled('slack-search-shared-app'))) return null + if (!isHosted || !(await isFeatureEnabled('slack-search-shared-app'))) return null return getSharedSlackSearchAppConfiguration() } diff --git a/apps/sim/lib/slack-search/types.ts b/apps/sim/lib/slack-search/types.ts index e81c2341038..84bb80d81dc 100644 --- a/apps/sim/lib/slack-search/types.ts +++ b/apps/sim/lib/slack-search/types.ts @@ -40,6 +40,7 @@ export const slackSearchJobSchema = z.object({ credentialId: id, credentialVersion: id, receivedAt: z.number().int().positive(), + redirectAppId: id.optional(), message: slackSearchMessageSchema, }) export type SlackSearchJob = z.infer