import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('SlackChannelsAPI') interface SlackChannel { id: string name: string is_private: boolean is_archived: boolean is_member: boolean } /** * Extracts the installing user's Slack id from credentials connected after the * privacy fix, which `auth.ts` tags with a `usr_` marker * (`${teamId}-usr_${installerUserId}-${uuid}`). Legacy credentials encode the * bot id with no marker and return null, so the caller keeps the existing * `is_member` filter — no regression. */ const SCOPED_USER_ID_PATTERN = /-usr_([UW][A-Z0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i function parseScopedSlackUserId(accountId: string): string | null { const match = SCOPED_USER_ID_PATTERN.exec(accountId) if (match) return match[1] // Marker present but unparseable — surface it rather than silently falling // back to the bot `is_member` filter and bypassing the privacy scope. if (accountId.includes('-usr_')) { logger.warn('Slack accountId carries usr_ marker but did not parse; using is_member fallback', { accountId, }) } return null } export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() const parsed = await parseRequest(slackChannelsSelectorContract, request, {}) if (!parsed.success) { logger.error('Missing credential in request') return parsed.response } const { credential, workflowId } = parsed.data.body let accessToken: string let isBotToken = false let scopedUserId: string | null = null if (credential.startsWith('xoxb-')) { accessToken = credential isBotToken = true logger.info('Using direct bot token for Slack API') } else { const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId: workflowId ?? undefined, }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } const resolvedToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, requestId ) if (!resolvedToken) { logger.error('Failed to get access token', { credentialId: credential, userId: authz.credentialOwnerUserId, }) return NextResponse.json( { error: 'Could not retrieve access token', authRequired: true, }, { status: 401 } ) } accessToken = resolvedToken // resolvedCredentialId is an account.id only for OAuth credentials // (the service_account path returns a credential.id). if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) { logger.info('Using OAuth token for Slack API') const [accountRow] = await db .select({ accountId: account.accountId }) .from(account) .where(eq(account.id, authz.resolvedCredentialId)) .limit(1) if (accountRow) { scopedUserId = parseScopedSlackUserId(accountRow.accountId) } } else { // A custom-bot service_account credential resolves to a bot token with // no scoped user; treat it like a direct bot token so the private -> // public channel fallback applies. isBotToken = true logger.info('Using custom bot token for Slack API') } } let data: SlackConversationsResult try { data = await fetchSlackChannels(accessToken, true) if (data.truncated) { logger.warn('conversations.list hit pagination cap; channel list may be incomplete') } logger.info('Successfully fetched channels including private channels') } catch (error) { if (isBotToken) { logger.warn( 'Failed to fetch private channels with bot token, falling back to public channels only:', (error as Error).message ) try { data = await fetchSlackChannels(accessToken, false) logger.info('Successfully fetched public channels only') } catch (fallbackError) { logger.error('Failed to fetch channels even with public-only fallback:', fallbackError) return NextResponse.json( { error: `Slack API error: ${(fallbackError as Error).message}` }, { status: 400 } ) } } else { logger.error('Slack API error with OAuth token:', error) return NextResponse.json( { error: `Slack API error: ${(error as Error).message}` }, { status: 400 } ) } } /** * Slack Marketplace privacy: a private channel may only be shown to a user * whose own Slack account is a member, even when the bot has been invited. * `users.conversations?user=` returns the channels the bot AND that user * share, giving us the allowed set. Public channels are never restricted. * Without a scoped user id (legacy credentials), fall back to bot membership. */ let allowedPrivateChannelIds: Set | null = null if (scopedUserId) { try { const userPrivate = await fetchUserPrivateChannels(accessToken, scopedUserId) allowedPrivateChannelIds = new Set(userPrivate.channels.map((c) => c.id)) if (userPrivate.truncated) { logger.warn( 'users.conversations hit pagination cap; some private channels the user belongs to may be hidden', { scopedUserId } ) } logger.info('Scoped private channels to installing user membership', { scopedUserId, allowedCount: allowedPrivateChannelIds.size, }) } catch (scopeError) { // Fail closed: if membership can't be verified, hide all private channels. logger.warn('Failed to scope private channels to user, hiding all private channels', { error: (scopeError as Error).message, }) allowedPrivateChannelIds = new Set() } } const channels = (data.channels || []) .filter((channel: SlackChannel) => { if (channel.is_archived) return false if (channel.is_private) { if (allowedPrivateChannelIds) { return allowedPrivateChannelIds.has(channel.id) } return channel.is_member } return true }) .filter((channel: SlackChannel) => { const validation = validateAlphanumericId(channel.id, 'channelId', 50) if (!validation.isValid) { logger.warn('Invalid channel ID received from Slack API', { channelId: channel.id, channelName: channel.name, error: validation.error, }) return false } if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) { logger.warn('Channel ID does not match Slack format', { channelId: channel.id, channelName: channel.name, }) return false } return true }) .map((channel: SlackChannel) => ({ id: channel.id, name: channel.name, isPrivate: channel.is_private, })) logger.info(`Successfully fetched ${channels.length} Slack channels`, { total: data.channels?.length || 0, private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length, public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length, tokenType: isBotToken ? 'bot_token' : 'oauth', userScoped: !!scopedUserId, }) return NextResponse.json({ channels }) } catch (error) { logger.error('Error processing Slack channels request:', error) return NextResponse.json( { error: 'Failed to retrieve Slack channels', details: (error as Error).message }, { status: 500 } ) } }) const SLACK_PAGE_LIMIT = 200 const SLACK_MAX_PAGES = 10 interface SlackConversationsResult { channels: SlackChannel[] truncated: boolean } /** * Lists Slack conversations, following `response_metadata.next_cursor` so the * full set is returned. Bounded by `SLACK_MAX_PAGES`; sets `truncated` rather * than silently dropping channels when the cap is hit. */ async function fetchAllConversations( method: 'conversations.list' | 'users.conversations', accessToken: string, params: Record ): Promise { const channels: SlackChannel[] = [] let cursor: string | undefined let truncated = false for (let page = 0; page < SLACK_MAX_PAGES; page++) { const url = new URL(`https://slack.com/api/${method}`) for (const [key, value] of Object.entries(params)) { url.searchParams.append(key, value) } url.searchParams.append('limit', String(SLACK_PAGE_LIMIT)) if (cursor) { url.searchParams.append('cursor', cursor) } const response = await fetch(url.toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { throw new Error(`Slack API error: ${response.status} ${response.statusText}`) } const data = await response.json() if (!data.ok) { throw new Error(data.error || `Failed to fetch ${method}`) } if (Array.isArray(data.channels)) { channels.push(...data.channels) } cursor = data.response_metadata?.next_cursor?.trim() || undefined if (!cursor) { return { channels, truncated } } if (page === SLACK_MAX_PAGES - 1) { truncated = true } } return { channels, truncated } } async function fetchSlackChannels( accessToken: string, includePrivate = true ): Promise { return fetchAllConversations('conversations.list', accessToken, { types: includePrivate ? 'public_channel,private_channel' : 'public_channel', exclude_archived: 'true', }) } async function fetchUserPrivateChannels( accessToken: string, userId: string ): Promise { return fetchAllConversations('users.conversations', accessToken, { user: userId, types: 'private_channel', exclude_archived: 'true', }) }