Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a518e43
feat(slack): enable assistant-agent tools via assistant:write scope
TheodoreSpeaks Jul 1, 2026
ec1eb62
Add slack trigger
TheodoreSpeaks Jul 1, 2026
5a36f48
fix channel picker in slack trigger
TheodoreSpeaks Jul 1, 2026
7efbfd3
improvement(slack-trigger): reorder app type, gate account to sim mod…
TheodoreSpeaks Jul 1, 2026
4886d1d
Merge remote-tracking branch 'origin/staging' into improve/slack
TheodoreSpeaks Jul 1, 2026
71beec4
fix(slack-trigger): drop unmapped events from filter, resolve oauth t…
TheodoreSpeaks Jul 1, 2026
a0e723b
fix(slack-trigger): empty operation selection fires nothing; resolve …
TheodoreSpeaks Jul 1, 2026
310061d
fix(slack-trigger): ignore message edit/delete/system subtypes; prefe…
TheodoreSpeaks Jul 1, 2026
65c1415
feat(slack-trigger): single-event model with contextual filters and f…
TheodoreSpeaks Jul 7, 2026
9de9952
fix(slack-trigger): apply event/channel/bot filters on custom-app pat…
TheodoreSpeaks Jul 7, 2026
5ac0932
fix(slack-trigger): don't drop edit/delete events when channel_type i…
TheodoreSpeaks Jul 7, 2026
4692708
Merge remote-tracking branch 'origin/staging' into improve/slack
TheodoreSpeaks Jul 7, 2026
9dfe5d5
feat(slack): reusable custom bot credentials, slack_v2 block, interac…
TheodoreSpeaks Jul 10, 2026
039a3f3
Merge remote-tracking branch 'origin/staging' into improve/slack
TheodoreSpeaks Jul 10, 2026
1c5be32
chore(api-validation): bump route baseline to 924 after staging merge
TheodoreSpeaks Jul 10, 2026
8d40d3f
feat(slack): preview-gate slack_v2 and the custom-bot credential surf…
TheodoreSpeaks Jul 10, 2026
d4ae64c
fix(slack): v1 keeps slack_webhook trigger subblocks; handle object-f…
TheodoreSpeaks Jul 10, 2026
d0ed86d
fix(slack): default absent appType to custom at deploy; deactivate cu…
TheodoreSpeaks Jul 10, 2026
ec06db5
fix(slack): resolve credential owner for deploy-time team_id lookup
TheodoreSpeaks Jul 10, 2026
26217bf
Merge remote-tracking branch 'origin/staging' into improve/slack
TheodoreSpeaks Jul 10, 2026
67f54df
chore(slack): reconcile staging merge
TheodoreSpeaks Jul 10, 2026
987ba13
fix(slack): workspace-scope bot credentials at deploy; recreate webho…
TheodoreSpeaks Jul 10, 2026
9146a33
test(slack): pin fail-closed behavior for empty/missing event selection
TheodoreSpeaks Jul 10, 2026
b77dd69
fix(slack): 409 on custom-bot name collision instead of silently retu…
TheodoreSpeaks Jul 10, 2026
470e3c5
fix(slack): reconnect surfaces Atlassian error codes and persists nam…
TheodoreSpeaks Jul 10, 2026
58d2851
fix(slack): require bot name; propagate rotated bot_user_id to webhoo…
TheodoreSpeaks Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/docs/components/workflow-preview/format-references.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
<span key={index} className='text-[var(--brand-secondary)]'>
{part}
</span>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
<span key={index}>{part}</span>
)
})
Expand Down
28 changes: 11 additions & 17 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getAtlassianServiceAccountSecret,
getCredential,
getOAuthToken,
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'

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

try {
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
emitServiceAccountAccess()
return NextResponse.json(
{
accessToken: secret.apiToken,
cloudId: secret.cloudId,
domain: secret.domain,
},
{ status: 200 }
)
}
const accessToken = await getServiceAccountToken(
const result = await resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes ?? [],
impersonateEmail
)
emitServiceAccountAccess()
return NextResponse.json({ accessToken }, { status: 200 })
return NextResponse.json(
{
accessToken: result.accessToken,
cloudId: result.cloudId,
domain: result.domain,
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
Expand Down
69 changes: 69 additions & 0 deletions apps/sim/app/api/auth/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,25 @@ vi.mock('@/lib/oauth/oauth', () => ({

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

const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })),
}))

import { db } from '@sim/db'
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
import { refreshOAuthToken } from '@/lib/oauth'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
import {
getCredential,
refreshAccessTokenIfNeeded,
refreshTokenIfNeeded,
resolveServiceAccountToken,
} from '@/app/api/auth/oauth/utils'

const mockDb = db as any
Expand Down Expand Up @@ -262,4 +274,61 @@ describe('OAuth Utils', () => {
expect(token).toBeNull()
})
})

describe('resolveServiceAccountToken', () => {
it('throws loudly for an unknown provider (never silently attempts Google)', async () => {
await expect(resolveServiceAccountToken('cred-1', 'mystery-provider')).rejects.toThrow(
/Unsupported service-account provider/
)
})

it('returns the decrypted bot token for a custom Slack bot', async () => {
mockSelectChain([
{
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
encryptedServiceAccountKey: 'enc',
},
])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({ signingSecret: 's', botToken: 'xoxb-tok', teamId: 'T1' }),
})
const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
expect(result.accessToken).toBe('xoxb-tok')
})

