Skip to content

Commit f4d47ed

Browse files
feat(slack): reusable custom bot credentials, slack_v2 block (preview), redesigned trigger (#5323)
* feat(slack): enable assistant-agent tools via assistant:write scope Add assistant:write, app_mentions:read, and im:history to the Slack bot OAuth scopes so the Set Assistant Status / Title / Suggested Prompts tools (assistant.threads.*) work with users' existing Slack credentials — no new app or credentials required. Restore the action_assistant trigger capability (scope assistant:write) in the manifest generator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpeT8J5yVCrrNQB9Hzm9uS * Add slack trigger * fix channel picker in slack trigger * improvement(slack-trigger): reorder app type, gate account to sim mode, add channel-id input * fix(slack-trigger): drop unmapped events from filter, resolve oauth token for reaction text + file downloads * fix(slack-trigger): empty operation selection fires nothing; resolve token via credential owner not execution actor * fix(slack-trigger): ignore message edit/delete/system subtypes; prefer channel picker over stale manual ids * feat(slack-trigger): single-event model with contextual filters and full event catalog * fix(slack-trigger): apply event/channel/bot filters on custom-app path too * fix(slack-trigger): don't drop edit/delete events when channel_type is absent * feat(slack): reusable custom bot credentials, slack_v2 block, interactivity triggers - Custom bot as a workspace service-account credential (set up once, shared ingest URL /api/webhooks/slack/custom/{credentialId}, reused across triggers and actions) - slack_v2 action block: credential-based Custom Bot auth alongside Sim OAuth; v1 hidden from toolbar - Interaction triggers (block_actions / view_submission) with optional action/callback id filter; settings.interactivity in generated manifests - Setup wizard: name + description, full permissions by default with ChipDropdown customization; reconnect mode rotates secrets in place - Centralized service-account token resolution (unknown provider fails loudly) - Shared Slack webhook fan-out dispatcher for native + custom ingest routes * chore(api-validation): bump route baseline to 924 after staging merge * feat(slack): preview-gate slack_v2 and the custom-bot credential surfaces slack_v2 (block + hosted slack_oauth trigger) ships preview: true — hidden from all discovery until revealed via block-visibility AppConfig or PREVIEW_BLOCKS. v1 stays toolbar-visible with the legacy slack_webhook trigger until v2 GAs. The integrations-page custom-bot setup surface rides the same flag via isHiddenUnder(slack_v2); placed instances, existing credentials, and ingest/execution paths are never gated. * fix(slack): v1 keeps slack_webhook trigger subblocks; handle object-form event channels - v1 spread had been swapped to slack_oauth's trigger subblocks (shared with v2), leaving its slack_webhook deploy path without signing-secret config (Bugbot high). v1 now carries the legacy trigger set again; v2 swaps them for slack_oauth's. - resolveSlackEventChannel reads channel.id for channel_created/channel_rename payloads, so channel filters no longer drop every rename event. * fix(slack): default absent appType to custom at deploy; deactivate custom-bot webhooks on credential delete - appType is hidden and seeded 'custom' by value(), which only covers editor-created blocks; defaultValue now persists it via buildProviderConfig and the deploy fallback flips to custom (the only exposed mode this ship) - deleting a slack-custom-bot credential now also deactivates provider='slack' webhooks routed by that credential id, not just native slack_app rows * fix(slack): resolve credential owner for deploy-time team_id lookup A teammate deploying a trigger wired to a shared Slack credential isn't the credential owner; refreshAccessTokenIfNeeded only loads tokens for the owning user. Resolve the account owner first, mirroring the runtime formatInput path. * chore(slack): reconcile staging merge - nullable webhook.path coalesced at correlation/payload/tiktok boundaries - slack dispatch delegates to staging's dispatchResolvedWebhookTarget (shared preprocess/deployment/filter/enqueue lifecycle), keeping the skip-reason diagnostics; route tests reworked around that seam - api-validation route baseline 924 -> 926 * fix(slack): workspace-scope bot credentials at deploy; recreate webhooks on routing transitions - a bot credential id is semi-public (embedded in Slack Request URLs), so the custom deploy branch now rejects credentials outside the workflow's workspace - needsRecreation also compares path/routingKey, so a row from an older routing model can't survive redeploy as a stale delivery surface * test(slack): pin fail-closed behavior for empty/missing event selection * fix(slack): 409 on custom-bot name collision instead of silently returning the existing credential The service-account dedupe matches on displayName, which defaults to the Slack team name — shared by every bot in that workspace. A second unnamed bot create returned the first credential as success, orphaning the new id already pasted into the Slack Request URL. Same-id replays stay idempotent; different-id collisions now fail loudly so the wizard prompts for a distinct name. * fix(slack): reconnect surfaces Atlassian error codes and persists name/description edits - PUT credential route now returns the Atlassian provider code (providerErrorCode -> code) so reconnect failures map to specific token/domain messages, matching create - Google/Atlassian reconnect send + seed displayName/description (parity with Slack); edits are no longer silently discarded, and empty fields don't clobber existing values * fix(slack): require bot name; propagate rotated bot_user_id to webhooks on reconnect - the setup wizard now requires a bot name (canAdvance), so the credential name, manifest app name, and uniqueness key all use the user's choice instead of the shared Slack team-name fallback that collided for a second bot in one workspace - reconnect that changes the bot user id (recreated Slack app) now updates the bot_user_id cached in each bound webhook's providerConfig, so reaction self-drop keeps working instead of letting the bot's own reactions re-enter --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f60d41a commit f4d47ed

59 files changed

Lines changed: 20641 additions & 438 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/workflow-preview/format-references.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
1515
const isReference =
1616
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
1717
return isReference ? (
18-
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
1918
<span key={index} className='text-[var(--brand-secondary)]'>
2019
{part}
2120
</span>
2221
) : (
23-
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
2422
<span key={index}>{part}</span>
2523
)
2624
})

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,13 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
1111
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14-
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
1514
import { captureServerEvent } from '@/lib/posthog/server'
1615
import {
17-
getAtlassianServiceAccountSecret,
1816
getCredential,
1917
getOAuthToken,
20-
getServiceAccountToken,
2118
refreshTokenIfNeeded,
2219
resolveOAuthAccountId,
20+
resolveServiceAccountToken,
2321
} from '@/app/api/auth/oauth/utils'
2422

