From 7fff762f6f868eecf87478ac43c2e90f49ef7896 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 15:35:28 -0700 Subject: [PATCH 1/6] feat(search): add shared Slack app installation and commands --- apps/docs/content/docs/search/slack.mdx | 17 +++ apps/sim/.env.example | 5 + apps/sim/app/api/knowledge/slack/route.ts | 3 +- apps/sim/app/api/webhooks/slack/route.ts | 44 ++++++- .../integrations/connect-account-options.tsx | 16 ++- .../integrations/integrations.test.tsx | 7 ++ .../components/organization-search-slack.tsx | 2 +- .../slack-search-setup-wizard.tsx | 46 +++++++ apps/sim/lib/api/contracts/knowledge/slack.ts | 4 + apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/core/config/feature-flags.ts | 5 + .../provider-configuration.ts | 27 +++- apps/sim/lib/credential-groups/service.ts | 19 +-- .../lib/credential-groups/shared-slack-app.ts | 89 ++++++++++++++ .../credential-groups/slack-managed-users.ts | 20 ++- apps/sim/lib/internal/slack/search-client.ts | 8 +- .../slack-search-turns.integration.ts | 5 +- .../personal-search-integrations.ts | 6 +- .../application/slack-search/assistant.ts | 4 +- .../application/slack-search/authorization.ts | 2 + .../application/slack-search/commands.test.ts | 90 ++++++++++++++ .../application/slack-search/commands.ts | 85 +++++++++++++ .../slack-search/installations.test.ts | 2 +- .../application/slack-search/installations.ts | 20 ++- .../slack-search/lifecycle.test.ts | 93 ++++++++++++++ .../application/slack-search/lifecycle.ts | 116 ++++++++++++++++++ .../application/slack-search/mention.test.ts | 45 +++++++ .../application/slack-search/mention.ts | 13 +- .../application/slack-search/onboarding.ts | 20 +-- .../slack-search/process-message.ts | 8 +- .../application/slack-search/repository.ts | 10 +- .../application/slack-search/setup.test.ts | 12 ++ .../application/slack-search/setup.ts | 81 +++++++++--- .../application/slack-search/stop.ts | 4 +- .../application/slack-search/turns.ts | 37 +++--- apps/sim/lib/slack-search/commands.ts | 19 +++ apps/sim/lib/slack-search/constants.ts | 1 + apps/sim/lib/slack-search/conversation.ts | 3 +- apps/sim/lib/slack-search/manifest.test.ts | 35 +++++- apps/sim/lib/slack-search/manifest.ts | 52 +++++++- apps/sim/lib/slack-search/oauth-state.ts | 1 + apps/sim/lib/slack-search/shared-app.test.ts | 54 ++++++++ apps/sim/lib/slack-search/shared-app.ts | 52 ++++++++ apps/sim/lib/slack-search/types.ts | 59 +++++---- .../scripts/register-platform-slack-app.ts | 13 +- 45 files changed, 1143 insertions(+), 113 deletions(-) create mode 100644 apps/sim/lib/credential-groups/shared-slack-app.ts create mode 100644 apps/sim/lib/knowledge/application/slack-search/commands.test.ts create mode 100644 apps/sim/lib/knowledge/application/slack-search/commands.ts create mode 100644 apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts create mode 100644 apps/sim/lib/knowledge/application/slack-search/lifecycle.ts create mode 100644 apps/sim/lib/slack-search/commands.ts create mode 100644 apps/sim/lib/slack-search/shared-app.test.ts create mode 100644 apps/sim/lib/slack-search/shared-app.ts diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 2b63bb88e82..1a5356f4260 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -8,6 +8,23 @@ import { Image } from '@/components/ui/image' Slack Search indexes messages and threads each connected member can access. Public and private channels are included by default; one-to-one and group DMs are opt-in. A Sim organization admin installs the organization's Slack app, then each teammate authorizes their own account for indexing. +## Install the official app + +When the shared-app rollout is enabled for Sim Search, an organization admin can open **Settings → Sim Search in Slack → Install Sim Search**, select a Slack workspace, and approve the bot installation. Slack may require workspace-admin approval. Each organization connects one Slack workspace; Enterprise Grid-wide installations are not supported yet. + +Each member then opens **Integrations → Slack → Connect**, chooses what to index, and authorizes their own Slack account. Use the same email for Slack and your verified Sim account. Public and private channels are included by default; direct messages and group DMs are opt-in. The bot installation alone does not authorize access to members' messages. + +Slack uses the same indexing pipeline as other connected sources. Both the Sim web Assistant and Slack bot search the organization's knowledge base, applying the current person's access permissions. Newly connected content becomes searchable after indexing completes. Connection and sync status appear in Integrations. + +- DM **Sim Search**, or mention it in a channel it has joined. +- Use **/sim-search [question]** to start a private DM thread. Channel invocations keep personalized answers and account details in DMs. +- Use **/sim-connect [provider]**, or **Home → Connect sources**, to open your personal Integrations in Sim. OAuth begins only after you click Connect there. +- Follow source links to the original messages. Use Slack's Stop control to cancel the active answer and queued follow-ups. + +Existing custom-app installations are not silently converted. To switch to the official app, remove the old Slack Search binding and source app configuration explicitly, install the official app, and have members authorize it afresh. Workflow integrations keep their existing app configuration. + +The instructions below describe setting up a custom app when the official app is unavailable. + ## Before you start You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account. diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 4f2b7a58cee..40f5b3cb7e2 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -252,3 +252,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # Hosted MISTRAL_API_KEY requests share capacity across key rotation. Map any additional # keys in the same organization to one group using SHA-256 fingerprints, never raw keys. # MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"} + +# Official Sim Search Slack app (optional; requires existing Search access) +# Register the company app with scripts/register-platform-slack-app.ts --search. +# SLACK_SEARCH_APP_ID= +# SLACK_SEARCH_SHARED_APP=false # Off-production fallback for the global slack-search-shared-app flag diff --git a/apps/sim/app/api/knowledge/slack/route.ts b/apps/sim/app/api/knowledge/slack/route.ts index 7be1608dba5..5ada2c1b27d 100644 --- a/apps/sim/app/api/knowledge/slack/route.ts +++ b/apps/sim/app/api/knowledge/slack/route.ts @@ -22,7 +22,8 @@ export const GET = defineInternalJsonRoute({ errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ query }) => query, useCase: listSlackSearchInstallations, - present: ({ installations, bots }) => ({ + present: ({ installations, bots, sharedAppAvailable }) => ({ + sharedAppAvailable, bots, installations: installations.map((row) => ({ ...row, diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index e303d260206..85b87c35b41 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -1,11 +1,18 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' +import { after, type NextRequest, NextResponse } from 'next/server' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { receiveSlackSearchCommand } from '@/lib/knowledge/application/slack-search/commands' import { resolveSlackAppInstallation } from '@/lib/knowledge/application/slack-search/ingress' +import { + revokeSlackSearchAccess, + slackSearchLifecycleSchema, +} from '@/lib/knowledge/application/slack-search/lifecycle' +import { dispatchSlackSearchTurn } from '@/lib/knowledge/application/slack-search/outbox' import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration' +import { slackSearchCommandEventId, slackSearchCommandSchema } from '@/lib/slack-search/commands' import { dispatchSlackSearch } from '@/lib/slack-search/dispatcher' import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor' import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' @@ -69,6 +76,20 @@ async function handleSlackAppWebhook(request: NextRequest): Promise dispatchSlackSearchTurn(turnId)) + return NextResponse.json(response) + } if (searchInstallation) { await Promise.all([ dispatchSlackSearch({ ...searchInstallation, body, receivedAt }), diff --git a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx index c5e75a3a749..965263028c1 100644 --- a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx @@ -23,6 +23,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { usePersonalSearchIntegrations } from '@/hooks/queries/personal-search-integrations' import { useSearchIntegrations } from '@/hooks/queries/search-integrations' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment' @@ -43,6 +44,14 @@ export function ConnectAccountOptions({ const sources = useSearchSources(scope, { search }) const overview = useSearchSourceOverview(scope) const integrations = useSearchIntegrations(organization.id) + const slackInventory = usePersonalSearchIntegrations({ + organizationId: organization.id, + connectorType: 'slack', + }) + const canConnectSharedSlack = + slackInventory.data?.available.some( + (entry) => entry.target.connectorType === 'slack' && !entry.target.connectorId + ) === true const availability = usePermissionConfig() const membershipQueryKeys = useMemo( () => [ @@ -90,7 +99,7 @@ export function ConnectAccountOptions({ ) const sourceChoices = SEARCH_CONNECTORS.filter((connector) => { if ( - connector.type === 'slack' || + (connector.type === 'slack' && !canConnectSharedSlack) || !approvedTypes.has(connector.type) || !connector.meta.name.toLowerCase().includes(search.toLowerCase()) || (configuredTypes.has(connector.type) && connector.setupFields.length === 0) @@ -124,7 +133,9 @@ export function ConnectAccountOptions({ ? overview : integrations.isError ? integrations - : null + : slackInventory.isError + ? slackInventory + : null return ( <> @@ -148,6 +159,7 @@ export function ConnectAccountOptions({ ) : sources.isPending || overview.isPending || integrations.isPending || + slackInventory.isPending || !availability.isIntegrationAvailabilityReady ? ( Loading sources… ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index 5ffeeca2f52..9cc1f7734c9 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -25,6 +25,13 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({ SlackSearchActions: ({ token }: { token: string }) => , })) +vi.mock('@/hooks/queries/personal-search-integrations', () => ({ + usePersonalSearchIntegrations: () => ({ + data: { available: [] }, + isPending: false, + isError: false, + }), +})) vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.integrations, })) 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 7a6a069fde9..26426ebd5f2 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 @@ -73,7 +73,7 @@ export function OrganizationSearchSlack() { description='Connect your workspace to ask questions in Slack.' trailing={ setWizard({})}> - Set up + {installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'} } /> diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index 9473b345fab..5b0e8be6948 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -63,6 +63,19 @@ export function SlackSearchSetupWizard({ } } + const shared = Boolean( + prepare.data?.sharedAppId && (!configuredAppId || configuredAppId === prepare.data.sharedAppId) + ) + + function installShared() { + oauth.mutate( + { organizationId, installationId, name, description, mode: 'shared' }, + { + onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl), + } + ) + } + function advance() { if (step === 'manifest') { setStep('credentials') @@ -86,6 +99,39 @@ export function SlackSearchSetupWizard({ } } + if (shared) + return ( + { + if (!open) onClose() + }} + srTitle='Install Sim Search' + > + + Install Sim Search + + + +

+ Ask Sim in DMs or mention it in a channel. Each member connects their own Slack + account to index the channels and direct messages they choose to connect. +

+
+ {error?.message} +
+ +
+ ) + return ( , userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise export function ensureWorkspaceAccountsGroup( workspaceId: string, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise export function ensureWorkspaceAccountsGroup( scope: ResourceScope, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise export async function ensureWorkspaceAccountsGroup( scopeInput: string | ResourceScope, userId: string, - option?: CredentialGroupOptionInput + option?: CredentialGroupOptionInput, + executor?: DbOrTx ): Promise { const scope = credentialGroupScope(scopeInput) if (option?.provider === 'slack') { @@ -267,7 +271,7 @@ export async function ensureWorkspaceAccountsGroup( } const preparedOption = option ? await buildOption(scope, { ...option, required: false }) : null let wasCreated = false - const row = await db.transaction(async (tx) => { + const provision = async (tx: DbOrTx) => { await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`search-accounts:${resourceScopeKey(scope)}`}, 0))` ) @@ -377,9 +381,10 @@ export async function ensureWorkspaceAccountsGroup( ) wasCreated = true return created - }) + } + const row = executor ? await provision(executor) : await db.transaction(provision) return { - ...(await toCredentialGroup(row, await listLinkedMcpServers(row.id))), + ...(await toCredentialGroup(row, await listLinkedMcpServers(row.id, executor))), created: wasCreated, } } diff --git a/apps/sim/lib/credential-groups/shared-slack-app.ts b/apps/sim/lib/credential-groups/shared-slack-app.ts new file mode 100644 index 00000000000..ce6fd46bf57 --- /dev/null +++ b/apps/sim/lib/credential-groups/shared-slack-app.ts @@ -0,0 +1,89 @@ +import { credentialGroup } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { + decryptCredentialGroupProviderConfiguration, + encryptCredentialGroupProviderConfiguration, +} from '@/lib/credential-groups/provider-configuration' +import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' +import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { DbOrTx } from '@/lib/db/types' + +/** Configures personal consent atomically with the authorized admin's bot installation. */ +export async function configureSharedSlackMemberApp( + tx: DbOrTx, + input: { + organizationId: string + userId: string + appId: string + teamId: string + } +) { + const container = await ensureWorkspaceAccountsGroup( + { kind: 'organization', organizationId: input.organizationId }, + input.userId, + undefined, + tx + ) + const [group] = await tx + .select() + .from(credentialGroup) + .where(eq(credentialGroup.id, container.id)) + .for('update') + .limit(1) + if (!group) throw new Error('Organization accounts configuration disappeared') + const configuration = await decryptCredentialGroupProviderConfiguration( + group.encryptedProviderConfiguration + ) + if ( + configuration.slack && + (configuration.slack.appId !== input.appId || configuration.slack.teamId !== input.teamId) + ) + throw new OrchestrationError( + 'conflict', + 'Remove the previous Slack source configuration before installing another app' + ) + const existing = group.options.find((option) => option.provider === 'slack') + const requiredScopes = [...SLACK_SEARCH_USER_SCOPES] + const authorizationAppId = `slack:${input.appId}:${input.teamId}` + const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes) + if ( + existing && + (existing.authorizationAppId !== authorizationAppId || existing.scopeVersion !== scopeVersion) + ) + throw new OrchestrationError( + 'conflict', + 'Remove the previous Slack connection configuration before switching Slack apps' + ) + const option = { + id: existing?.id ?? generateId(), + provider: 'slack', + label: 'Slack', + authorizationAppId, + requiredScopes, + scopeVersion, + required: false, + status: 'active' as const, + } + await tx + .update(credentialGroup) + .set({ + options: existing + ? group.options.map((entry) => (entry.id === existing.id ? option : entry)) + : [...group.options, option], + encryptedProviderConfiguration: await encryptCredentialGroupProviderConfiguration({ + ...configuration, + slack: { + source: 'slack_app', + appId: input.appId, + teamId: input.teamId, + scopes: requiredScopes, + verifiedAt: new Date().toISOString(), + }, + }), + updatedAt: new Date(), + }) + .where(eq(credentialGroup.id, group.id)) +} diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 0e2e0ae96cb..5b2863c9d7a 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -11,7 +11,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' @@ -29,6 +29,7 @@ import { } from '@/lib/credential-groups/slack-managed-user-scopes' import type { DbOrTx } from '@/lib/db/types' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' +import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' const logger = createLogger('SlackManagedUsers') const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000 @@ -504,8 +505,10 @@ export async function createSlackManagedUsersAttempt(params: { .where( and( eq(slackApp.id, params.appId), - eq(slackApp.organizationId, scope.organizationId), - eq(slackApp.kind, 'custom') + or( + and(eq(slackApp.organizationId, scope.organizationId), eq(slackApp.kind, 'custom')), + and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) + ) ) ) .limit(1) @@ -514,6 +517,7 @@ export async function createSlackManagedUsersAttempt(params: { 'Set up this organization’s Slack app first.', 'invalid_response' ) + await requireSlackSearchAppAvailable(configured.app.id) identity = { appId: configured.app.id, teamId: configured.teamId } clientId = configured.app.clientId clientSecret = (await decryptSecret(configured.app.encryptedClientSecret)).decrypted @@ -690,12 +694,18 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { .where( and( eq(slackApp.id, params.attempt.expectedAppId), - eq(slackApp.organizationId, params.attempt.organizationId), - eq(slackApp.kind, 'custom') + or( + and( + eq(slackApp.organizationId, params.attempt.organizationId), + eq(slackApp.kind, 'custom') + ), + and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) + ) ) ) .limit(1) .for('update') + if (app?.kind === 'shared') await requireSlackSearchAppAvailable(app.id) if ( !app || !params.attempt.appRevision || diff --git a/apps/sim/lib/internal/slack/search-client.ts b/apps/sim/lib/internal/slack/search-client.ts index 0b03ced53a2..ea432315f04 100644 --- a/apps/sim/lib/internal/slack/search-client.ts +++ b/apps/sim/lib/internal/slack/search-client.ts @@ -29,7 +29,11 @@ function requireSuccess(result: SlackApiResult) { } /** Verifies a workspace-installed bot and its granted scopes, never a user token. */ -export async function verifySlackSearchBot(accessToken: string, signal?: AbortSignal) { +export async function verifySlackSearchBot( + accessToken: string, + signal?: AbortSignal, + requiredScopes: readonly string[] = SLACK_SEARCH_SCOPES +) { const result = await requestSlackApi({ accessToken, method: 'auth.test', signal }) const auth = requireSuccess(result) const teamId = slackString(auth, 'team_id') @@ -40,7 +44,7 @@ export async function verifySlackSearchBot(accessToken: string, signal?: AbortSi 'Slack Search requires a bot installed in a single Slack workspace' ) } - const missing = SLACK_SEARCH_SCOPES.filter((scope) => !result.grantedScopes?.includes(scope)) + const missing = requiredScopes.filter((scope) => !result.grantedScopes?.includes(scope)) if (missing.length) throw new SlackSearchConfigurationError( `Reinstall the Slack bot with these scopes: ${missing.join(', ')}` diff --git a/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts b/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts index 9e2369fba14..740cfa9fc69 100644 --- a/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/slack-search-turns.integration.ts @@ -123,7 +123,10 @@ describe('durable Slack Search turns in PostgreSQL', () => { await db.$client.end() }) - function job(threadTs = '1000.000001', messageTs = '1000.000002'): SlackSearchJob { + function job( + threadTs = '1000.000001', + messageTs = '1000.000002' + ): SlackSearchJob & { message: SlackSearchJob['message'] & { messageTs: string } } { return { installationId, credentialId, diff --git a/apps/sim/lib/knowledge/application/personal-search-integrations.ts b/apps/sim/lib/knowledge/application/personal-search-integrations.ts index 735f5acd97f..1497c88983e 100644 --- a/apps/sim/lib/knowledge/application/personal-search-integrations.ts +++ b/apps/sim/lib/knowledge/application/personal-search-integrations.ts @@ -17,6 +17,7 @@ import { listSearchSources } from '@/lib/knowledge/application/search-sources' import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' import { listOrganizationSearchApprovals } from '@/lib/knowledge/search/integration-policy' import { getConnectorAccessAvailability, SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { findSharedSlackSearchInstallation } from '@/lib/slack-search/shared-app' export interface ListPersonalSearchIntegrationsInput { organizationId: string @@ -39,7 +40,7 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ .where(eq(user.id, userId)) .limit(1) if (!viewer) throw new OrchestrationError('forbidden', 'The current person is unavailable') - const [page, overview, approvals, access] = await Promise.all([ + const [page, overview, approvals, access, sharedSlack] = await Promise.all([ listSearchSources.execute({ principal, input }), readSearchSourceOverview.execute({ principal, @@ -47,6 +48,7 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ }), listOrganizationSearchApprovals(context.organizationId), resolveKnowledgeAccessAvailability(context), + findSharedSlackSearchInstallation(context.organizationId), ]) const deployment = new Map( getIntegrationAvailability().map((entry) => [entry.type.toLowerCase(), entry]) @@ -144,7 +146,7 @@ export const listPersonalSearchIntegrations = defineAuthorizedKnowledgeUseCase({ (connector) => !input.connectorId && (!input.connectorType || connector.type === input.connectorType) && - connector.type !== 'slack' && + (connector.type !== 'slack' || sharedSlack !== null) && (!configured.has(connector.type) || connector.setupFields.length > 0) && eligible(connector.type) ).map((connector) => ({ diff --git a/apps/sim/lib/knowledge/application/slack-search/assistant.ts b/apps/sim/lib/knowledge/application/slack-search/assistant.ts index 552a68ac710..edf24d5607d 100644 --- a/apps/sim/lib/knowledge/application/slack-search/assistant.ts +++ b/apps/sim/lib/knowledge/application/slack-search/assistant.ts @@ -54,7 +54,7 @@ import { SLACK_SEARCH_FAILED_ANSWER, SLACK_SEARCH_MAX_DURATION_SECONDS, } from '@/lib/slack-search/constants' -import type { SlackSearchJob } from '@/lib/slack-search/types' +import { type SlackSearchJob, slackSearchThreadTimestamp } from '@/lib/slack-search/types' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -204,7 +204,7 @@ export async function runSlackSearchAssistant( const responseStream = new SlackSearchAssistantStream({ token: secret.botToken, channel: job.message.channelId, - threadTs: job.message.threadTs ?? job.message.messageTs, + threadTs: slackSearchThreadTimestamp(job.message), slackUserId: job.message.userId, controller, registry: environmentContext.resolvedSecretTraceRegistry, diff --git a/apps/sim/lib/knowledge/application/slack-search/authorization.ts b/apps/sim/lib/knowledge/application/slack-search/authorization.ts index 0634034d4a8..37bc713ee26 100644 --- a/apps/sim/lib/knowledge/application/slack-search/authorization.ts +++ b/apps/sim/lib/knowledge/application/slack-search/authorization.ts @@ -5,6 +5,7 @@ import { findSlackSearchInstallation, loadSlackSearchCredential, } from '@/lib/knowledge/application/slack-search/repository' +import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' export function requireSlackInstallationPrincipal( principal: Principal @@ -38,6 +39,7 @@ export async function authorizeSlackSearchInstallation( throw new OrchestrationError('forbidden', 'Slack Search binding is no longer valid') } await requireOrganizationSearchAvailable(installation.organizationId) + await requireSlackSearchAppAvailable(installation.appId) const secret = await loadSlackSearchCredential( installation.credentialId, installation.organizationId diff --git a/apps/sim/lib/knowledge/application/slack-search/commands.test.ts b/apps/sim/lib/knowledge/application/slack-search/commands.test.ts new file mode 100644 index 00000000000..3660fb19fb1 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/commands.test.ts @@ -0,0 +1,90 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ authorize: vi.fn(), receive: vi.fn() })) +vi.mock('@/lib/knowledge/application/slack-search/authorization', () => ({ + requireSlackInstallationPrincipal: (p: { kind: string }) => { + if (p.kind !== 'slack_installation') throw new Error('principal') + }, + authorizeSlackSearchInstallation: m.authorize, +})) +vi.mock('@/lib/knowledge/application/slack-search/process-message', () => ({ + receiveSlackSearchMessage: { execute: m.receive }, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://www.sim.ai' })) +vi.mock('@/lib/sim-search/connectors', () => ({ + SEARCH_CONNECTORS: [{ type: 'slack', providerId: 'slack' }], +})) + +import { receiveSlackSearchCommand } from '@/lib/knowledge/application/slack-search/commands' +import { slackSearchCommandEventId, slackSearchCommandSchema } from '@/lib/slack-search/commands' + +const input = { + api_app_id: 'A1', + team_id: 'T1', + user_id: 'U1', + channel_id: 'C1', + trigger_id: 'trigger.1', + command: '/sim-search', + text: 'release notes', +} as const +const principal = { + kind: 'slack_installation', + appId: 'A1', + teamId: 'T1', + eventId: slackSearchCommandEventId(input), + credentialId: 'c1', + credentialVersion: 'v1', + receivedAt: new Date(), +} as const +beforeEach(() => { + vi.clearAllMocks() + m.authorize.mockResolvedValue({ installation: { organizationId: 'org' } }) + m.receive.mockResolvedValue('turn') +}) +describe('Slack commands', () => { + it('acknowledges durable intake without attempting a Slack send', async () => { + await expect(receiveSlackSearchCommand.execute({ principal, input })).resolves.toMatchObject({ + response_type: 'ephemeral', + turnId: 'turn', + }) + expect(m.receive).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ + command: '/sim-search', + messageTs: null, + channelId: 'C1', + userId: 'U1', + query: 'release notes', + }), + }) + }) + it('uses stable deduplication for retries and rejects forged user scope', async () => { + expect(slackSearchCommandEventId({ ...input })).toBe(principal.eventId) + await expect( + receiveSlackSearchCommand.execute({ principal, input: { ...input, user_id: 'U2' } }) + ).rejects.toThrow('verified identity') + expect(m.receive).not.toHaveBeenCalled() + }) + it('returns an environment-correct personal connection link without OAuth state', async () => { + const result = await receiveSlackSearchCommand.execute({ + principal, + input: { ...input, command: '/sim-connect', text: 'slack' }, + }) + expect(result.text).toBe( + '' + ) + expect(m.receive).not.toHaveBeenCalled() + }) + it('does not queue commands on a disabled installation', async () => { + m.authorize.mockResolvedValue(null) + await receiveSlackSearchCommand.execute({ principal, input }) + expect(m.receive).not.toHaveBeenCalled() + }) + it('does not accept unsupported commands or oversized invocations', () => { + expect(slackSearchCommandSchema.safeParse({ ...input, command: '/other' }).success).toBe(false) + expect(slackSearchCommandSchema.safeParse({ ...input, text: 'x'.repeat(40001) }).success).toBe( + false + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/commands.ts b/apps/sim/lib/knowledge/application/slack-search/commands.ts new file mode 100644 index 00000000000..151d9c00665 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/commands.ts @@ -0,0 +1,85 @@ +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { + authorizeSlackSearchInstallation, + requireSlackInstallationPrincipal, +} from '@/lib/knowledge/application/slack-search/authorization' +import { receiveSlackSearchMessage } from '@/lib/knowledge/application/slack-search/process-message' +import { organizationRoutes } from '@/lib/navigation/paths' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { type SlackSearchCommand, slackSearchCommandEventId } from '@/lib/slack-search/commands' + +const operation = Object.freeze({ + id: 'knowledge.slack.command', + capability: 'knowledge.use', + principalKinds: ['slack_installation'] as const, +}) + +/** Commits search intake before acknowledging; the worker creates the real private Slack thread. */ +export const receiveSlackSearchCommand: OperationUseCase< + typeof operation, + SlackSearchCommand, + { response_type: 'ephemeral'; text: string; turnId?: string } +> = { + operation, + async execute({ principal, input }) { + requireSlackInstallationPrincipal(principal) + if ( + principal.appId !== input.api_app_id || + principal.teamId !== input.team_id || + principal.eventId !== slackSearchCommandEventId(input) + ) + throw new OrchestrationError( + 'forbidden', + 'Slack command does not match its verified identity' + ) + const context = await authorizeSlackSearchInstallation(principal) + if (!context) + return { + response_type: 'ephemeral', + text: 'An admin needs to enable Sim Search for this Slack workspace.', + } + if (input.command === '/sim-connect') { + const url = new URL( + organizationRoutes(context.installation.organizationId).integrations, + getBaseUrl() + ) + const requested = input.text.trim() + if (requested) { + const provider = SEARCH_CONNECTORS.find( + (entry) => entry.type === requested || entry.providerId === requested + ) + if (!provider) + return { + response_type: 'ephemeral', + text: 'Choose an integration in Sim to connect your account.', + } + url.searchParams.set('connectorType', provider.type) + } + return { response_type: 'ephemeral', text: `<${url.href}|Connect your sources in Sim>` } + } + const query = input.text.trim() + if (!query) + return { response_type: 'ephemeral', text: 'Use /sim-search followed by your question.' } + const turnId = await receiveSlackSearchMessage.execute({ + principal, + input: { + appId: input.api_app_id, + teamId: input.team_id, + eventId: principal.eventId, + userId: input.user_id, + channelId: input.channel_id, + command: '/sim-search', + messageTs: null, + query: query.length > 2000 ? '' : query, + queryTooLong: query.length > 2000, + }, + }) + return { + response_type: 'ephemeral', + text: 'I’ll answer in a private Sim Search DM thread.', + turnId, + } + }, +} diff --git a/apps/sim/lib/knowledge/application/slack-search/installations.test.ts b/apps/sim/lib/knowledge/application/slack-search/installations.test.ts index 2f1a04e43ab..947f2e9c657 100644 --- a/apps/sim/lib/knowledge/application/slack-search/installations.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/installations.test.ts @@ -129,7 +129,7 @@ describe('Slack Search installation configuration', () => { id: 'install1', }) expect(mocks.credential).toHaveBeenCalledWith('cred1', 'org1') - expect(mocks.verifyBot).toHaveBeenCalledWith('secret-token', expect.any(AbortSignal)) + expect(mocks.verifyBot).toHaveBeenCalledWith('secret-token', expect.any(AbortSignal), undefined) expect(mocks.audit).toHaveBeenCalledWith( expect.objectContaining({ actorId: 'admin', diff --git a/apps/sim/lib/knowledge/application/slack-search/installations.ts b/apps/sim/lib/knowledge/application/slack-search/installations.ts index 04145d4ebc8..cd53db31d22 100644 --- a/apps/sim/lib/knowledge/application/slack-search/installations.ts +++ b/apps/sim/lib/knowledge/application/slack-search/installations.ts @@ -16,6 +16,11 @@ import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { loadSlackSearchCredential } from '@/lib/knowledge/application/slack-search/repository' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { slackBotCredentialVersion } from '@/lib/slack-search/app-configuration' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' +import { + readSharedSlackSearchApp, + requireSlackSearchAppAvailable, +} from '@/lib/slack-search/shared-app' interface OrganizationInput { organizationId: string @@ -34,12 +39,13 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ resolveKnowledgeOrganizationContext(input), async execute({ context }) { await requireOrganizationSearchAvailable(context.organizationId) - const [installations, bots] = await Promise.all([ + const [installations, bots, sharedApp] = await Promise.all([ db .select({ id: slackSearchInstallation.id, credentialId: slackSearchInstallation.credentialId, appId: slackSearchInstallation.appId, + appKind: slackApp.kind, teamId: slackSearchInstallation.teamId, teamName: slackSearchInstallation.teamName, enabled: slackSearchInstallation.enabled, @@ -48,6 +54,7 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ credentialVersion: slackSearchInstallation.credentialVersion, }) .from(slackSearchInstallation) + .leftJoin(slackApp, eq(slackApp.id, slackSearchInstallation.slackAppId)) .where(eq(slackSearchInstallation.organizationId, context.organizationId)) .limit(101), db @@ -67,6 +74,7 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ ) ) .limit(101), + readSharedSlackSearchApp(), ]) if (installations.length > 100 || bots.length > 100) throw new OrchestrationError( @@ -74,10 +82,12 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ 'Slack Search supports up to 100 bots per organization' ) return { + sharedAppAvailable: Boolean(sharedApp), installations: installations.map(({ credentialVersion, ...installation }) => { const bot = bots.find((bot) => bot.id === installation.credentialId) return { ...installation, + appKind: installation.appKind ?? 'custom', needsValidation: !bot?.encryptedKey || slackBotCredentialVersion(bot.encryptedKey, bot.appRevision ?? undefined) !== @@ -101,7 +111,11 @@ export const configureSlackSearchInstallation = defineAuthorizedKnowledgeUseCase let identity: Awaited> | undefined if (secret) { try { - identity = await verifySlackSearchBot(secret.botToken, AbortSignal.timeout(10_000)) + identity = await verifySlackSearchBot( + secret.botToken, + AbortSignal.timeout(10_000), + secret.appKind === 'shared' ? SLACK_SHARED_SEARCH_BOT_SCOPES : undefined + ) } catch (error) { if ( error instanceof SlackSearchProviderError || @@ -137,6 +151,8 @@ export const configureSlackSearchInstallation = defineAuthorizedKnowledgeUseCase const [app] = current.slackAppId ? await tx.select().from(slackApp).where(eq(slackApp.id, current.slackAppId)).limit(1) : [] + if (input.enabled && current.slackAppId) + await requireSlackSearchAppAvailable(current.slackAppId) if (current.slackAppId && !app) throw new Error('Slack app configuration is missing') if ( secret && diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts new file mode 100644 index 00000000000..620082a27a8 --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts @@ -0,0 +1,93 @@ +/** @vitest-environment node */ +import { slackSearchInstallation } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ app: vi.fn() })) +vi.mock('@/lib/slack-search/app-configuration', () => ({ loadSlackAppConfiguration: m.app })) + +import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle' + +const principal = { + kind: 'slack_app', + appId: 'A1', + appRevision: 'r1', + receivedAt: new Date(), +} as const +const input = { + type: 'event_callback', + api_app_id: 'A1', + team_id: 'T1', + event_id: 'Ev1', + event_time: Math.floor(Date.now() / 1000), + event: { type: 'app_uninstalled' }, +} as const +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + m.app.mockResolvedValue({ + app: { id: 'A1', kind: 'shared', revision: 'r1', organizationId: null }, + }) + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + botUserId: 'UBOT', + updatedAt: new Date(1), + }, + ]) +}) +describe('Slack access revocation', () => { + it('disables the bot and cancels queued and running work on uninstall', async () => { + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false, lastOutcome: 'app_uninstalled' }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'cancelled', outcome: 'access_revoked' }) + ) + }) + it('invalidates member grants without disabling the bot for personal revocation', async () => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ managedOauthStatus: 'needs_reauth' }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ lastOutcome: 'tokens_revoked' }) + ) + expect(dbChainMockFns.set.mock.calls.some(([value]) => 'enabled' in value)).toBe(false) + }) + it.each([{ appId: 'A2' }, { receivedAt: new Date(0) }, { receivedAt: new Date(Number.NaN) }])( + 'rejects invalid verified authority %#', + async (change) => { + await expect( + revokeSlackSearchAccess.execute({ principal: { ...principal, ...change }, input }) + ).rejects.toThrow('authority') + expect(m.app).not.toHaveBeenCalled() + } + ) + it('does nothing when that app/workspace has no binding', async () => { + resetDbChainMock() + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('does not revoke a replacement installation because of a delayed uninstall', async () => { + resetDbChainMock() + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + updatedAt: new Date(Date.now() + 1000), + }, + ]) + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.set.mock.calls.some(([value]) => 'enabled' in value)).toBe(false) + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts new file mode 100644 index 00000000000..af9ae40c41a --- /dev/null +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts @@ -0,0 +1,116 @@ +import { db } from '@sim/db' +import { credential, slackSearchInstallation, slackSearchTurn } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, lte, or } from 'drizzle-orm' +import { z } from 'zod' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration' + +const id = z.string().min(1).max(200) +export const slackSearchLifecycleSchema = z.object({ + type: z.literal('event_callback'), + api_app_id: id, + team_id: id, + event_id: id, + event_time: z.number().int().positive(), + event: z.discriminatedUnion('type', [ + z.object({ type: z.literal('app_uninstalled') }), + z.object({ + type: z.literal('tokens_revoked'), + tokens: z.object({ + oauth: z.array(id).max(10000).optional(), + bot: z.array(id).max(10000).optional(), + }), + }), + ]), +}) +const operation = Object.freeze({ + id: 'knowledge.slack.revoke', + capability: 'none', + principalKinds: ['slack_app'] as const, +}) + +/** Revocations bypass the rollout gate and enabled check so disabling access never blocks cleanup. */ +export const revokeSlackSearchAccess: OperationUseCase< + typeof operation, + z.infer, + void +> = { + operation, + async execute({ principal, input }) { + if ( + principal.kind !== 'slack_app' || + principal.appId !== input.api_app_id || + !Number.isFinite(principal.receivedAt.getTime()) || + Date.now() - principal.receivedAt.getTime() > 60_000 || + principal.receivedAt.getTime() > Date.now() + ) + throw new OrchestrationError('forbidden', 'Verified Slack lifecycle authority is required') + const app = await loadSlackAppConfiguration(principal.appId) + if (!app || app.app.revision !== principal.appRevision) + throw new OrchestrationError('forbidden', 'Slack app configuration changed') + const occurredAt = new Date(input.event_time * 1000) + if (occurredAt.getTime() > Date.now() + 60_000) throw new Error('Invalid Slack revocation time') + await db.transaction(async (tx) => { + const [installation] = await tx + .select() + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.slackAppId, principal.appId), + eq(slackSearchInstallation.teamId, input.team_id) + ) + ) + .for('update') + .limit(1) + if (!installation) return + if (app.app.kind === 'custom' && app.app.organizationId !== installation.organizationId) + throw new Error('Slack installation ownership is inconsistent') + const uninstall = input.event.type === 'app_uninstalled' + const revokedUsers = + input.event.type === 'tokens_revoked' ? (input.event.tokens.oauth ?? []) : [] + const revokeBot = + uninstall || + (input.event.type === 'tokens_revoked' && + (input.event.tokens.bot ?? []).includes(installation.botUserId)) + if (uninstall || revokedUsers.length) { + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) + .where( + and( + eq(credential.organizationId, installation.organizationId), + eq(credential.type, 'managed_oauth'), + eq( + credential.authorizationAppId, + `slack:${installation.appId}:${installation.teamId}` + ), + or(isNull(credential.grantedAt), lte(credential.grantedAt, occurredAt)), + ...(uninstall ? [] : [inArray(credential.providerSubjectId, revokedUsers)]) + ) + ) + } + if (installation.updatedAt > occurredAt) return + if (!revokeBot && !revokedUsers.length) return + await tx + .update(slackSearchInstallation) + .set({ + revision: generateId(), + ...(revokeBot ? { enabled: false } : {}), + lastOutcome: uninstall ? 'app_uninstalled' : 'tokens_revoked', + updatedAt: new Date(), + }) + .where(eq(slackSearchInstallation.id, installation.id)) + await tx + .update(slackSearchTurn) + .set({ status: 'cancelled', outcome: 'access_revoked', updatedAt: new Date() }) + .where( + and( + eq(slackSearchTurn.installationId, installation.id), + inArray(slackSearchTurn.status, ['pending', 'running']) + ) + ) + }) + }, +} diff --git a/apps/sim/lib/knowledge/application/slack-search/mention.test.ts b/apps/sim/lib/knowledge/application/slack-search/mention.test.ts index ca430d22491..036d8ca82cf 100644 --- a/apps/sim/lib/knowledge/application/slack-search/mention.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/mention.test.ts @@ -98,3 +98,48 @@ describe('private Slack mention roots', () => { } ) }) + +it('creates a real DM root for a slash command and never uses its trigger ID as a timestamp', async () => { + const job = slackSearchJobSchema.parse({ + installationId: 'i1', + revision: 'r1', + credentialId: 'c1', + credentialVersion: 'v1', + receivedAt: principal.receivedAt.getTime(), + message: { + appId: 'A1', + teamId: 'T1', + eventId: 'Ev1', + channelId: 'C1', + userId: 'U1', + messageTs: null, + command: '/sim-search', + query: 'Find release notes', + queryTooLong: false, + }, + }) + queueTableRows(schemaMock.slackSearchInstallation, [ + { enabled: true, revision: 'r1', credentialVersion: 'v1' }, + ]) + queueTableRows(schemaMock.slackSearchTurn, [ + { + status: 'running', + leaseId: 'lease1', + leaseExpiresAt: new Date(Date.now() + 60000), + payload: job, + }, + ]) + const routed = await routeSlackSearchMentionToDm(principal, { + job, + turnId: 'turn1', + leaseId: 'lease1', + signal: new AbortController().signal, + }) + expect(routed.message).toMatchObject({ + channelId: 'D1', + messageTs: '1800000000.2', + threadTs: '1800000000.2', + }) + expect(mocks.post).toHaveBeenCalledOnce() + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ payload: routed })) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/mention.ts b/apps/sim/lib/knowledge/application/slack-search/mention.ts index 63b9ba9f4aa..e912b5cedcc 100644 --- a/apps/sim/lib/knowledge/application/slack-search/mention.ts +++ b/apps/sim/lib/knowledge/application/slack-search/mention.ts @@ -19,10 +19,10 @@ export async function routeSlackSearchMentionToDm( input: { job: SlackSearchJob; turnId: string; leaseId: string; signal: AbortSignal } ): Promise { const { job, turnId, leaseId, signal } = input - if (job.message.channelId.startsWith('D')) return job + const command = job.message.command === '/sim-search' && job.message.messageTs === null + if (job.message.channelId.startsWith('D') && !command) return job if ( - !job.message.origin || - job.message.origin.channelId !== job.message.channelId || + (!command && (!job.message.origin || job.message.origin.channelId !== job.message.channelId)) || principal.eventId !== job.message.eventId ) throw new OrchestrationError('forbidden', 'Slack mention identity is inconsistent') @@ -100,7 +100,12 @@ export async function routeSlackSearchMentionToDm( throw new Error('Could not create the private Slack question thread') const routed = slackSearchJobSchema.parse({ ...job, - message: { ...job.message, channelId, threadTs }, + message: { + ...job.message, + channelId, + threadTs, + messageTs: job.message.messageTs ?? threadTs, + }, }) await tx .update(slackSearchTurn) diff --git a/apps/sim/lib/knowledge/application/slack-search/onboarding.ts b/apps/sim/lib/knowledge/application/slack-search/onboarding.ts index 5fd5eb22994..84602d24549 100644 --- a/apps/sim/lib/knowledge/application/slack-search/onboarding.ts +++ b/apps/sim/lib/knowledge/application/slack-search/onboarding.ts @@ -38,7 +38,11 @@ import { readSlackSearchOnboardingState, storeSlackSearchOnboardingState, } from '@/lib/slack-search/onboarding-state' -import { type SlackSearchJob, slackSearchJobSchema } from '@/lib/slack-search/types' +import { + type SlackSearchJob, + slackSearchJobSchema, + slackSearchThreadTimestamp, +} from '@/lib/slack-search/types' export const slackSearchOnboardingOperations = { /** @@ -84,7 +88,7 @@ export async function sendSlackSearchOnboarding( httpMethod: 'GET', query: { channel: job.message.channelId, - message_ts: job.message.threadTs ?? job.message.messageTs, + message_ts: slackSearchThreadTimestamp(job.message), }, signal, }) @@ -99,7 +103,7 @@ export async function sendSlackSearchOnboarding( slackUrl.password ) throw new Error('Slack returned an invalid question link') - slackUrl.searchParams.set('thread_ts', job.message.threadTs ?? job.message.messageTs) + slackUrl.searchParams.set('thread_ts', slackSearchThreadTimestamp(job.message)) slackUrl.searchParams.set('cid', job.message.channelId) const token = await storeSlackSearchOnboardingState({ turnId, @@ -146,7 +150,7 @@ export async function sendSlackSearchOnboarding( context.secret.botToken, { ...message, - thread_ts: job.message.threadTs ?? job.message.messageTs, + thread_ts: slackSearchThreadTimestamp(job.message), unfurl_links: false, unfurl_media: false, }, @@ -163,7 +167,7 @@ export async function sendSlackSearchOnboarding( context.secret.botToken, { channel: job.message.channelId, - thread_ts: job.message.threadTs ?? job.message.messageTs, + thread_ts: slackSearchThreadTimestamp(job.message), text: 'I don’t have any sources I can search for you yet. Check the “Connect sources” message in our DM to get set up, then retry this question.', unfurl_links: false, unfurl_media: false, @@ -262,7 +266,7 @@ async function resolveOnboarding(principal: Principal, token: string) { const conversationKey = slackSearchConversationKey( job.installationId, job.message.channelId, - job.message.threadTs ?? job.message.messageTs + slackSearchThreadTimestamp(job.message) ) if (turn.conversationKey !== conversationKey) throw new OrchestrationError('forbidden', 'The Slack conversation binding changed') @@ -281,7 +285,7 @@ async function resolveOnboarding(principal: Principal, token: string) { binding.installationId !== job.installationId || binding.slackUserId !== job.message.userId || binding.channelId !== job.message.channelId || - binding.threadTs !== (job.message.threadTs ?? job.message.messageTs) + binding.threadTs !== slackSearchThreadTimestamp(job.message) ) throw new OrchestrationError('forbidden', 'The Slack thread belongs to a different account') } @@ -339,7 +343,7 @@ export const retrySlackSearchOnboarding: OperationUseCase< message: { ...resolved.job.message, eventId: resolved.retryEventId, - threadTs: resolved.job.message.threadTs ?? resolved.job.message.messageTs, + threadTs: slackSearchThreadTimestamp(resolved.job.message), messageTs: `${Math.floor(now / 1000)}.${String((now % 1000) * 1000).padStart(6, '0')}`, }, }, 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 3483d35a0f2..e5b38ee3457 100644 --- a/apps/sim/lib/knowledge/application/slack-search/process-message.ts +++ b/apps/sim/lib/knowledge/application/slack-search/process-message.ts @@ -16,6 +16,7 @@ import { import { SLACK_SEARCH_QUERY_TOO_LONG } from '@/lib/slack-search/constants' import { slackSearchReply } from '@/lib/slack-search/messages' import type { SlackSearchJob, SlackSearchMessage } from '@/lib/slack-search/types' +import { slackSearchThreadTimestamp } from '@/lib/slack-search/types' const receiveOperation = Object.freeze({ id: 'knowledge.slack.receive', @@ -43,7 +44,7 @@ function requireMessageBinding(principal: SlackInstallationPrincipal, message: S export const receiveSlackSearchMessage: OperationUseCase< typeof receiveOperation, SlackSearchMessage, - void + string | undefined > = { operation: receiveOperation, async execute({ principal, input }) { @@ -64,7 +65,8 @@ export const receiveSlackSearchMessage: OperationUseCase< receivedAt: principal.receivedAt.getTime(), message: { ...input, query }, }) - await dispatchSlackSearchTurn(turnId) + if (!input.command) await dispatchSlackSearchTurn(turnId) + return turnId }, } @@ -103,7 +105,7 @@ export const respondToSlackSearchMessage: OperationUseCase< const response = await postSlackMessage( context.secret.botToken, slackSearchReply( - { ...job.message, threadTs: job.message.threadTs ?? job.message.messageTs }, + { ...job.message, threadTs: slackSearchThreadTimestamp(job.message) }, SLACK_SEARCH_QUERY_TOO_LONG ), AbortSignal.any([input.controller.signal, AbortSignal.timeout(10_000)]) diff --git a/apps/sim/lib/knowledge/application/slack-search/repository.ts b/apps/sim/lib/knowledge/application/slack-search/repository.ts index 82038fb9e71..0162dd8defc 100644 --- a/apps/sim/lib/knowledge/application/slack-search/repository.ts +++ b/apps/sim/lib/knowledge/application/slack-search/repository.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, slackSearchInstallation } from '@sim/db/schema' +import { credential, slackApp, slackSearchInstallation } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getSlackBotCredential } from '@/lib/oauth/credential-service' @@ -9,8 +9,12 @@ export type SlackSearchInstallation = typeof slackSearchInstallation.$inferSelec export async function loadSlackSearchCredential(credentialId: string, organizationId: string) { const [row] = await db - .select() + .select({ + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + appKind: slackApp.kind, + }) .from(credential) + .leftJoin(slackApp, eq(slackApp.id, credential.slackAppId)) .where( and( eq(credential.id, credentialId), @@ -25,7 +29,7 @@ export async function loadSlackSearchCredential(credentialId: string, organizati const secret = await getSlackBotCredential(credentialId) if (!secret?.signingSecret) throw new OrchestrationError('validation', 'Reconnect this bot using Slack Search setup') - return { ...secret, version: secret.credentialVersion } + return { ...secret, appKind: row.appKind, version: secret.credentialVersion } } export async function findSlackSearchInstallation(credentialId: string) { 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 2fea4eba5b1..dbeeb74a885 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts @@ -13,7 +13,9 @@ const m = vi.hoisted(() => ({ set: vi.fn(), audit: vi.fn(), baseUrl: vi.fn(), + shared: vi.fn(), })) +vi.mock('@/lib/slack-search/shared-app', () => ({ readSharedSlackSearchApp: m.shared })) vi.mock('@sim/audit', () => ({ AuditAction: { ORGANIZATION_UPDATED: 'organization.updated' }, AuditResourceType: { ORGANIZATION: 'organization' }, @@ -84,6 +86,7 @@ const complete = () => completeSlackSearchSetup.execute({ principal, input: { state: 'state', code: 'code' } }) beforeEach(() => { vi.clearAllMocks() + m.shared.mockResolvedValue(null) m.baseUrl.mockReturnValue('https://sim.test') m.membership.mockResolvedValue([{ role: 'admin' }]) m.rows.mockReset().mockResolvedValue([]) @@ -274,3 +277,12 @@ describe('Search OAuth installation', () => { expect(new URL(result.authorizationUrl).searchParams.has('user_scope')).toBe(false) }) }) + +it('rejects a shared-app callback if the global configuration was disabled or rotated', async () => { + m.consume.mockResolvedValue({ ...attempt, sharedApp: { id: 'ASHARED', revision: 'app-rev' } }) + await expect(complete()).rejects.toThrow('configuration changed') + expect(m.exchange).not.toHaveBeenCalled() + m.shared.mockResolvedValue({ id: 'ASHARED', revision: 'new-rev' }) + await expect(complete()).rejects.toThrow() + expect(m.exchange).not.toHaveBeenCalled() +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index dafec925b36..be9213e28dd 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -11,6 +11,7 @@ import { adoptOrganizationSlackMemberApp, loadOrganizationSlackMemberApps, } from '@/lib/credential-groups/organization-slack-app' +import { configureSharedSlackMemberApp } from '@/lib/credential-groups/shared-slack-app' import { exchangeSlackBotAuthorization } from '@/lib/internal/slack/oauth' import { SlackSearchConfigurationError, @@ -23,12 +24,13 @@ import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' import { slackBotCredentialVersion } from '@/lib/slack-search/app-configuration' -import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' +import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' import { createSlackSearchManifest, SLACK_SEARCH_CALLBACK_PATH } from '@/lib/slack-search/manifest' import { consumeSlackSearchOAuthAttempt, storeSlackSearchOAuthAttempt, } from '@/lib/slack-search/oauth-state' +import { readSharedSlackSearchApp } from '@/lib/slack-search/shared-app' interface PrepareInput { organizationId: string @@ -36,6 +38,7 @@ interface PrepareInput { description: string } interface StartInput extends PrepareInput { + mode?: 'custom' | 'shared' installationId?: string clientId?: string clientSecret?: string @@ -83,7 +86,9 @@ export const prepareSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ getBaseUrl(), member.scopes ) + const sharedApp = await readSharedSlackSearchApp() return { + sharedAppId: sharedApp?.id ?? null, manifest: JSON.stringify(manifest, null, 2), existingApp: member.app, createAppUrl: `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(JSON.stringify(manifest))}`, @@ -114,19 +119,32 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ : [] if (input.installationId && !installation) throw new OrchestrationError('not_found', 'Slack Search installation not found') - const [app] = installation?.slackAppId + const [savedApp] = installation?.slackAppId ? await db .select() .from(slackApp) - .where( - and( - eq(slackApp.id, installation.slackAppId), - eq(slackApp.organizationId, context.organizationId), - eq(slackApp.kind, 'custom') - ) - ) + .where(and(eq(slackApp.id, installation.slackAppId))) .limit(1) : [] + const shared = input.mode === 'shared' + if (savedApp && (savedApp.kind === 'shared') !== shared) + throw new OrchestrationError( + 'conflict', + 'Remove the existing installation before switching Slack apps' + ) + if (savedApp?.kind === 'custom' && savedApp.organizationId !== context.organizationId) + throw new OrchestrationError('forbidden', 'Slack app ownership changed') + const app = shared ? await readSharedSlackSearchApp() : savedApp + if (shared && (!app || input.clientId || input.clientSecret || input.signingSecret)) + throw new OrchestrationError( + 'validation', + 'Shared Slack app setup is unavailable or contains custom credentials' + ) + if (shared && member.app && member.app.appId !== app?.id) + throw new OrchestrationError( + 'conflict', + 'Remove the previous Slack source configuration before switching apps; members must reconnect' + ) const clientId = input.clientId ?? app?.clientId const encryptedClientSecret = input.clientSecret ? (await encryptSecret(input.clientSecret)).encrypted @@ -141,6 +159,7 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ ) const redirectUri = new URL(SLACK_SEARCH_CALLBACK_PATH, origin).href const state = await storeSlackSearchOAuthAttempt({ + ...(shared && app ? { sharedApp: { id: app.id, revision: app.revision } } : {}), userId: principal.userId, sessionId: principal.sessionId, organizationId: context.organizationId, @@ -168,7 +187,7 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ const url = new URL('https://slack.com/oauth/v2/authorize') url.search = new URLSearchParams({ client_id: clientId, - scope: SLACK_SEARCH_SCOPES.join(','), + scope: (shared ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES).join(','), redirect_uri: redirectUri, state, ...(installation ? { team: installation.teamId } : {}), @@ -200,6 +219,14 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ ) await requireOrganizationSearchAvailable(context.organizationId) const { attempt } = context + if (attempt.sharedApp) { + const app = await readSharedSlackSearchApp() + if (app?.id !== attempt.sharedApp.id || app.revision !== attempt.sharedApp.revision) + throw new OrchestrationError( + 'conflict', + 'Shared Slack app configuration changed. Start again.' + ) + } const { decrypted: clientSecret } = await decryptSecret(attempt.encryptedClientSecret) const grant = await exchangeSlackBotAuthorization({ clientId: attempt.clientId, @@ -209,7 +236,11 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ }) let identity: Awaited> try { - identity = await verifySlackSearchBot(grant.access_token, AbortSignal.timeout(10_000)) + identity = await verifySlackSearchBot( + grant.access_token, + AbortSignal.timeout(10_000), + attempt.sharedApp ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES + ) } catch (error) { if ( error instanceof SlackSearchConfigurationError || @@ -219,6 +250,7 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ throw error } if ( + (attempt.sharedApp && identity.appId !== attempt.sharedApp.id) || identity.appId !== grant.app_id || identity.teamId !== grant.team.id || identity.botUserId !== grant.bot_user_id @@ -270,8 +302,13 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ .for('update') .limit(1) if ( - existingApp && - (existingApp.kind !== 'custom' || existingApp.organizationId !== context.organizationId) + attempt.sharedApp + ? !existingApp || + existingApp.kind !== 'shared' || + existingApp.organizationId !== null || + existingApp.revision !== attempt.sharedApp.revision + : existingApp && + (existingApp.kind !== 'custom' || existingApp.organizationId !== context.organizationId) ) throw new OrchestrationError( 'conflict', @@ -329,7 +366,7 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ 'conflict', 'This Slack workspace already has an active Search installation' ) - const appRevision = generateId() + const appRevision = attempt.sharedApp?.revision ?? generateId() const appValues = { id: identity.appId, kind: 'custom' as const, @@ -340,10 +377,11 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ revision: appRevision, updatedAt: new Date(), } - await tx - .insert(slackApp) - .values(appValues) - .onConflictDoUpdate({ target: slackApp.id, set: appValues }) + if (!attempt.sharedApp) + await tx + .insert(slackApp) + .values(appValues) + .onConflictDoUpdate({ target: slackApp.id, set: appValues }) await adoptOrganizationSlackMemberApp( tx, context.organizationId, @@ -351,6 +389,13 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ identity.teamId, attempt.clientId ) + if (attempt.sharedApp) + await configureSharedSlackMemberApp(tx, { + organizationId: context.organizationId, + userId: principal.userId, + appId: identity.appId, + teamId: identity.teamId, + }) const credentialId = existing?.credentialId ?? generateId() const credentialValues = { slackAppId: identity.appId, diff --git a/apps/sim/lib/knowledge/application/slack-search/stop.ts b/apps/sim/lib/knowledge/application/slack-search/stop.ts index b2918094d64..86813ee3cf6 100644 --- a/apps/sim/lib/knowledge/application/slack-search/stop.ts +++ b/apps/sim/lib/knowledge/application/slack-search/stop.ts @@ -23,7 +23,7 @@ import { slackSearchConversation, slackSearchConversationKey, } from '@/lib/slack-search/conversation' -import { slackSearchJobSchema } from '@/lib/slack-search/types' +import { slackSearchJobSchema, slackSearchThreadTimestamp } from '@/lib/slack-search/types' import { setSlackAgentSessionStatus } from '@/lib/webhooks/slack-agent-api' const timestamp = z.string().regex(/^\d{1,12}\.\d{1,9}$/) @@ -81,7 +81,7 @@ export const stopSlackSearchThread: OperationUseCase< slackSearchConversationKey( job.installationId, job.message.channelId, - job.message.threadTs ?? job.message.messageTs + slackSearchThreadTimestamp(job.message) ) !== conversationKey ) throw new Error('Persisted Slack event has an inconsistent conversation identity') diff --git a/apps/sim/lib/knowledge/application/slack-search/turns.ts b/apps/sim/lib/knowledge/application/slack-search/turns.ts index b235b739d35..2c9cf165fa4 100644 --- a/apps/sim/lib/knowledge/application/slack-search/turns.ts +++ b/apps/sim/lib/knowledge/application/slack-search/turns.ts @@ -29,6 +29,10 @@ export function slackSearchTurnOutboxId(turnId: string) { /** Commits the turn and its retryable dispatch together; only dispatch, never execution, is retried. */ export async function persistSlackSearchTurn(job: SlackSearchJob, expectedUserId?: string) { return db.transaction(async (tx) => { + if (job.message.command) { + await tx.execute(sql`SET LOCAL statement_timeout = '1500ms'`) + await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`) + } const [installation] = await tx .select() .from(slackSearchInstallation) @@ -55,33 +59,34 @@ export async function persistSlackSearchTurn(job: SlackSearchJob, expectedUserId ) ) .limit(1) - const conversation = slackSearchConversation(job) - const conversationKey = slackSearchConversationKey( - installation.id, - conversation.channelId, - conversation.threadTs - ) - if (duplicate && job.message.origin) { + const conversation = job.message.messageTs === null ? null : slackSearchConversation(job) + const conversationKey = conversation + ? slackSearchConversationKey(installation.id, conversation.channelId, conversation.threadTs) + : `slack-command:${installation.id}:${job.message.eventId}` + if (duplicate && (job.message.origin || job.message.command)) { const original = slackSearchJobSchema.parse(duplicate.payload) if ( original.message.userId !== job.message.userId || original.message.query !== job.message.query || original.message.queryTooLong !== job.message.queryTooLong || - JSON.stringify(original.message.origin) !== JSON.stringify(job.message.origin) + JSON.stringify(original.message.origin) !== JSON.stringify(job.message.origin) || + original.message.command !== job.message.command ) throw new OrchestrationError('forbidden', 'Slack event identity changed') return duplicate.id } if (duplicate && duplicate.conversationKey !== conversationKey) throw new OrchestrationError('forbidden', 'Slack event conversation changed') - await requireSlackSearchConversationSender(tx, conversation) - const chat = expectedUserId - ? await resolveSlackSearchChatRecord(tx, { - organizationId: installation.organizationId, - userId: expectedUserId, - conversation, - }) - : await findSlackSearchChatRecord(tx, conversation) + 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 ( chat && (chat.organizationId !== installation.organizationId || diff --git a/apps/sim/lib/slack-search/commands.ts b/apps/sim/lib/slack-search/commands.ts new file mode 100644 index 00000000000..11941ba602e --- /dev/null +++ b/apps/sim/lib/slack-search/commands.ts @@ -0,0 +1,19 @@ +import { sha256Hex } from '@sim/security/hash' +import { z } from 'zod' + +const id = z.string().min(1).max(200) +export const slackSearchCommandSchema = z.object({ + api_app_id: id, + team_id: id, + user_id: id, + channel_id: z.string().regex(/^[CGD][A-Z0-9]+$/), + command: z.enum(['/sim-search', '/sim-connect']), + text: z.string().max(40_000), + trigger_id: z.string().min(1).max(200), +}) +export type SlackSearchCommand = z.infer + +/** A verified invocation ID, independent of delivery retries and unrelated to message timestamps. */ +export function slackSearchCommandEventId(command: SlackSearchCommand) { + return `command:${sha256Hex(JSON.stringify([command.api_app_id, command.team_id, command.user_id, command.trigger_id]))}` +} diff --git a/apps/sim/lib/slack-search/constants.ts b/apps/sim/lib/slack-search/constants.ts index 8aeeb839f5f..def905139d5 100644 --- a/apps/sim/lib/slack-search/constants.ts +++ b/apps/sim/lib/slack-search/constants.ts @@ -7,6 +7,7 @@ export const SLACK_SEARCH_SCOPES = [ 'users:read', 'users:read.email', ] as const +export const SLACK_SHARED_SEARCH_BOT_SCOPES = [...SLACK_SEARCH_SCOPES, 'commands'] as const export const SLACK_SEARCH_MAX_DURATION_SECONDS = 180 export const SLACK_SEARCH_CONCURRENCY = 2 export const SLACK_SEARCH_MAX_PENDING_TURNS = 20 diff --git a/apps/sim/lib/slack-search/conversation.ts b/apps/sim/lib/slack-search/conversation.ts index 54b717effe4..26f6e975020 100644 --- a/apps/sim/lib/slack-search/conversation.ts +++ b/apps/sim/lib/slack-search/conversation.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import type { SlackSearchJob } from '@/lib/slack-search/types' +import { slackSearchThreadTimestamp } from '@/lib/slack-search/types' const slackTimestamp = z.string().regex(/^\d{1,12}\.\d{1,9}$/) export const slackSearchConversationSchema = z @@ -41,7 +42,7 @@ export function slackSearchConversation(job: SlackSearchJob): SlackSearchConvers type: 'slack', installationId: job.installationId, channelId: job.message.channelId, - threadTs: job.message.threadTs ?? job.message.messageTs, + threadTs: slackSearchThreadTimestamp(job.message), slackUserId: job.message.userId, lastStopTs: null, ...(job.message.origin ? { origin: job.message.origin } : {}), diff --git a/apps/sim/lib/slack-search/manifest.test.ts b/apps/sim/lib/slack-search/manifest.test.ts index d8b0b6f46ec..c1ccdee37d9 100644 --- a/apps/sim/lib/slack-search/manifest.test.ts +++ b/apps/sim/lib/slack-search/manifest.test.ts @@ -1,7 +1,10 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { createSlackSearchManifest } from '@/lib/slack-search/manifest' +import { + createSharedSlackSearchManifest, + createSlackSearchManifest, +} from '@/lib/slack-search/manifest' describe('Search app manifest', () => { it('combines bot conversations and member indexing in one app with separate grants', () => { @@ -88,3 +91,33 @@ describe('Search app manifest', () => { ).toThrow(OrchestrationError) }) }) + +it('official app uses the existing personal indexing grants with bot commands', () => { + const manifest = createSharedSlackSearchManifest('https://www.sim.ai') + expect(manifest.oauth_config.scopes.user).toEqual([ + 'channels:history', + 'channels:read', + 'groups:history', + 'groups:read', + 'im:history', + 'im:read', + 'mpim:history', + 'mpim:read', + 'users:read', + 'users:read.email', + ]) + expect(manifest.oauth_config.scopes.bot).toContain('commands') + expect(manifest.oauth_config.scopes.bot).not.toContain('groups:history') + expect(manifest.features.slash_commands.map((command) => command.command)).toEqual([ + '/sim-search', + '/sim-connect', + ]) + expect( + manifest.features.slash_commands.every( + (command) => command.url === 'https://www.sim.ai/api/webhooks/slack' + ) + ).toBe(true) + expect(manifest.settings.event_subscriptions.bot_events).toContain('tokens_revoked') + expect(manifest.settings.event_subscriptions.bot_events).not.toContain('message.channels') + expect(manifest.settings.event_subscriptions).not.toHaveProperty('user_events') +}) diff --git a/apps/sim/lib/slack-search/manifest.ts b/apps/sim/lib/slack-search/manifest.ts index a1b73ca8a55..1805c623591 100644 --- a/apps/sim/lib/slack-search/manifest.ts +++ b/apps/sim/lib/slack-search/manifest.ts @@ -4,7 +4,7 @@ import { SLACK_MANAGED_USER_ENROLLMENT_CALLBACK_PATH, SLACK_SEARCH_USER_SCOPES, } from '@/lib/credential-groups/slack-managed-user-scopes' -import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' +import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' export const SLACK_SEARCH_CALLBACK_PATH = '/api/knowledge/slack/oauth/callback' export const SLACK_SEARCH_WEBHOOK_PATH = '/api/webhooks/slack' @@ -61,3 +61,53 @@ export function createSlackSearchManifest( }, } } + +/** The official app combines personal source indexing with bot conversations and commands. */ +export function createSharedSlackSearchManifest(origin: string) { + const manifest = createSlackSearchManifest( + SLACK_SEARCH_DEFAULT_NAME, + SLACK_SEARCH_DEFAULT_DESCRIPTION, + origin + ) + const webhook = new URL(SLACK_SEARCH_WEBHOOK_PATH, origin).href + return { + ...manifest, + features: { + ...manifest.features, + slash_commands: [ + { + command: '/sim-search', + description: 'Ask Sim Search a question privately', + usage_hint: '[question]', + url: webhook, + should_escape: false, + }, + { + command: '/sim-connect', + description: 'Connect your personal sources in Sim', + usage_hint: '[provider]', + url: webhook, + should_escape: false, + }, + ], + }, + oauth_config: { + ...manifest.oauth_config, + scopes: { + bot: [...SLACK_SHARED_SEARCH_BOT_SCOPES], + user: [...SLACK_SEARCH_USER_SCOPES], + }, + }, + settings: { + ...manifest.settings, + event_subscriptions: { + ...manifest.settings.event_subscriptions, + bot_events: [ + ...manifest.settings.event_subscriptions.bot_events, + 'app_uninstalled', + 'tokens_revoked', + ], + }, + }, + } +} diff --git a/apps/sim/lib/slack-search/oauth-state.ts b/apps/sim/lib/slack-search/oauth-state.ts index b570a416030..a1257f0582b 100644 --- a/apps/sim/lib/slack-search/oauth-state.ts +++ b/apps/sim/lib/slack-search/oauth-state.ts @@ -12,6 +12,7 @@ const attemptSchema = z.object({ organizationId: z.string().min(1), name: z.string().min(1), description: z.string().min(1), + sharedApp: z.object({ id: z.string().min(1), revision: z.string().min(1) }).optional(), memberApp: z.object({ appId: z.string().min(1), teamId: z.string().min(1) }).optional(), clientId: z.string().min(1), encryptedClientSecret: z.string().min(1), diff --git a/apps/sim/lib/slack-search/shared-app.test.ts b/apps/sim/lib/slack-search/shared-app.test.ts new file mode 100644 index 00000000000..ce541a84e61 --- /dev/null +++ b/apps/sim/lib/slack-search/shared-app.test.ts @@ -0,0 +1,54 @@ +/** @vitest-environment node */ +import { slackApp, slackSearchInstallation } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ flag: vi.fn(), env: { SLACK_SEARCH_APP_ID: 'A1' } })) +vi.mock('@/lib/core/config/env', () => ({ env: m.env })) +vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: m.flag })) + +import { + findSharedSlackSearchInstallation, + readSharedSlackSearchApp, + requireSlackSearchAppAvailable, +} from '@/lib/slack-search/shared-app' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + m.env.SLACK_SEARCH_APP_ID = 'A1' + m.flag.mockResolvedValue(true) +}) +describe('shared Slack rollout', () => { + 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 = '' + await expect(readSharedSlackSearchApp()).resolves.toBeNull() + }) + it.each( + [ + [], + [{ id: 'A1', kind: 'custom', organizationId: 'org' }], + [{ id: 'A1', kind: 'shared', organizationId: 'org' }], + ].map((rows) => ({ rows })) + )('fails closed for invalid registration %#', async ({ rows }) => { + queueTableRows(slackApp, rows) + await expect(readSharedSlackSearchApp()).rejects.toThrow('not registered') + }) + it('preserves custom bot handling while the shared flag is off', async () => { + m.flag.mockResolvedValue(false) + queueTableRows(slackApp, [{ kind: 'custom' }]) + await expect(requireSlackSearchAppAvailable('CUSTOM')).resolves.toBeUndefined() + expect(m.flag).not.toHaveBeenCalled() + }) + it('refuses a shared bot while the shared flag is off', async () => { + m.flag.mockResolvedValue(false) + queueTableRows(slackApp, [{ kind: 'shared' }]) + await expect(requireSlackSearchAppAvailable('A1')).rejects.toThrow('unavailable') + }) + it('rejects ambiguous organization installations', async () => { + queueTableRows(slackApp, [{ id: 'A1', kind: 'shared', organizationId: null }]) + queueTableRows(slackSearchInstallation, [{ id: 'one' }, { id: 'two' }]) + await expect(findSharedSlackSearchInstallation('org')).rejects.toThrow('single Slack workspace') + }) +}) diff --git a/apps/sim/lib/slack-search/shared-app.ts b/apps/sim/lib/slack-search/shared-app.ts new file mode 100644 index 00000000000..8b87c0e2c22 --- /dev/null +++ b/apps/sim/lib/slack-search/shared-app.ts @@ -0,0 +1,52 @@ +import { db } from '@sim/db' +import { slackApp, slackSearchInstallation } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { env } from '@/lib/core/config/env' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Called only inside authorized installation/member operations; never returns secrets to a surface. */ +export async function readSharedSlackSearchApp() { + if (!(await isFeatureEnabled('slack-search-shared-app')) || !env.SLACK_SEARCH_APP_ID) return null + const [app] = await db + .select() + .from(slackApp) + .where(eq(slackApp.id, env.SLACK_SEARCH_APP_ID)) + .limit(1) + if (!app || app.kind !== 'shared' || app.organizationId !== null) + throw new Error('The configured shared Slack Search app is not registered') + return app +} + +/** Existing custom bots remain independent of the shared-app rollout. */ +export async function requireSlackSearchAppAvailable(appId: string) { + const [app] = await db + .select({ kind: slackApp.kind }) + .from(slackApp) + .where(eq(slackApp.id, appId)) + .limit(1) + if (app?.kind !== 'shared') return + const configured = await readSharedSlackSearchApp() + if (configured?.id !== appId) + throw new OrchestrationError('forbidden', 'The shared Slack Search app is unavailable') +} + +/** Canonical lookup inside an authorized organization operation. */ +export async function findSharedSlackSearchInstallation(organizationId: string) { + const app = await readSharedSlackSearchApp() + if (!app) return null + const installations = await db + .select() + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.organizationId, organizationId), + eq(slackSearchInstallation.slackAppId, app.id), + eq(slackSearchInstallation.enabled, true) + ) + ) + .limit(2) + if (installations.length > 1) + throw new OrchestrationError('conflict', 'Select a single Slack workspace for Search') + return installations[0] ? { ...installations[0], appRevision: app.revision } : null +} diff --git a/apps/sim/lib/slack-search/types.ts b/apps/sim/lib/slack-search/types.ts index 63458f543b0..4ad8bed130f 100644 --- a/apps/sim/lib/slack-search/types.ts +++ b/apps/sim/lib/slack-search/types.ts @@ -1,27 +1,37 @@ import { z } from 'zod' const id = z.string().min(1).max(200) -export const slackSearchMessageSchema = z.object({ - appId: id, - teamId: id, - eventId: id, - userId: id, - channelId: z.string().regex(/^[CGD][A-Z0-9]+$/), - origin: z - .object({ - channelId: z.string().regex(/^[CG][A-Z0-9]+$/), - threadTs: z.string().regex(/^\d+\.\d+$/), - messageTs: z.string().regex(/^\d+\.\d+$/), - }) - .optional(), - messageTs: z.string().regex(/^\d+\.\d+$/), - threadTs: z - .string() - .regex(/^\d+\.\d+$/) - .optional(), - query: z.string().trim().max(2000), - queryTooLong: z.boolean(), -}) +export const slackSearchMessageSchema = z + .object({ + appId: id, + teamId: id, + eventId: id, + userId: id, + channelId: z.string().regex(/^[CGD][A-Z0-9]+$/), + origin: z + .object({ + channelId: z.string().regex(/^[CG][A-Z0-9]+$/), + threadTs: z.string().regex(/^\d+\.\d+$/), + messageTs: z.string().regex(/^\d+\.\d+$/), + }) + .optional(), + messageTs: z + .string() + .regex(/^\d+\.\d+$/) + .nullable(), + command: z.literal('/sim-search').optional(), + threadTs: z + .string() + .regex(/^\d+\.\d+$/) + .optional(), + query: z.string().trim().max(2000), + queryTooLong: z.boolean(), + }) + .refine( + (message) => + message.messageTs !== null || (message.command === '/sim-search' && !message.threadTs), + 'Only a queued slash command may lack a Slack message timestamp' + ) export type SlackSearchMessage = z.infer /** Queue-only contract; no secrets, results, or caller-supplied Sim user identity. */ @@ -102,3 +112,10 @@ export function parseSlackSearchMessage( queryTooLong: query.length > 2000, }) } + +/** A command obtains this identity only after Slack acknowledges its private root message. */ +export function slackSearchThreadTimestamp(message: SlackSearchMessage): string { + const timestamp = message.threadTs ?? message.messageTs + if (!timestamp) throw new Error('Slack command has not created its private thread yet') + return timestamp +} diff --git a/apps/sim/scripts/register-platform-slack-app.ts b/apps/sim/scripts/register-platform-slack-app.ts index 55ffbd21cf6..5e0376cbb09 100644 --- a/apps/sim/scripts/register-platform-slack-app.ts +++ b/apps/sim/scripts/register-platform-slack-app.ts @@ -10,12 +10,17 @@ const logger = createLogger('RegisterPlatformSlackApp') /** Explicit deployment preparation; never chooses an app identity from an unauthenticated event. */ async function main() { const appId = process.argv[2] - const clientId = process.env.SLACK_CLIENT_ID - const clientSecret = process.env.SLACK_CLIENT_SECRET - const signingSecret = process.env.SLACK_SIGNING_SECRET + const searchApp = process.argv.includes('--search') + const clientId = searchApp ? process.env.SLACK_SEARCH_CLIENT_ID : process.env.SLACK_CLIENT_ID + const clientSecret = searchApp + ? process.env.SLACK_SEARCH_CLIENT_SECRET + : process.env.SLACK_CLIENT_SECRET + const signingSecret = searchApp + ? process.env.SLACK_SEARCH_SIGNING_SECRET + : process.env.SLACK_SIGNING_SECRET if (!appId || !/^A[A-Z0-9]+$/.test(appId) || !clientId || !clientSecret || !signingSecret) throw new Error( - 'Supply the verified platform Slack app ID and SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET' + 'Supply a verified app ID and client/signing secrets. With --search use SLACK_SEARCH_CLIENT_ID, SLACK_SEARCH_CLIENT_SECRET, SLACK_SEARCH_SIGNING_SECRET; otherwise use SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET.' ) const [client, signing] = await Promise.all([ encryptSecret(clientSecret), From a2b9234ea746cfcc314cefc95b05180864147b0d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 15:45:17 -0700 Subject: [PATCH 2/6] fix(slack): clean up unsuccessful shared app grants --- apps/sim/.env.example | 2 +- apps/sim/lib/internal/slack/oauth.test.ts | 53 +- apps/sim/lib/internal/slack/oauth.ts | 27 +- .../application/slack-search/setup.test.ts | 140 +++++- .../application/slack-search/setup.ts | 459 ++++++++++-------- 5 files changed, 468 insertions(+), 213 deletions(-) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 40f5b3cb7e2..5b8106a17b7 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -254,6 +254,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"} # Official Sim Search Slack app (optional; requires existing Search access) -# Register the company app with scripts/register-platform-slack-app.ts --search. +# Register the company app with bun scripts/register-platform-slack-app.ts --search. # SLACK_SEARCH_APP_ID= # SLACK_SEARCH_SHARED_APP=false # Off-production fallback for the global slack-search-shared-app flag diff --git a/apps/sim/lib/internal/slack/oauth.test.ts b/apps/sim/lib/internal/slack/oauth.test.ts index 34f787222d4..9e4deeb48cf 100644 --- a/apps/sim/lib/internal/slack/oauth.test.ts +++ b/apps/sim/lib/internal/slack/oauth.test.ts @@ -1,6 +1,10 @@ /** @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { exchangeSlackBotAuthorization } from '@/lib/internal/slack/oauth' +import { + exchangeSlackBotAuthorization, + revokeSlackBotAuthorization, + validateSlackBotAuthorization, +} from '@/lib/internal/slack/oauth' import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' const fetchMock = vi.fn() @@ -11,9 +15,9 @@ const input = { redirectUri: 'https://sim.test/api/knowledge/slack/oauth/callback', } const grant = { - ok: true, + ok: true as const, app_id: 'A1', - token_type: 'bot', + token_type: 'bot' as const, access_token: 'test-bot-token', bot_user_id: 'UBOT', scope: SLACK_SEARCH_SCOPES.join(','), @@ -34,19 +38,48 @@ describe('Slack bot OAuth exchange', () => { expect(request.body.get('redirect_uri')).toBe(input.redirectUri) expect(request.body.get('code')).toBe('code') }) + it.each([{ token_type: 'user' }, { ok: false, error: 'invalid_client_id' }])( + 'rejects incompatible or unsuccessful grants: %j', + async (change) => { + fetchMock.mockResolvedValueOnce(Response.json({ ...grant, ...change })) + await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow() + } + ) + it('does not expose provider credentials in error messages', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ ok: false, error: 'SECRET-DO-NOT-LOG' })) + await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow('Slack authorization failed') + }) +}) + +describe('Slack bot grant policy and cleanup', () => { it.each([ - { token_type: 'user' }, { is_enterprise_install: true }, { refresh_token: 'refresh' }, { expires_in: 3600 }, { scope: 'chat:write' }, - { ok: false, error: 'invalid_client_id' }, - ])('rejects incompatible or unsuccessful grants: %j', async (change) => { - fetchMock.mockResolvedValueOnce(Response.json({ ...grant, ...change })) - await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow() + ])('rejects unsupported grants after the caller takes ownership: %j', (change) => { + expect(() => validateSlackBotAuthorization({ ...grant, ...change })).toThrow() }) - it('does not expose provider credentials in error messages', async () => { + it('accepts the existing indexing bot scope policy', () => { + expect(() => validateSlackBotAuthorization(grant)).not.toThrow() + }) + it('requires the additional command scope for shared installs', () => { + expect(() => + validateSlackBotAuthorization(grant, [...SLACK_SEARCH_SCOPES, 'commands']) + ).toThrow('commands') + }) + it('revokes an unused token through Slack with a bounded request', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ ok: true, revoked: true })) + await revokeSlackBotAuthorization('unused-token') + const [url, request] = fetchMock.mock.calls[0] + expect(String(url)).toContain('/api/auth.revoke') + expect(request.headers.Authorization).toBe('Bearer unused-token') + expect(request.signal).toBeDefined() + }) + it('fails visibly when Slack does not confirm revocation', async () => { fetchMock.mockResolvedValueOnce(Response.json({ ok: false, error: 'SECRET-DO-NOT-LOG' })) - await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow('Slack authorization failed') + await expect(revokeSlackBotAuthorization('unused-token')).rejects.toThrow( + 'Slack could not revoke' + ) }) }) diff --git a/apps/sim/lib/internal/slack/oauth.ts b/apps/sim/lib/internal/slack/oauth.ts index 257fe9a3f70..ced0697443a 100644 --- a/apps/sim/lib/internal/slack/oauth.ts +++ b/apps/sim/lib/internal/slack/oauth.ts @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer' import { z } from 'zod' import { OrchestrationError } from '@/lib/core/orchestration/types' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { requestSlackApi } from '@/lib/internal/slack/client' import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants' const botGrantSchema = z.object({ @@ -44,19 +45,35 @@ export async function exchangeSlackBotAuthorization(input: { 'Slack authorization failed. Check the client credentials and install the app again.' ) } - const grant = parsed.data - if (grant.is_enterprise_install || grant.refresh_token || grant.expires_in) { + return parsed.data +} + +/** Runs after exchange so the application can clean up an issued grant if policy rejects it. */ +export function validateSlackBotAuthorization( + grant: z.infer, + requiredScopes: readonly string[] = SLACK_SEARCH_SCOPES +) { + if (grant.is_enterprise_install || grant.refresh_token || grant.expires_in) throw new OrchestrationError( 'validation', 'Install the app in one workspace with token rotation disabled.' ) - } const scopes = grant.scope.split(',').map((scope) => scope.trim()) - const missing = SLACK_SEARCH_SCOPES.filter((scope) => !scopes.includes(scope)) + const missing = requiredScopes.filter((scope) => !scopes.includes(scope)) if (missing.length) throw new OrchestrationError( 'validation', `Reinstall the app with these scopes: ${missing.join(', ')}` ) - return grant +} + +/** Revokes an unused bot grant after failed setup without logging provider credentials. */ +export async function revokeSlackBotAuthorization(accessToken: string) { + const response = await requestSlackApi({ + accessToken, + method: 'auth.revoke', + signal: AbortSignal.timeout(10_000), + }) + if (response.status !== 200 || response.data.ok !== true || response.data.revoked !== true) + throw new OrchestrationError('validation', 'Slack could not revoke the unused setup token') } 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 dbeeb74a885..2f197b744b9 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts @@ -14,6 +14,9 @@ const m = vi.hoisted(() => ({ audit: vi.fn(), baseUrl: vi.fn(), shared: vi.fn(), + revoke: vi.fn(), + validateGrant: vi.fn(), + ensureGroup: vi.fn(), })) vi.mock('@/lib/slack-search/shared-app', () => ({ readSharedSlackSearchApp: m.shared })) vi.mock('@sim/audit', () => ({ @@ -45,7 +48,12 @@ vi.mock('@/lib/slack-search/oauth-state', () => ({ consumeSlackSearchOAuthAttempt: m.consume, storeSlackSearchOAuthAttempt: m.store, })) -vi.mock('@/lib/internal/slack/oauth', () => ({ exchangeSlackBotAuthorization: m.exchange })) +vi.mock('@/lib/internal/slack/oauth', () => ({ + exchangeSlackBotAuthorization: m.exchange, + revokeSlackBotAuthorization: m.revoke, + validateSlackBotAuthorization: m.validateGrant, +})) +vi.mock('@/lib/credential-groups/service', () => ({ ensureWorkspaceAccountsGroup: m.ensureGroup })) vi.mock('@/lib/credential-groups/organization-slack-app', () => ({ loadOrganizationSlackMemberApps: async () => [], adoptOrganizationSlackMemberApp: vi.fn(), @@ -87,6 +95,9 @@ const complete = () => beforeEach(() => { vi.clearAllMocks() m.shared.mockResolvedValue(null) + m.revoke.mockResolvedValue(undefined) + m.validateGrant.mockReset() + m.ensureGroup.mockResolvedValue({ id: 'accounts' }) m.baseUrl.mockReturnValue('https://sim.test') m.membership.mockResolvedValue([{ role: 'admin' }]) m.rows.mockReset().mockResolvedValue([]) @@ -286,3 +297,130 @@ it('rejects a shared-app callback if the global configuration was disabled or ro await expect(complete()).rejects.toThrow() expect(m.exchange).not.toHaveBeenCalled() }) + +describe('shared app completion', () => { + const sharedApp = { id: 'A1', revision: 'shared-revision', kind: 'shared', organizationId: null } + beforeEach(() => { + m.shared.mockResolvedValue(sharedApp) + m.consume.mockResolvedValue({ + ...attempt, + sharedApp: { id: sharedApp.id, revision: sharedApp.revision }, + }) + }) + + it('commits the personal app configuration, bot credential and installation in one transaction', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'accounts', options: [], encryptedProviderConfiguration: null }, + ]) + await expect(complete()).resolves.toEqual({ organizationId: 'org1' }) + expect(db.transaction).toHaveBeenCalledOnce() + expect(m.ensureGroup).toHaveBeenCalledWith( + { kind: 'organization', organizationId: 'org1' }, + 'admin', + undefined, + expect.objectContaining({ insert: expect.any(Function) }) + ) + const group = m.set.mock.calls[0][0] + expect(group.options).toEqual([ + expect.objectContaining({ + provider: 'slack', + authorizationAppId: 'slack:A1:T1', + status: 'active', + requiredScopes: expect.arrayContaining([ + 'channels:history', + 'groups:history', + 'im:history', + 'mpim:history', + 'users:read.email', + ]), + }), + ]) + const configuration = JSON.parse( + group.encryptedProviderConfiguration.slice('encrypted:'.length) + ) + expect(configuration.slack).toMatchObject({ + source: 'slack_app', + appId: 'A1', + teamId: 'T1', + scopes: group.options[0].requiredScopes, + }) + expect(configuration.slack).not.toHaveProperty('clientSecret') + const rows = m.values.mock.calls.map(([value]) => value) + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ + organizationId: 'org1', + workspaceId: null, + type: 'service_account', + slackAppId: 'A1', + }) + expect(rows[1]).toMatchObject({ + organizationId: 'org1', + credentialId: rows[0].id, + slackAppId: 'A1', + appId: 'A1', + teamId: 'T1', + enabled: true, + }) + expect(m.verify).toHaveBeenCalledTimes(2) + expect(m.revoke).not.toHaveBeenCalled() + expect(m.audit).toHaveBeenCalledOnce() + }) + + it('revokes an unused shared bot grant after a conflicting workspace binding', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'other-app' }]) + await expect(complete()).rejects.toThrow('already has an active Search installation') + expect(m.revoke).toHaveBeenCalledWith('bot-token') + expect(m.values).not.toHaveBeenCalled() + expect(m.audit).not.toHaveBeenCalled() + }) + + it('revokes an unused shared grant after a database write fails', async () => { + m.rows + .mockResolvedValueOnce([sharedApp]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'accounts', options: [], encryptedProviderConfiguration: null }, + ]) + m.values.mockImplementationOnce(() => { + throw new Error('write failed') + }) + await expect(complete()).rejects.toThrow('write failed') + expect(m.revoke).toHaveBeenCalledWith('bot-token') + expect(m.audit).not.toHaveBeenCalled() + }) + + it('never revokes a bot with an existing installation when the initiating admin loses access', async () => { + m.verify.mockImplementationOnce(async () => { + m.membership.mockResolvedValue([{ role: 'member' }]) + return identity + }) + m.rows.mockResolvedValueOnce([{ id: 'existing-installation' }]) + await expect(complete()).rejects.toThrow('administrator') + expect(m.revoke).not.toHaveBeenCalled() + expect(m.values).not.toHaveBeenCalled() + }) + + it('revokes a shared grant rejected by scope or token-rotation policy', async () => { + m.validateGrant.mockImplementationOnce(() => { + throw new Error('unsupported grant') + }) + await expect(complete()).rejects.toThrow('unsupported grant') + expect(m.revoke).toHaveBeenCalledWith('bot-token') + expect(m.values).not.toHaveBeenCalled() + }) + + it('surfaces cleanup failure with a concrete recovery step', async () => { + m.verify.mockRejectedValueOnce(new Error('invalid bot')) + m.revoke.mockRejectedValueOnce(new Error('provider failed')) + await expect(complete()).rejects.toThrow('Remove the unused app in Slack before retrying') + expect(m.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index be9213e28dd..4a6c46d2da5 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -12,7 +12,11 @@ import { loadOrganizationSlackMemberApps, } from '@/lib/credential-groups/organization-slack-app' import { configureSharedSlackMemberApp } from '@/lib/credential-groups/shared-slack-app' -import { exchangeSlackBotAuthorization } from '@/lib/internal/slack/oauth' +import { + exchangeSlackBotAuthorization, + revokeSlackBotAuthorization, + validateSlackBotAuthorization, +} from '@/lib/internal/slack/oauth' import { SlackSearchConfigurationError, SlackSearchProviderError, @@ -196,6 +200,39 @@ export const startSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ }, }) +/** Cleanup shares installation locks; an existing bot must never be revoked by a failed setup. */ +async function revokeUninstalledSharedGrant( + grant: Awaited> +) { + try { + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${grant.team.id}`}, 0))` + ) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${grant.app_id}`}, 0))` + ) + const [installation] = await tx + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.appId, grant.app_id), + eq(slackSearchInstallation.teamId, grant.team.id) + ) + ) + .limit(1) + if (installation) return + await revokeSlackBotAuthorization(grant.access_token) + }) + } catch { + throw new OrchestrationError( + 'validation', + 'Slack setup failed and token cleanup could not be verified. Remove the unused app in Slack before retrying.' + ) + } +} + export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.completeSlackInstallation, async resolveContext({ @@ -234,228 +271,258 @@ export const completeSlackSearchSetup = defineAuthorizedKnowledgeUseCase({ code: input.code, redirectUri: attempt.redirectUri, }) - let identity: Awaited> try { - identity = await verifySlackSearchBot( - grant.access_token, - AbortSignal.timeout(10_000), + validateSlackBotAuthorization( + grant, attempt.sharedApp ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES ) - } catch (error) { + let identity: Awaited> + try { + identity = await verifySlackSearchBot( + grant.access_token, + AbortSignal.timeout(10_000), + attempt.sharedApp ? SLACK_SHARED_SEARCH_BOT_SCOPES : SLACK_SEARCH_SCOPES + ) + } catch (error) { + if ( + error instanceof SlackSearchConfigurationError || + error instanceof SlackSearchProviderError + ) + throw new OrchestrationError('validation', error.message) + throw error + } if ( - error instanceof SlackSearchConfigurationError || - error instanceof SlackSearchProviderError - ) - throw new OrchestrationError('validation', error.message) - throw error - } - if ( - (attempt.sharedApp && identity.appId !== attempt.sharedApp.id) || - identity.appId !== grant.app_id || - identity.teamId !== grant.team.id || - identity.botUserId !== grant.bot_user_id - ) - throw new OrchestrationError( - 'validation', - 'Slack returned an inconsistent installation identity' + (attempt.sharedApp && identity.appId !== attempt.sharedApp.id) || + identity.appId !== grant.app_id || + identity.teamId !== grant.team.id || + identity.botUserId !== grant.bot_user_id ) - if ( - attempt.installation && - (attempt.installation.appId !== identity.appId || - attempt.installation.teamId !== identity.teamId) - ) - throw new OrchestrationError('conflict', 'Reconnect the same Slack app and workspace') - if ( - attempt.memberApp && - (attempt.memberApp.appId !== identity.appId || attempt.memberApp.teamId !== identity.teamId) - ) - throw new OrchestrationError( - 'conflict', - 'Install the same Slack app and workspace used for member indexing' - ) - const { encrypted: encryptedToken } = await encryptSecret( - JSON.stringify({ - type: SLACK_CUSTOM_BOT_SECRET_TYPE, - botToken: grant.access_token, - teamId: identity.teamId, - botUserId: identity.botUserId, - teamName: identity.teamName, - }) - ) - await authorizeOrganizationOperation( - principal, - knowledgeOperations.completeSlackInstallation.organizationOperation, - context - ) - await db.transaction(async (tx) => { - /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${identity.teamId}`}, 0))` - ) - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${identity.appId}`}, 0))` + throw new OrchestrationError( + 'validation', + 'Slack returned an inconsistent installation identity' + ) + if ( + attempt.installation && + (attempt.installation.appId !== identity.appId || + attempt.installation.teamId !== identity.teamId) ) - const [existingApp] = await tx - .select() - .from(slackApp) - .where(eq(slackApp.id, identity.appId)) - .for('update') - .limit(1) + throw new OrchestrationError('conflict', 'Reconnect the same Slack app and workspace') if ( - attempt.sharedApp - ? !existingApp || - existingApp.kind !== 'shared' || - existingApp.organizationId !== null || - existingApp.revision !== attempt.sharedApp.revision - : existingApp && - (existingApp.kind !== 'custom' || existingApp.organizationId !== context.organizationId) + attempt.memberApp && + (attempt.memberApp.appId !== identity.appId || attempt.memberApp.teamId !== identity.teamId) ) throw new OrchestrationError( 'conflict', - 'This Slack app belongs to another installation owner' + 'Install the same Slack app and workspace used for member indexing' ) - if ( - attempt.installation?.appRevision && - existingApp?.revision !== attempt.installation.appRevision + const { encrypted: encryptedToken } = await encryptSecret( + JSON.stringify({ + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + botToken: grant.access_token, + teamId: identity.teamId, + botUserId: identity.botUserId, + teamName: identity.teamName, + }) ) - throw new OrchestrationError( - 'conflict', - 'The Slack app credentials changed during setup. Start again.' + await authorizeOrganizationOperation( + principal, + knowledgeOperations.completeSlackInstallation.organizationOperation, + context + ) + await db.transaction(async (tx) => { + /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-search:${identity.teamId}`}, 0))` ) - const [existing] = await tx - .select() - .from(slackSearchInstallation) - .where( - attempt.installation - ? eq(slackSearchInstallation.id, attempt.installation.id) - : and( - eq(slackSearchInstallation.appId, identity.appId), - eq(slackSearchInstallation.teamId, identity.teamId) - ) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-app:${identity.appId}`}, 0))` ) - .for('update') - .limit(1) - if ( - attempt.installation && - (!existing || - existing.revision !== attempt.installation.revision || - existing.credentialId !== attempt.installation.credentialId) - ) - throw new OrchestrationError( - 'conflict', - 'This installation changed during setup. Start setup again.' + const [existingApp] = await tx + .select() + .from(slackApp) + .where(eq(slackApp.id, identity.appId)) + .for('update') + .limit(1) + if ( + attempt.sharedApp + ? !existingApp || + existingApp.kind !== 'shared' || + existingApp.organizationId !== null || + existingApp.revision !== attempt.sharedApp.revision + : existingApp && + (existingApp.kind !== 'custom' || + existingApp.organizationId !== context.organizationId) ) - if (existing && (!attempt.installation || existing.organizationId !== context.organizationId)) - throw new OrchestrationError( - 'conflict', - 'This app is already connected. Use Reconnect on its existing installation.' + throw new OrchestrationError( + 'conflict', + 'This Slack app belongs to another installation owner' + ) + if ( + attempt.installation?.appRevision && + existingApp?.revision !== attempt.installation.appRevision ) - const [active] = await tx - .select({ id: slackSearchInstallation.id }) - .from(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.teamId, identity.teamId), - eq(slackSearchInstallation.enabled, true), - existing ? ne(slackSearchInstallation.id, existing.id) : undefined + throw new OrchestrationError( + 'conflict', + 'The Slack app credentials changed during setup. Start again.' + ) + const [existing] = await tx + .select() + .from(slackSearchInstallation) + .where( + attempt.installation + ? eq(slackSearchInstallation.id, attempt.installation.id) + : and( + eq(slackSearchInstallation.appId, identity.appId), + eq(slackSearchInstallation.teamId, identity.teamId) + ) ) + .for('update') + .limit(1) + if ( + attempt.installation && + (!existing || + existing.revision !== attempt.installation.revision || + existing.credentialId !== attempt.installation.credentialId) ) - .limit(1) - if (active) - throw new OrchestrationError( - 'conflict', - 'This Slack workspace already has an active Search installation' + throw new OrchestrationError( + 'conflict', + 'This installation changed during setup. Start setup again.' + ) + if ( + existing && + (!attempt.installation || existing.organizationId !== context.organizationId) ) - const appRevision = attempt.sharedApp?.revision ?? generateId() - const appValues = { - id: identity.appId, - kind: 'custom' as const, - organizationId: context.organizationId, - clientId: attempt.clientId, - encryptedClientSecret: attempt.encryptedClientSecret, - encryptedSigningSecret: attempt.encryptedSigningSecret, - revision: appRevision, - updatedAt: new Date(), - } - if (!attempt.sharedApp) - await tx - .insert(slackApp) - .values(appValues) - .onConflictDoUpdate({ target: slackApp.id, set: appValues }) - await adoptOrganizationSlackMemberApp( - tx, - context.organizationId, - identity.appId, - identity.teamId, - attempt.clientId - ) - if (attempt.sharedApp) - await configureSharedSlackMemberApp(tx, { - organizationId: context.organizationId, - userId: principal.userId, - appId: identity.appId, - teamId: identity.teamId, - }) - const credentialId = existing?.credentialId ?? generateId() - const credentialValues = { - slackAppId: identity.appId, - displayName: attempt.name, - description: attempt.description, - encryptedServiceAccountKey: encryptedToken, - updatedAt: new Date(), - } - if (existing) { - const [updated] = await tx - .update(credential) - .set(credentialValues) + throw new OrchestrationError( + 'conflict', + 'This app is already connected. Use Reconnect on its existing installation.' + ) + const [active] = await tx + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) .where( and( - eq(credential.id, credentialId), - eq(credential.organizationId, context.organizationId), - eq(credential.type, 'service_account'), - eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + eq(slackSearchInstallation.teamId, identity.teamId), + eq(slackSearchInstallation.enabled, true), + existing ? ne(slackSearchInstallation.id, existing.id) : undefined ) ) - .returning({ id: credential.id }) - if (!updated) + .limit(1) + if (active) throw new OrchestrationError( 'conflict', - 'Slack bot credential no longer belongs to this organization' + 'This Slack workspace already has an active Search installation' ) - } else { - await tx.insert(credential).values({ - id: credentialId, - organizationId: context.organizationId, - workspaceId: null, - type: 'service_account', - providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, - createdBy: principal.userId, - ...credentialValues, - }) - } - const installationValues = { - ...identity, - slackAppId: identity.appId, - credentialVersion: slackBotCredentialVersion(encryptedToken, appRevision), - enabled: true, - revision: generateId(), - lastOutcome: null, - lastEventAt: null, - updatedAt: new Date(), - } - if (existing) - await tx - .update(slackSearchInstallation) - .set(installationValues) - .where(eq(slackSearchInstallation.id, existing.id)) - else - await tx.insert(slackSearchInstallation).values({ - id: generateId(), + if (attempt.sharedApp) { + /** A concurrent failed setup may have revoked an uncommitted grant while we waited. */ + const current = await verifySlackSearchBot( + grant.access_token, + AbortSignal.timeout(10_000), + SLACK_SHARED_SEARCH_BOT_SCOPES + ) + if ( + current.appId !== identity.appId || + current.teamId !== identity.teamId || + current.botUserId !== identity.botUserId + ) + throw new OrchestrationError( + 'validation', + 'Slack installation identity changed during setup' + ) + } + const appRevision = attempt.sharedApp?.revision ?? generateId() + const appValues = { + id: identity.appId, + kind: 'custom' as const, organizationId: context.organizationId, - credentialId, - ...installationValues, - }) - }) + clientId: attempt.clientId, + encryptedClientSecret: attempt.encryptedClientSecret, + encryptedSigningSecret: attempt.encryptedSigningSecret, + revision: appRevision, + updatedAt: new Date(), + } + if (!attempt.sharedApp) + await tx + .insert(slackApp) + .values(appValues) + .onConflictDoUpdate({ target: slackApp.id, set: appValues }) + await adoptOrganizationSlackMemberApp( + tx, + context.organizationId, + identity.appId, + identity.teamId, + attempt.clientId + ) + if (attempt.sharedApp) + await configureSharedSlackMemberApp(tx, { + organizationId: context.organizationId, + userId: principal.userId, + appId: identity.appId, + teamId: identity.teamId, + }) + const credentialId = existing?.credentialId ?? generateId() + const credentialValues = { + slackAppId: identity.appId, + displayName: attempt.name, + description: attempt.description, + encryptedServiceAccountKey: encryptedToken, + updatedAt: new Date(), + } + if (existing) { + const [updated] = await tx + .update(credential) + .set(credentialValues) + .where( + and( + eq(credential.id, credentialId), + eq(credential.organizationId, context.organizationId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) + ) + .returning({ id: credential.id }) + if (!updated) + throw new OrchestrationError( + 'conflict', + 'Slack bot credential no longer belongs to this organization' + ) + } else { + await tx.insert(credential).values({ + id: credentialId, + organizationId: context.organizationId, + workspaceId: null, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + createdBy: principal.userId, + ...credentialValues, + }) + } + const installationValues = { + ...identity, + slackAppId: identity.appId, + credentialVersion: slackBotCredentialVersion(encryptedToken, appRevision), + enabled: true, + revision: generateId(), + lastOutcome: null, + lastEventAt: null, + updatedAt: new Date(), + } + if (existing) + await tx + .update(slackSearchInstallation) + .set(installationValues) + .where(eq(slackSearchInstallation.id, existing.id)) + else + await tx.insert(slackSearchInstallation).values({ + id: generateId(), + organizationId: context.organizationId, + credentialId, + ...installationValues, + }) + }) + } catch (error) { + if (attempt.sharedApp) await revokeUninstalledSharedGrant(grant) + throw error + } return { organizationId: context.organizationId } }, projectAudit: ({ context }) => ({ From b53e234ad95788ab4034587379ccbbfe8f305b7b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 16:52:11 -0700 Subject: [PATCH 3/6] improvement(slack): rename commands to query and connect --- apps/docs/content/docs/search/slack.mdx | 4 ++-- .../knowledge/application/slack-search/commands.test.ts | 6 +++--- .../sim/lib/knowledge/application/slack-search/commands.ts | 7 +++---- .../lib/knowledge/application/slack-search/mention.test.ts | 2 +- apps/sim/lib/knowledge/application/slack-search/mention.ts | 2 +- apps/sim/lib/slack-search/commands.ts | 2 +- apps/sim/lib/slack-search/manifest.test.ts | 4 ++-- apps/sim/lib/slack-search/manifest.ts | 4 ++-- apps/sim/lib/slack-search/types.ts | 5 ++--- 9 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 1a5356f4260..289acf54e95 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -17,8 +17,8 @@ Each member then opens **Integrations → Slack → Connect**, chooses what to i Slack uses the same indexing pipeline as other connected sources. Both the Sim web Assistant and Slack bot search the organization's knowledge base, applying the current person's access permissions. Newly connected content becomes searchable after indexing completes. Connection and sync status appear in Integrations. - DM **Sim Search**, or mention it in a channel it has joined. -- Use **/sim-search [question]** to start a private DM thread. Channel invocations keep personalized answers and account details in DMs. -- Use **/sim-connect [provider]**, or **Home → Connect sources**, to open your personal Integrations in Sim. OAuth begins only after you click Connect there. +- Use **/query [question]** to start a private DM thread. Channel invocations keep personalized answers and account details in DMs. +- Use **/connect [provider]**, or **Home → Connect sources**, to open your personal Integrations in Sim. OAuth begins only after you click Connect there. - Follow source links to the original messages. Use Slack's Stop control to cancel the active answer and queued follow-ups. Existing custom-app installations are not silently converted. To switch to the official app, remove the old Slack Search binding and source app configuration explicitly, install the official app, and have members authorize it afresh. Workflow integrations keep their existing app configuration. diff --git a/apps/sim/lib/knowledge/application/slack-search/commands.test.ts b/apps/sim/lib/knowledge/application/slack-search/commands.test.ts index 3660fb19fb1..ec6e75df5e4 100644 --- a/apps/sim/lib/knowledge/application/slack-search/commands.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/commands.test.ts @@ -25,7 +25,7 @@ const input = { user_id: 'U1', channel_id: 'C1', trigger_id: 'trigger.1', - command: '/sim-search', + command: '/query', text: 'release notes', } as const const principal = { @@ -51,7 +51,7 @@ describe('Slack commands', () => { expect(m.receive).toHaveBeenCalledWith({ principal, input: expect.objectContaining({ - command: '/sim-search', + command: '/query', messageTs: null, channelId: 'C1', userId: 'U1', @@ -69,7 +69,7 @@ describe('Slack commands', () => { it('returns an environment-correct personal connection link without OAuth state', async () => { const result = await receiveSlackSearchCommand.execute({ principal, - input: { ...input, command: '/sim-connect', text: 'slack' }, + input: { ...input, command: '/connect', text: 'slack' }, }) expect(result.text).toBe( '' diff --git a/apps/sim/lib/knowledge/application/slack-search/commands.ts b/apps/sim/lib/knowledge/application/slack-search/commands.ts index 151d9c00665..3283c70ab7e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/commands.ts +++ b/apps/sim/lib/knowledge/application/slack-search/commands.ts @@ -40,7 +40,7 @@ export const receiveSlackSearchCommand: OperationUseCase< response_type: 'ephemeral', text: 'An admin needs to enable Sim Search for this Slack workspace.', } - if (input.command === '/sim-connect') { + if (input.command === '/connect') { const url = new URL( organizationRoutes(context.installation.organizationId).integrations, getBaseUrl() @@ -60,8 +60,7 @@ export const receiveSlackSearchCommand: OperationUseCase< return { response_type: 'ephemeral', text: `<${url.href}|Connect your sources in Sim>` } } const query = input.text.trim() - if (!query) - return { response_type: 'ephemeral', text: 'Use /sim-search followed by your question.' } + if (!query) return { response_type: 'ephemeral', text: 'Use /query followed by your question.' } const turnId = await receiveSlackSearchMessage.execute({ principal, input: { @@ -70,7 +69,7 @@ export const receiveSlackSearchCommand: OperationUseCase< eventId: principal.eventId, userId: input.user_id, channelId: input.channel_id, - command: '/sim-search', + command: '/query', messageTs: null, query: query.length > 2000 ? '' : query, queryTooLong: query.length > 2000, diff --git a/apps/sim/lib/knowledge/application/slack-search/mention.test.ts b/apps/sim/lib/knowledge/application/slack-search/mention.test.ts index 036d8ca82cf..34efdaa3b0a 100644 --- a/apps/sim/lib/knowledge/application/slack-search/mention.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/mention.test.ts @@ -113,7 +113,7 @@ it('creates a real DM root for a slash command and never uses its trigger ID as channelId: 'C1', userId: 'U1', messageTs: null, - command: '/sim-search', + command: '/query', query: 'Find release notes', queryTooLong: false, }, diff --git a/apps/sim/lib/knowledge/application/slack-search/mention.ts b/apps/sim/lib/knowledge/application/slack-search/mention.ts index e912b5cedcc..813789d651e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/mention.ts +++ b/apps/sim/lib/knowledge/application/slack-search/mention.ts @@ -19,7 +19,7 @@ export async function routeSlackSearchMentionToDm( input: { job: SlackSearchJob; turnId: string; leaseId: string; signal: AbortSignal } ): Promise { const { job, turnId, leaseId, signal } = input - const command = job.message.command === '/sim-search' && job.message.messageTs === null + const command = job.message.command === '/query' && job.message.messageTs === null if (job.message.channelId.startsWith('D') && !command) return job if ( (!command && (!job.message.origin || job.message.origin.channelId !== job.message.channelId)) || diff --git a/apps/sim/lib/slack-search/commands.ts b/apps/sim/lib/slack-search/commands.ts index 11941ba602e..e76c48249a8 100644 --- a/apps/sim/lib/slack-search/commands.ts +++ b/apps/sim/lib/slack-search/commands.ts @@ -7,7 +7,7 @@ export const slackSearchCommandSchema = z.object({ team_id: id, user_id: id, channel_id: z.string().regex(/^[CGD][A-Z0-9]+$/), - command: z.enum(['/sim-search', '/sim-connect']), + command: z.enum(['/query', '/connect']), text: z.string().max(40_000), trigger_id: z.string().min(1).max(200), }) diff --git a/apps/sim/lib/slack-search/manifest.test.ts b/apps/sim/lib/slack-search/manifest.test.ts index c1ccdee37d9..12c578edd47 100644 --- a/apps/sim/lib/slack-search/manifest.test.ts +++ b/apps/sim/lib/slack-search/manifest.test.ts @@ -109,8 +109,8 @@ it('official app uses the existing personal indexing grants with bot commands', expect(manifest.oauth_config.scopes.bot).toContain('commands') expect(manifest.oauth_config.scopes.bot).not.toContain('groups:history') expect(manifest.features.slash_commands.map((command) => command.command)).toEqual([ - '/sim-search', - '/sim-connect', + '/query', + '/connect', ]) expect( manifest.features.slash_commands.every( diff --git a/apps/sim/lib/slack-search/manifest.ts b/apps/sim/lib/slack-search/manifest.ts index 1805c623591..cadeba498c0 100644 --- a/apps/sim/lib/slack-search/manifest.ts +++ b/apps/sim/lib/slack-search/manifest.ts @@ -76,14 +76,14 @@ export function createSharedSlackSearchManifest(origin: string) { ...manifest.features, slash_commands: [ { - command: '/sim-search', + command: '/query', description: 'Ask Sim Search a question privately', usage_hint: '[question]', url: webhook, should_escape: false, }, { - command: '/sim-connect', + command: '/connect', description: 'Connect your personal sources in Sim', usage_hint: '[provider]', url: webhook, diff --git a/apps/sim/lib/slack-search/types.ts b/apps/sim/lib/slack-search/types.ts index 4ad8bed130f..e81c2341038 100644 --- a/apps/sim/lib/slack-search/types.ts +++ b/apps/sim/lib/slack-search/types.ts @@ -19,7 +19,7 @@ export const slackSearchMessageSchema = z .string() .regex(/^\d+\.\d+$/) .nullable(), - command: z.literal('/sim-search').optional(), + command: z.literal('/query').optional(), threadTs: z .string() .regex(/^\d+\.\d+$/) @@ -28,8 +28,7 @@ export const slackSearchMessageSchema = z queryTooLong: z.boolean(), }) .refine( - (message) => - message.messageTs !== null || (message.command === '/sim-search' && !message.threadTs), + (message) => message.messageTs !== null || (message.command === '/query' && !message.threadTs), 'Only a queued slash command may lack a Slack message timestamp' ) export type SlackSearchMessage = z.infer From 8e34f89f55205bed0f4cadab22c3ee778c5da417 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 17:04:00 -0700 Subject: [PATCH 4/6] fix(slack): scope personal revocation to affected searches --- .../slack-search/lifecycle.test.ts | 82 +++++++++++++++++-- .../application/slack-search/lifecycle.ts | 47 +++++++---- 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts index 620082a27a8..c3cdc30eeec 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts @@ -1,5 +1,5 @@ /** @vitest-environment node */ -import { slackSearchInstallation } from '@sim/db/schema' +import { credential, slackSearchInstallation, slackSearchTurn } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -49,18 +49,86 @@ describe('Slack access revocation', () => { expect.objectContaining({ status: 'cancelled', outcome: 'access_revoked' }) ) }) - it('invalidates member grants without disabling the bot for personal revocation', async () => { + it('cancels only affected members without rotating the shared installation revision', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) await revokeSlackSearchAccess.execute({ principal, - input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1', 'U2'] } } }, }) expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ managedOauthStatus: 'needs_reauth' }) ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ lastOutcome: 'tokens_revoked' }) - ) - expect(dbChainMockFns.set.mock.calls.some(([value]) => 'enabled' in value)).toBe(false) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + credential, + slackSearchTurn, + ]) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: slackSearchTurn.installationId, right: 'i1' }, + { type: 'inArray', column: slackSearchTurn.status, values: ['pending', 'running'] }, + { + type: 'inArray', + column: expect.objectContaining({ + strings: ['', " #>> '{message,userId}'"], + values: [slackSearchTurn.payload], + }), + values: ['U1'], + }, + ], + }) + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(2, { + type: 'and', + conditions: expect.arrayContaining([ + { type: 'eq', left: credential.organizationId, right: 'org' }, + { type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' }, + { type: 'inArray', column: credential.providerSubjectId, values: ['U1', 'U2'] }, + { + type: 'or', + conditions: [ + { type: 'isNull', column: credential.grantedAt }, + { + type: 'lte', + left: credential.grantedAt, + right: new Date(input.event_time * 1000), + }, + ], + }, + ]), + }) + }) + it('does not cancel work when no current member grants were revoked', async () => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, + }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([credential]) + }) + it.each([{ bot: ['UBOT'] }, { bot: ['UBOT'], oauth: ['U1'] }])( + 'cancels all installation work when its bot is revoked: %j', + async (tokens) => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens } }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false, revision: expect.any(String) }) + ) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: slackSearchTurn.installationId, right: 'i1' }, + { type: 'inArray', column: slackSearchTurn.status, values: ['pending', 'running'] }, + ], + }) + } + ) + it('does not invalidate the installation when a different bot token is revoked', async () => { + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { bot: ['OTHER'] } } }, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it.each([{ appId: 'A2' }, { receivedAt: new Date(0) }, { receivedAt: new Date(Number.NaN) }])( 'rejects invalid verified authority %#', diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts index af9ae40c41a..cb6e20def83 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, slackSearchInstallation, slackSearchTurn } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, lte, or } from 'drizzle-orm' +import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm' import { z } from 'zod' import type { OperationUseCase } from '@/lib/core/application/operation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -74,8 +74,9 @@ export const revokeSlackSearchAccess: OperationUseCase< uninstall || (input.event.type === 'tokens_revoked' && (input.event.tokens.bot ?? []).includes(installation.botUserId)) + let revokedMemberIds: string[] = [] if (uninstall || revokedUsers.length) { - await tx + const revokeCredentials = tx .update(credential) .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) .where( @@ -90,25 +91,43 @@ export const revokeSlackSearchAccess: OperationUseCase< ...(uninstall ? [] : [inArray(credential.providerSubjectId, revokedUsers)]) ) ) + if (revokeBot) await revokeCredentials + else { + const revokedCredentials = await revokeCredentials.returning({ + providerSubjectId: credential.providerSubjectId, + }) + revokedMemberIds = revokedCredentials.flatMap(({ providerSubjectId }) => + providerSubjectId ? [providerSubjectId] : [] + ) + } } - if (installation.updatedAt > occurredAt) return - if (!revokeBot && !revokedUsers.length) return - await tx - .update(slackSearchInstallation) - .set({ - revision: generateId(), - ...(revokeBot ? { enabled: false } : {}), - lastOutcome: uninstall ? 'app_uninstalled' : 'tokens_revoked', - updatedAt: new Date(), - }) - .where(eq(slackSearchInstallation.id, installation.id)) + if (revokeBot) { + if (installation.updatedAt > occurredAt) return + await tx + .update(slackSearchInstallation) + .set({ + revision: generateId(), + enabled: false, + lastOutcome: uninstall ? 'app_uninstalled' : 'tokens_revoked', + updatedAt: new Date(), + }) + .where(eq(slackSearchInstallation.id, installation.id)) + } else if (!revokedMemberIds.length) return await tx .update(slackSearchTurn) .set({ status: 'cancelled', outcome: 'access_revoked', updatedAt: new Date() }) .where( and( eq(slackSearchTurn.installationId, installation.id), - inArray(slackSearchTurn.status, ['pending', 'running']) + inArray(slackSearchTurn.status, ['pending', 'running']), + ...(revokeBot + ? [] + : [ + inArray( + sql`${slackSearchTurn.payload} #>> '{message,userId}'`, + revokedMemberIds + ), + ]) ) ) }) From a20e84fcdab509ed3be52e6d735671bd7b838832 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 17:14:55 -0700 Subject: [PATCH 5/6] fix(slack): reject stale uninstall before revoking member grants --- .../slack-search/lifecycle.test.ts | 34 ++++++++++++++++--- .../application/slack-search/lifecycle.ts | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts index c3cdc30eeec..805557f2a4e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts @@ -144,7 +144,10 @@ describe('Slack access revocation', () => { await revokeSlackSearchAccess.execute({ principal, input }) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) - it('does not revoke a replacement installation because of a delayed uninstall', async () => { + it.each([ + { type: 'app_uninstalled' as const }, + { type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'], oauth: ['U1'] } }, + ])('ignores stale installation-wide revocations before any writes: %j', async (event) => { resetDbChainMock() queueTableRows(slackSearchInstallation, [ { @@ -152,10 +155,33 @@ describe('Slack access revocation', () => { organizationId: 'org', appId: 'A1', teamId: 'T1', - updatedAt: new Date(Date.now() + 1000), + botUserId: 'UBOT', + updatedAt: new Date(input.event_time * 1000 + 1000), }, ]) - await revokeSlackSearchAccess.execute({ principal, input }) - expect(dbChainMockFns.set.mock.calls.some(([value]) => 'enabled' in value)).toBe(false) + await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('still revokes matching personal grants when only the installation is newer', async () => { + resetDbChainMock() + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + botUserId: 'UBOT', + updatedAt: new Date(input.event_time * 1000 + 1000), + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, + }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + credential, + slackSearchTurn, + ]) }) }) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts index cb6e20def83..a3ffc733e1e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts @@ -74,6 +74,7 @@ export const revokeSlackSearchAccess: OperationUseCase< uninstall || (input.event.type === 'tokens_revoked' && (input.event.tokens.bot ?? []).includes(installation.botUserId)) + if (revokeBot && installation.updatedAt > occurredAt) return let revokedMemberIds: string[] = [] if (uninstall || revokedUsers.length) { const revokeCredentials = tx @@ -102,7 +103,6 @@ export const revokeSlackSearchAccess: OperationUseCase< } } if (revokeBot) { - if (installation.updatedAt > occurredAt) return await tx .update(slackSearchInstallation) .set({ From 3c572d61dacb563d4a35279753bd5b74cb465ff6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 17:25:17 -0700 Subject: [PATCH 6/6] fix(slack): process member revocations independently of bot events --- .../slack-search/lifecycle.test.ts | 63 ++++++++++++------- .../application/slack-search/lifecycle.ts | 10 +-- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts index 805557f2a4e..296715abde3 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts @@ -146,7 +146,7 @@ describe('Slack access revocation', () => { }) it.each([ { type: 'app_uninstalled' as const }, - { type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'], oauth: ['U1'] } }, + { type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'] } }, ])('ignores stale installation-wide revocations before any writes: %j', async (event) => { resetDbChainMock() queueTableRows(slackSearchInstallation, [ @@ -162,26 +162,43 @@ describe('Slack access revocation', () => { await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } }) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) - it('still revokes matching personal grants when only the installation is newer', async () => { - resetDbChainMock() - queueTableRows(slackSearchInstallation, [ - { - id: 'i1', - organizationId: 'org', - appId: 'A1', - teamId: 'T1', - botUserId: 'UBOT', - updatedAt: new Date(input.event_time * 1000 + 1000), - }, - ]) - dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) - await revokeSlackSearchAccess.execute({ - principal, - input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, - }) - expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ - credential, - slackSearchTurn, - ]) - }) + it.each([{ oauth: ['U1'] }, { oauth: ['U1'], bot: ['UBOT'] }])( + 'revokes matching member grants independently of a newer bot installation: %j', + async (tokens) => { + resetDbChainMock() + queueTableRows(slackSearchInstallation, [ + { + id: 'i1', + organizationId: 'org', + appId: 'A1', + teamId: 'T1', + botUserId: 'UBOT', + updatedAt: new Date(input.event_time * 1000 + 1000), + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens } }, + }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + credential, + slackSearchTurn, + ]) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + { + type: 'inArray', + column: expect.objectContaining({ + strings: ['', " #>> '{message,userId}'"], + values: [slackSearchTurn.payload], + }), + values: ['U1'], + }, + ]), + }) + ) + } + ) }) diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts index a3ffc733e1e..b712a4cb6f7 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts @@ -68,13 +68,15 @@ export const revokeSlackSearchAccess: OperationUseCase< if (app.app.kind === 'custom' && app.app.organizationId !== installation.organizationId) throw new Error('Slack installation ownership is inconsistent') const uninstall = input.event.type === 'app_uninstalled' + const staleInstallation = installation.updatedAt > occurredAt + if (uninstall && staleInstallation) return const revokedUsers = input.event.type === 'tokens_revoked' ? (input.event.tokens.oauth ?? []) : [] const revokeBot = - uninstall || - (input.event.type === 'tokens_revoked' && - (input.event.tokens.bot ?? []).includes(installation.botUserId)) - if (revokeBot && installation.updatedAt > occurredAt) return + !staleInstallation && + (uninstall || + (input.event.type === 'tokens_revoked' && + (input.event.tokens.bot ?? []).includes(installation.botUserId))) let revokedMemberIds: string[] = [] if (uninstall || revokedUsers.length) { const revokeCredentials = tx