fix(slack): stop requesting the unapproved scopes, gate them behind an opt-in flag (#5977)

* fix(slack): stop requesting the unapproved mention/assistant/DM scopes

* feat(slack): gate the approval-pending scopes behind an opt-in env flag
This commit is contained in:
Theodore Li
2026-07-27 14:48:45 -04:00
committed by GitHub
parent bd61603701
commit ae49070bea
5 changed files with 40 additions and 26 deletions
+1 -23
View File
@@ -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<SlackResponse> = {
type: 'slack',
name: 'Slack',
@@ -126,7 +107,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
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',
+22
View File
@@ -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
*/
+3
View File
@@ -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,
+12 -3
View File
@@ -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<string, OAuthProviderConfig> = {
'claude-platform': {
name: 'Claude Platform',
@@ -782,9 +793,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'groups:write',
'chat:write',
'chat:write.public',
'assistant:write',
'app_mentions:read',
'im:history',
...SLACK_APPROVAL_GATED_SCOPES,
'im:write',
'im:read',
'users:read',
@@ -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,