2523
export const dynamic = 'force-dynamic'
@@ -170,25 +168,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
170168
}
171169

172170
try {
173-
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
174-
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
175-
emitServiceAccountAccess()
176-
return NextResponse.json(
177-
{
178-
accessToken: secret.apiToken,
179-
cloudId: secret.cloudId,
180-
domain: secret.domain,
181-
},
182-
{ status: 200 }
183-
)
184-
}
185-
const accessToken = await getServiceAccountToken(
171+
const result = await resolveServiceAccountToken(
186172
resolved.credentialId,
173+
resolved.providerId,
187174
scopes ?? [],
188175
impersonateEmail
189176
)
190177
emitServiceAccountAccess()
191-
return NextResponse.json({ accessToken }, { status: 200 })
178+
return NextResponse.json(
179+
{
180+
accessToken: result.accessToken,
181+
cloudId: result.cloudId,
182+
domain: result.domain,
183+
},
184+
{ status: 200 }
185+
)
192186
} catch (error) {
193187
logger.error(`[${requestId}] Service account token error:`, error)
194188
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })

apps/sim/app/api/auth/oauth/utils.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,25 @@ vi.mock('@/lib/oauth/oauth', () => ({
1414

1515
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
1616

17+
const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
18+
vi.mock('@/lib/core/security/encryption', () => ({
19+
decryptSecret: mockDecryptSecret,
20+
encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })),
21+
}))
22+
1723
import { db } from '@sim/db'
1824
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
1925
import { refreshOAuthToken } from '@/lib/oauth'
26+
import {
27+
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
28+
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
29+
SLACK_CUSTOM_BOT_PROVIDER_ID,
30+
} from '@/lib/oauth/types'
2031
import {
2132
getCredential,
2233
refreshAccessTokenIfNeeded,
2334
refreshTokenIfNeeded,
35+
resolveServiceAccountToken,
2436
} from '@/app/api/auth/oauth/utils'
2537

