Skip to content

Commit b59d165

Browse files
fix(slack): address review — set credentialId, scope OAuth path to workspace, drop event narrowing
Review round 1 (Greptile 4/5 + Cursor Bugbot): - deploy.ts: the native Sim-app (OAuth) branch now sets providerConfig.credentialId (runtime token resolution in the slack provider and credential-disconnect cleanup both key slack_app rows on it — without it, file downloads / reaction text fail and disconnect leaves the webhook active). - deploy.ts: resolve the OAuth credential through resolveTriggerCredentialId (workspace- and oauth-scoped) so a pasted foreign/other-tenant credential id can't bind to the workflow; use the resolved canonical id for token owner lookup + routing. - deploy.ts: a deleted/secretless custom bot credential (getSlackBotCredential → null but a service_account row exists) now returns the "reconnect the bot" error instead of the misleading "connected Slack account" message. - oauth.ts: drop the credential-based event-option narrowing. The shared dropdown framework doesn't revalidate a stored value when its dependency changes, so switching a custom bot → Sim account left an orphaned event that failed deploy with a 400. The event picker now offers all events; the deploy path is the authoritative gate. - tests: add broken-bot and workspace-not-resolvable cases; assert credentialId is set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz
1 parent ab14cc9 commit b59d165

4 files changed

Lines changed: 83 additions & 54 deletions

File tree

apps/sim/lib/webhooks/deploy.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,37 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
278278
expect(result?.error?.message).toContain('not available in this workspace')
279279
})
280280

