diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 0cdb2266280..1792db96c52 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -7,25 +7,6 @@ import { normalizeFileInput } from '@/blocks/utils' import type { SlackResponse } from '@/tools/slack/types' import { getTrigger } from '@/triggers' -/** - * Scopes added for the native Sim Slack app trigger (`slack_oauth`): the shared - * app's `app_mention`, assistant-thread, and DM events don't deliver without - * them. Advertised by `slack_v2` and the trigger only — the legacy block has no - * feature that needs them, and listing them there would flag every existing - * Slack credential as missing scopes and prompt a reconnect. - * - * This only controls what each picker *advertises* and treats as missing. The - * authorization request itself is provider-wide - * (`getCanonicalScopesForProvider('slack')`), so any reconnect grants the full - * set regardless of which block started it. - */ -const SLACK_V2_ONLY_SCOPES = new Set(['app_mentions:read', 'assistant:write', 'im:history']) - -/** Slack scopes the legacy v1 block advertises — the set from before the trigger expansion. */ -const SLACK_V1_ADVERTISED_SCOPES = getScopesForService('slack').filter( - (scope) => !SLACK_V2_ONLY_SCOPES.has(scope) -) - export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', @@ -126,7 +107,7 @@ export const SlackBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'slack', - requiredScopes: SLACK_V1_ADVERTISED_SCOPES, + requiredScopes: getScopesForService('slack'), placeholder: 'Select Slack workspace', dependsOn: ['authMethod'], condition: { @@ -2661,9 +2642,6 @@ function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { ...rest, credentialKind: 'any', placeholder: 'Select Slack account or bot', - // Full set, unlike v1: v2 hosts the native Sim app trigger, whose events - // need the mention/assistant/DM scopes. - requiredScopes: getScopesForService('slack'), credentialLabels: { oauthGroup: 'Sim app', oauthConnect: 'Connect the Sim app', diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 4df5b046c36..23960cea260 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -144,6 +144,28 @@ export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENA export const isAppConfigEnabled = isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) +/** + * Whether the deployment's Slack app is approved for `app_mentions:read`, + * `assistant:write`, and `im:history` — the scopes backing the native Sim app + * trigger's mention, assistant-thread, and DM events. + * + * Off by default because Slack rejects the ENTIRE authorization when it requests + * a scope the app is not approved for, breaking every Slack connect. Sim Cloud's + * app is directory-listed and pinned to its review-approved set, so this stays + * off there until review lands; a self-hosted deployment pointing at its own + * (unlisted) Slack app can opt in. + * + * Server code reads `SLACK_EXTENDED_SCOPES`. Server-only vars never reach browser + * bundles, so client evaluation reads the `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` + * twin (see {@link isBillingEnabled}) — deployments must set both together. The + * server value decides the grant; the client value only decides what the credential + * pickers advertise and treat as missing. + */ +export const isSlackExtendedScopesEnabled = + typeof window === 'undefined' + ? isTruthy(env.SLACK_EXTENDED_SCOPES) + : isTruthy(getEnv('NEXT_PUBLIC_SLACK_EXTENDED_SCOPES')) + /** * Is Trigger.dev enabled for async job processing */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 76b87384171..a97d6854e33 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -421,6 +421,7 @@ export const env = createEnv({ SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger) + SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Request app_mentions:read, assistant:write, im:history — only where the Slack app is approved for them REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID @@ -571,6 +572,7 @@ export const env = createEnv({ // Feature Flags NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted + NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Client twin of SLACK_EXTENDED_SCOPES — set both together NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements) @@ -612,6 +614,7 @@ export const env = createEnv({ NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR, NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED, NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED, + NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: process.env.NEXT_PUBLIC_SLACK_EXTENDED_SCOPES, NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED, NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED, NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED, diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 1d74cac2b63..747b607a7f1 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -57,6 +57,7 @@ import { ZoomIcon, } from '@/components/icons' import { env } from '@/lib/core/config/env' +import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' import { DEFAULT_MAX_ERROR_BODY_BYTES, readResponseTextWithLimit, @@ -66,6 +67,16 @@ import type { OAuthProviderConfig } from './types' const logger = createLogger('OAuth') +/** + * Slack scopes requested only where the app is approved for them, gated by + * {@link isSlackExtendedScopesEnabled}. Slack rejects the entire authorization + * with "unapproved permissions requested" when any requested scope is not on the + * app's approved list, so these stay out of the default grant. + */ +const SLACK_APPROVAL_GATED_SCOPES = isSlackExtendedScopesEnabled + ? (['assistant:write', 'app_mentions:read', 'im:history'] as const) + : ([] as const) + export const OAUTH_PROVIDERS: Record = { 'claude-platform': { name: 'Claude Platform', @@ -782,9 +793,7 @@ export const OAUTH_PROVIDERS: Record = { 'groups:write', 'chat:write', 'chat:write.public', - 'assistant:write', - 'app_mentions:read', - 'im:history', + ...SLACK_APPROVAL_GATED_SCOPES, 'im:write', 'im:read', 'users:read', diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 5800e2860ac..69b721e7b8c 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -21,6 +21,7 @@ export interface EnvFlagsMockState { isEmailPasswordEnabled: boolean isSignupMxValidationEnabled: boolean isAppConfigEnabled: boolean + isSlackExtendedScopesEnabled: boolean isTriggerDevEnabled: boolean isSsoEnabled: boolean isAccessControlEnabled: boolean @@ -61,6 +62,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isEmailPasswordEnabled: true, isSignupMxValidationEnabled: false, isAppConfigEnabled: false, + isSlackExtendedScopesEnabled: false, isTriggerDevEnabled: false, isSsoEnabled: false, isAccessControlEnabled: false,