2638
const mockDb = db as any
@@ -262,4 +274,61 @@ describe('OAuth Utils', () => {
262274
expect(token).toBeNull()
263275
})
264276
})
277+
278+
describe('resolveServiceAccountToken', () => {
279+
it('throws loudly for an unknown provider (never silently attempts Google)', async () => {
280+
await expect(resolveServiceAccountToken('cred-1', 'mystery-provider')).rejects.toThrow(
281+
/Unsupported service-account provider/
282+
)
283+
})
284+
285+
it('returns the decrypted bot token for a custom Slack bot', async () => {
286+
mockSelectChain([
287+
{
288+
type: 'service_account',
289+
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
290+
encryptedServiceAccountKey: 'enc',
291+
},
292+
])
293+
mockDecryptSecret.mockResolvedValueOnce({
294+
decrypted: JSON.stringify({ signingSecret: 's', botToken: 'xoxb-tok', teamId: 'T1' }),
295+
})
296+
const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
297+
expect(result.accessToken).toBe('xoxb-tok')
298+
})
299+
300+
it('throws when the Slack bot credential is missing', async () => {
301+
mockSelectChain([])
302+
await expect(
303+
resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
304+
).rejects.toThrow(/Slack bot credential not found/)
305+
})
306+
307+
it('returns apiToken + cloudId + domain for Atlassian', async () => {
308+
mockSelectChain([{ encryptedServiceAccountKey: 'enc' }])
309+
mockDecryptSecret.mockResolvedValueOnce({
310+
decrypted: JSON.stringify({
311+
type: 'atlassian_service_account',
312+
apiToken: 'atk',
313+
domain: 'acme.atlassian.net',
314+
cloudId: 'cloud-1',
315+
}),
316+
})
317+
const result = await resolveServiceAccountToken(
318+
'cred-1',
319+
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
320+
)
321+
expect(result).toMatchObject({
322+
accessToken: 'atk',
323+
cloudId: 'cloud-1',
324+
domain: 'acme.atlassian.net',
325+
})
326+
})
327+
328+
it('requires scopes for a Google service account', async () => {
329+
await expect(
330+
resolveServiceAccountToken('cred-1', GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, [])
331+
).rejects.toThrow(/Scopes are required/)
332+
})
333+
})
265334
})

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
import {
2222
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
2323
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
24+
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
25+
SLACK_CUSTOM_BOT_PROVIDER_ID,
2426
} from '@/lib/oauth/types'
2527

2628
const logger = createLogger('OAuthUtilsAPI')
@@ -222,6 +224,64 @@ export async function getServiceAccountToken(
222224
return tokenData.access_token
223225
}
224226