281+
it('rejects a deleted or secretless custom bot credential as an invalid bot', async () => {
282+
mockGetSlackBotCredential.mockResolvedValue(null)
283+
mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account' })
284+
285+
const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_x' })
286+
287+
expect(result?.success).toBe(false)
288+
if (result?.success) throw new Error('expected failure')
289+
expect(result?.error?.status).toBe(400)
290+
expect(result?.error?.message).toContain('bot credential is missing or invalid')
291+
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
292+
})
293+
294+
it('rejects an OAuth credential not resolvable in the workflow workspace', async () => {
295+
mockGetSlackBotCredential.mockResolvedValue(null)
296+
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
297+
// No credential row queued → resolveTriggerCredentialId returns null.
298+
299+
const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_foreign' })
300+
301+
expect(result?.success).toBe(false)
302+
if (result?.success) throw new Error('expected failure')
303+
expect(result?.error?.status).toBe(400)
304+
expect(result?.error?.message).toContain('not available in this workspace')
305+
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
306+
})
307+
281308
it('rejects a non-simSubscribed event on the native Sim app (OAuth account)', async () => {
282309
mockGetSlackBotCredential.mockResolvedValue(null)
310+
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
311+
queueTableRows(credential, [{ id: 'cred_oauth_1' }])
283312

284313
const result = await resolveSlack({
285314
eventType: 'file_shared',
@@ -296,6 +325,7 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
296325
it('routes an OAuth account by team_id on the slack_app provider', async () => {
297326
mockGetSlackBotCredential.mockResolvedValue(null)
298327
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
328+
queueTableRows(credential, [{ id: 'cred_oauth_1' }])
299329
queueTableRows(account, [{ userId: 'owner-1' }])
300330
mockRefreshAccessTokenIfNeeded.mockResolvedValue('xoxb-token')
301331
mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' })
@@ -308,13 +338,16 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
308338
expect(result.config.routingKey).toBe('T123')
309339
expect(result.config.triggerPath).toBeNull()
310340
expect(result.config.providerConfig.bot_user_id).toBe('UBOT')
341+
// Runtime token resolution + disconnect cleanup key slack_app rows on this.
342+
expect(result.config.providerConfig.credentialId).toBe('cred_oauth_1')
311343
// Owner's token, not the deploying actor's.
312344
expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith('cred_oauth_1', 'owner-1', 'req-1')
313345
})
314346

315347
it('fails when the connected Slack account token cannot be resolved', async () => {
316348
mockGetSlackBotCredential.mockResolvedValue(null)
317349
mockResolveOAuthAccountId.mockResolvedValue({ accountId: '' })
350+
queueTableRows(credential, [{ id: 'cred_oauth_1' }])
318351
mockRefreshAccessTokenIfNeeded.mockResolvedValue(null)
319352

320353
const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' })

apps/sim/lib/webhooks/deploy.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,40 @@ export async function resolveWebhookConfigForBlock(input: {
469469
providerConfig.credentialId = slackCredentialId
470470
if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId
471471
} else {
472-
// Native Sim app: an OAuth-connected account. The app only subscribes to a
473-
// fixed event set, so reject anything outside it before deriving routing.
472+
// getSlackBotCredential also returns null for a custom bot credential that
473+
// was deleted or lost its stored secrets. Name that case so the error
474+
// directs the user to reconnect the bot rather than mislabeling it an OAuth
475+
// account below.
476+
const resolvedKind = await resolveOAuthAccountId(slackCredentialId)
477+
if (resolvedKind?.credentialType === 'service_account') {
478+
return {
479+
success: false,
480+
error: {
481+
message: 'The selected Slack bot credential is missing or invalid. Reconnect it.',
482+
status: 400,
483+
},
484+
}
485+
}
486+
// Native Sim app: a workspace OAuth Slack credential. Resolve it through the
487+
// same workspace/provider-scoped lookup the generic credential path uses, so
488+
// a pasted foreign or other-tenant credential id can't bind here and the
489+
// canonical id is what routing and runtime token resolution key on.
490+
const workflowWorkspace =
491+
typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined
492+
const resolvedCredentialId = workflowWorkspace
493+
? await resolveTriggerCredentialId(slackCredentialId, workflowWorkspace, 'slack')
494+
: null
495+
if (!resolvedCredentialId) {
496+
return {
497+
success: false,
498+
error: {
499+
message: 'The selected Slack credential is not available in this workspace.',
500+
status: 400,
501+
},
502+
}
503+
}
504+
// The shared app only subscribes to a fixed event set; reject anything
505+
// outside it before deriving routing.
474506
const eventType =
475507
typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null
476508
if (!eventType || !SIM_SUBSCRIBED_EVENTS.includes(eventType)) {
@@ -487,7 +519,7 @@ export async function resolveWebhookConfigForBlock(input: {
487519
// shared workspace a teammate can deploy a trigger wired to someone else's
488520
// Slack account.
489521
let tokenOwnerUserId = input.userId
490-
const resolvedAccount = await resolveOAuthAccountId(slackCredentialId)
522+
const resolvedAccount = await resolveOAuthAccountId(resolvedCredentialId)
491523
if (resolvedAccount?.accountId) {
492524
const [owner] = await db
493525
.select({ userId: account.userId })
@@ -497,7 +529,7 @@ export async function resolveWebhookConfigForBlock(input: {
497529
if (owner?.userId) tokenOwnerUserId = owner.userId
498530
}
499531
const botToken = await refreshAccessTokenIfNeeded(
500-
slackCredentialId,
532+
resolvedCredentialId,
501533
tokenOwnerUserId,
502534
input.requestId
503535
)
@@ -529,6 +561,9 @@ export async function resolveWebhookConfigForBlock(input: {
529561
}
530562
effectiveProvider = 'slack_app'
531563
effectivePath = null
564+
// Runtime token resolution and credential-disconnect cleanup key native
565+
// (`slack_app`) rows on providerConfig.credentialId.
566+
providerConfig.credentialId = resolvedCredentialId
532567
}
533568
}
534569

apps/sim/triggers/slack/oauth.ts

Lines changed: 5 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,14 @@
11
import { SlackIcon } from '@/components/icons'
2-
import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils'
3-
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
4-
import {
5-
fetchOAuthCredentials,
6-
OAUTH_CREDENTIAL_LIST_STALE_TIME,
7-
oauthCredentialKeys,
8-
} from '@/hooks/queries/oauth/oauth-credentials'
9-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
10-
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
2+
import { getScopesForService } from '@/lib/oauth/utils'
113
import {
124
SLACK_ALL_EVENT_OPTIONS,
13-
SLACK_SIM_EVENT_OPTIONS,
145
SLACK_SOURCE_OPTIONS,
156
SLACK_THREAD_OPTIONS,
167
SLACK_TRIGGER_OUTPUTS,
178
slackEventsSupportingFilter,
189
} from '@/triggers/slack/shared'
1910
import type { TriggerConfig } from '@/triggers/types'
2011

21-
const SLACK_PROVIDER_ID = getProviderIdFromServiceId('slack')
22-
23-
/**
24-
* Event options for the picker, narrowed to what the selected credential can
25-
* actually receive: a native Sim-app OAuth account is limited to the events the
26-
* shared app subscribes to, while a reusable custom bot generates its own
27-
* manifest and can use any event. Resolved from the credential list the picker
28-
* already warmed, so this reads the cache without a refetch in the common case.
29-
*/
30-
async function fetchSlackEventOptions(blockId: string) {
31-
const credentialId = useSubBlockStore.getState().getValue(blockId, 'customBotCredential')
32-
if (typeof credentialId !== 'string' || !credentialId) return [...SLACK_ALL_EVENT_OPTIONS]
33-
34-
const registry = useWorkflowRegistry.getState()
35-
const workspaceId = registry.hydration.workspaceId ?? undefined
36-
const workflowId = registry.activeWorkflowId ?? undefined
37-
38-
const credentials = await getQueryClient().fetchQuery({
39-
queryKey: oauthCredentialKeys.list(SLACK_PROVIDER_ID, workspaceId, workflowId),
40-
queryFn: ({ signal }) =>
41-
fetchOAuthCredentials({ providerId: SLACK_PROVIDER_ID, workspaceId, workflowId }, signal),
42-
staleTime: OAUTH_CREDENTIAL_LIST_STALE_TIME,
43-
})
44-
45-
const selected = credentials.find((cred) => cred.id === credentialId)
46-
return selected && selected.type !== 'service_account'
47-
? [...SLACK_SIM_EVENT_OPTIONS]
48-
: [...SLACK_ALL_EVENT_OPTIONS]
49-
}
50-
5112
// Filter sub-block gating is derived from the catalog's `filters` field so the
5213
// UI conditions and the ingest route share one source of truth.
5314
const SOURCE_FILTER_EVENTS = slackEventsSupportingFilter('source')
@@ -69,7 +30,10 @@ const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reactio
6930
* credentialId`) to one shared ingest URL verified with the bot's own signing
7031
* secret, so many triggers on the same bot share a single Request URL.
7132
* - Native Sim app: events route by Slack `team_id` on the official shared app
72-
* (derived at deploy time via `auth.test`, no path or app setup).
33+
* (derived at deploy time via `auth.test`, no path or app setup). Only the
34+
* events the shared app subscribes to are usable; the deploy path enforces
35+
* that (`SIM_SUBSCRIBED_EVENTS`), so the event picker offers every event
36+
* rather than mutating its option set with the selected credential.
7337
*
7438
* The trigger is only reachable through the preview-gated `slack_v2` block, so
7539
* the native Sim-app mode inherits that gate — no separate env flag.
@@ -93,8 +57,6 @@ export const slackOAuthTrigger: TriggerConfig = {
9357
'The single Slack event this trigger fires on. Add another trigger block for another event.',
9458
required: true,
9559
mode: 'trigger',
96-
dependsOn: ['customBotCredential'],
97-
fetchOptions: fetchSlackEventOptions,
9860
},
9961
{
10062
id: 'customBotCredential',

apps/sim/triggers/slack/shared.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,17 +271,16 @@ export const slackEventById = new Map<string, SlackEventCatalogEntry>(
271271
SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry])
272272
)
273273

274-
/** Event ids the official shared Sim app subscribes to (Sim-app gating SOT). */
274+
/**
275+
* Event ids the official shared Sim app subscribes to. Single source of truth
276+
* for the deploy-time gate that rejects a native Sim-app trigger configured with
277+
* an event the shared app can't deliver.
278+
*/
275279
export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter(
276280
(entry) => entry.simSubscribed
277281
).map((entry) => entry.id)
278282

279-
/** Dropdown options for the native Sim app — only officially-subscribed events. */
280-
export const SLACK_SIM_EVENT_OPTIONS = SLACK_EVENT_CATALOG.filter(
281-
(entry) => entry.simSubscribed
282-
).map((entry) => ({ label: entry.label, id: entry.id }))
283-
284-
/** Dropdown options for a custom bot — every event (manifest generated on demand). */
283+
/** Dropdown options for the event picker — every selectable event. */
285284
export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({
286285
label: entry.label,
287286
id: entry.id,

0 commit comments

Comments
 (0)