it('throws when the Slack bot credential is missing', async () => {
mockSelectChain([])
await expect(
resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
).rejects.toThrow(/Slack bot credential not found/)
})

it('returns apiToken + cloudId + domain for Atlassian', async () => {
mockSelectChain([{ encryptedServiceAccountKey: 'enc' }])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({
type: 'atlassian_service_account',
apiToken: 'atk',
domain: 'acme.atlassian.net',
cloudId: 'cloud-1',
}),
})
const result = await resolveServiceAccountToken(
'cred-1',
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
)
expect(result).toMatchObject({
accessToken: 'atk',
cloudId: 'cloud-1',
domain: 'acme.atlassian.net',
})
})

it('requires scopes for a Google service account', async () => {
await expect(
resolveServiceAccountToken('cred-1', GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, [])
).rejects.toThrow(/Scopes are required/)
})
})
})
117 changes: 106 additions & 11 deletions apps/sim/app/api/auth/oauth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'

const logger = createLogger('OAuthUtilsAPI')
Expand Down Expand Up @@ -222,6 +224,64 @@ export async function getServiceAccountToken(
return tokenData.access_token
}

export interface SlackBotCredentialSecrets {
signingSecret: string
botToken: string
teamId: string
botUserId?: string
teamName?: string
/** Owning workspace — callers with a user/workflow context must verify it. */
workspaceId: string | null
}

/**
* Decrypt a reusable custom Slack bot credential — a `service_account` credential
* with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own
* app's signing secret + bot token + derived team_id/bot_user_id. Returns null if
* the id is not such a credential (or its blob is incomplete).
*
* @remarks Server-internal. The native custom ingest route authenticates each
* request via the app's signing secret (not a user session), so this reader does
* no per-user authorization; callers with a user context authorize separately.
*/
export async function getSlackBotCredential(
credentialId: string
): Promise<SlackBotCredentialSecrets | null> {
const [row] = await db
.select({
type: credential.type,
providerId: credential.providerId,
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)

if (
!row ||
row.type !== 'service_account' ||
row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID ||
!row.encryptedServiceAccountKey
) {
return null
}

const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
const blob = JSON.parse(decrypted) as Partial<SlackBotCredentialSecrets>
if (!blob.signingSecret || !blob.botToken || !blob.teamId) {
return null
}
return {
signingSecret: blob.signingSecret,
botToken: blob.botToken,
teamId: blob.teamId,
botUserId: blob.botUserId,
teamName: blob.teamName,
workspaceId: row.workspaceId ?? null,
}
}

interface AtlassianServiceAccountSecret {
type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
Expand Down Expand Up @@ -264,9 +324,45 @@ export async function getAtlassianServiceAccountSecret(
* blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
* No exchange or refresh is needed; we just decrypt and return the raw token.
*/
async function getAtlassianServiceAccountToken(credentialId: string): Promise<string> {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return secret.apiToken
export interface ServiceAccountTokenResult {
accessToken: string
/** Atlassian only — the resolved Jira/Confluence cloud id. */
cloudId?: string
/** Atlassian only — the site domain. */
domain?: string
}

/**
* Single dispatch point for turning a `service_account` credential into an
* access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
* `POST /api/auth/oauth/token` route go through here, so a new service-account
* provider is one edit and an unknown provider fails loudly instead of silently
* attempting a Google JWT.
*/
export async function resolveServiceAccountToken(
credentialId: string,
providerId: string | null | undefined,
scopes?: string[],
impersonateEmail?: string
): Promise<ServiceAccountTokenResult> {
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
}
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
}
if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
}
throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
}

/**
Expand Down Expand Up @@ -511,15 +607,14 @@ export async function refreshAccessTokenIfNeeded(
}

if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
logger.info(`[${requestId}] Using Atlassian service account token for credential`)
return getAtlassianServiceAccountToken(resolved.credentialId)
}
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
logger.info(`[${requestId}] Using service account token for credential`)
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
const { accessToken } = await resolveServiceAccountToken(
resolved.credentialId,
resolved.providerId,
scopes,
impersonateEmail
)
return accessToken
}

// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/app/api/credentials/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export const PUT = withRouteHandler(
displayName: body.displayName,
description: body.description,
serviceAccountJson: body.serviceAccountJson,
signingSecret: body.signingSecret,
botToken: body.botToken,
apiToken: body.apiToken,
domain: body.domain,
Comment thread
cursor[bot] marked this conversation as resolved.
request,
})
if (!result.success) {
Expand All @@ -95,7 +99,13 @@ export const PUT = withRouteHandler(
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json({ error: result.error }, { status })
return NextResponse.json(
{
error: result.error,
...(result.providerErrorCode ? { code: result.providerErrorCode } : {}),
},
{ status }
)
}

const access = await getCredentialActorContext(id, session.user.id)
Expand Down
Loading
Loading