227+
export interface SlackBotCredentialSecrets {
228+
signingSecret: string
229+
botToken: string
230+
teamId: string
231+
botUserId?: string
232+
teamName?: string
233+
/** Owning workspace — callers with a user/workflow context must verify it. */
234+
workspaceId: string | null
235+
}
236+
237+
/**
238+
* Decrypt a reusable custom Slack bot credential — a `service_account` credential
239+
* with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own
240+
* app's signing secret + bot token + derived team_id/bot_user_id. Returns null if
241+
* the id is not such a credential (or its blob is incomplete).
242+
*
243+
* @remarks Server-internal. The native custom ingest route authenticates each
244+
* request via the app's signing secret (not a user session), so this reader does
245+
* no per-user authorization; callers with a user context authorize separately.
246+
*/
247+
export async function getSlackBotCredential(
248+
credentialId: string
249+
): Promise<SlackBotCredentialSecrets | null> {
250+
const [row] = await db
251+
.select({
252+
type: credential.type,
253+
providerId: credential.providerId,
254+
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
255+
workspaceId: credential.workspaceId,
256+
})
257+
.from(credential)
258+
.where(eq(credential.id, credentialId))
259+
.limit(1)
260+
261+
if (
262+
!row ||
263+
row.type !== 'service_account' ||
264+
row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID ||
265+
!row.encryptedServiceAccountKey
266+
) {
267+
return null
268+
}
269+
270+
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
271+
const blob = JSON.parse(decrypted) as Partial<SlackBotCredentialSecrets>
272+
if (!blob.signingSecret || !blob.botToken || !blob.teamId) {
273+
return null
274+
}
275+
return {
276+
signingSecret: blob.signingSecret,
277+
botToken: blob.botToken,
278+
teamId: blob.teamId,
279+
botUserId: blob.botUserId,
280+
teamName: blob.teamName,
281+
workspaceId: row.workspaceId ?? null,
282+
}
283+
}
284+
225285
interface AtlassianServiceAccountSecret {
226286
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
227287
apiToken: string
@@ -264,9 +324,45 @@ export async function getAtlassianServiceAccountSecret(
264324
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
265325
* No exchange or refresh is needed; we just decrypt and return the raw token.
266326
*/
267-
async function getAtlassianServiceAccountToken(credentialId: string): Promise<string> {
268-
const secret = await getAtlassianServiceAccountSecret(credentialId)
269-
return secret.apiToken
327+
export interface ServiceAccountTokenResult {
328+
accessToken: string
329+
/** Atlassian only — the resolved Jira/Confluence cloud id. */
330+
cloudId?: string
331+
/** Atlassian only — the site domain. */
332+
domain?: string
333+
}
334+
335+
/**
336+
* Single dispatch point for turning a `service_account` credential into an
337+
* access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
338+
* `POST /api/auth/oauth/token` route go through here, so a new service-account
339+
* provider is one edit and an unknown provider fails loudly instead of silently
340+
* attempting a Google JWT.
341+
*/
342+
export async function resolveServiceAccountToken(
343+
credentialId: string,
344+
providerId: string | null | undefined,
345+
scopes?: string[],
346+
impersonateEmail?: string
347+
): Promise<ServiceAccountTokenResult> {
348+
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
349+
const secret = await getAtlassianServiceAccountSecret(credentialId)
350+
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
351+
}
352+
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
353+
const botCredential = await getSlackBotCredential(credentialId)
354+
if (!botCredential) {
355+
throw new Error('Slack bot credential not found')
356+
}
357+
return { accessToken: botCredential.botToken }
358+
}
359+
if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
360+
if (!scopes?.length) {
361+
throw new Error('Scopes are required for service account credentials')
362+
}
363+
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
364+
}
365+
throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
270366
}
271367

272368
/**
@@ -511,15 +607,14 @@ export async function refreshAccessTokenIfNeeded(
511607
}
512608

513609
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
514-
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
515-
logger.info(`[${requestId}] Using Atlassian service account token for credential`)
516-
return getAtlassianServiceAccountToken(resolved.credentialId)
517-
}
518-
if (!scopes?.length) {
519-
throw new Error('Scopes are required for service account credentials')
520-
}
521610
logger.info(`[${requestId}] Using service account token for credential`)
522-
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
611+
const { accessToken } = await resolveServiceAccountToken(
612+
resolved.credentialId,
613+
resolved.providerId,
614+
scopes,
615+
impersonateEmail
616+
)
617+
return accessToken
523618
}
524619

525620
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query

apps/sim/app/api/credentials/[id]/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export const PUT = withRouteHandler(
8282
displayName: body.displayName,
8383
description: body.description,
8484
serviceAccountJson: body.serviceAccountJson,
85+
signingSecret: body.signingSecret,
86+
botToken: body.botToken,
87+
apiToken: body.apiToken,
88+
domain: body.domain,
8589
request,
8690
})
8791
if (!result.success) {
@@ -95,7 +99,13 @@ export const PUT = withRouteHandler(
9599
: result.errorCode === 'validation'
96100
? 400
97101
: 500
98-
return NextResponse.json({ error: result.error }, { status })
102+
return NextResponse.json(
103+
{
104+
error: result.error,
105+
...(result.providerErrorCode ? { code: result.providerErrorCode } : {}),
106+
},
107+
{ status }
108+
)
99109
}
100110

101111
const access = await getCredentialActorContext(id, session.user.id)

0 commit comments

Comments
 (0)