- {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