1+ import { db } from '@sim/db'
2+ import { account } from '@sim/db/schema'
13import { createLogger } from '@sim/logger'
4+ import { eq } from 'drizzle-orm'
25import { type NextRequest , NextResponse } from 'next/server'
36import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack'
47import { parseRequest } from '@/lib/api/server'
@@ -20,6 +23,21 @@ interface SlackChannel {
2023 is_member : boolean
2124}
2225
26+ /**
27+ * Extracts the installing user's Slack id from credentials connected after the
28+ * privacy fix, which `auth.ts` tags with a `usr_` marker
29+ * (`${teamId}-usr_${installerUserId}-${uuid}`). Legacy credentials encode the
30+ * bot id with no marker and return null, so the caller keeps the existing
31+ * `is_member` filter — no regression.
32+ */
33+ const SCOPED_USER_ID_PATTERN =
34+ / - u s r _ ( [ U W B ] [ A - Z 0 - 9 ] + ) - [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / i
35+
36+ function parseScopedSlackUserId ( accountId : string ) : string | null {
37+ const match = SCOPED_USER_ID_PATTERN . exec ( accountId )
38+ return match ? match [ 1 ] : null
39+ }
40+
2341export const POST = withRouteHandler ( async ( request : NextRequest ) => {
2442 try {
2543 const requestId = generateRequestId ( )
@@ -32,6 +50,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3250
3351 let accessToken : string
3452 let isBotToken = false
53+ let scopedUserId : string | null = null
3554
3655 if ( credential . startsWith ( 'xoxb-' ) ) {
3756 accessToken = credential
@@ -65,11 +84,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6584 }
6685 accessToken = resolvedToken
6786 logger . info ( 'Using OAuth token for Slack API' )
87+
88+ if ( authz . resolvedCredentialId ) {
89+ const [ accountRow ] = await db
90+ . select ( { accountId : account . accountId } )
91+ . from ( account )
92+ . where ( eq ( account . id , authz . resolvedCredentialId ) )
93+ . limit ( 1 )
94+ if ( accountRow ) {
95+ scopedUserId = parseScopedSlackUserId ( accountRow . accountId )
96+ }
97+ }
6898 }
6999
70- let data
100+ let data : SlackConversationsResult
71101 try {
72102 data = await fetchSlackChannels ( accessToken , true )
103+ if ( data . truncated ) {
104+ logger . warn ( 'conversations.list hit pagination cap; channel list may be incomplete' )
105+ }
73106 logger . info ( 'Successfully fetched channels including private channels' )
74107 } catch ( error ) {
75108 if ( isBotToken ) {
@@ -96,17 +129,49 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
96129 }
97130 }
98131
132+ /**
133+ * Slack Marketplace privacy: a private channel may only be shown to a user
134+ * whose own Slack account is a member, even when the bot has been invited.
135+ * `users.conversations?user=` returns the channels the bot AND that user
136+ * share, giving us the allowed set. Public channels are never restricted.
137+ * Without a scoped user id (legacy credentials), fall back to bot membership.
138+ */
139+ let allowedPrivateChannelIds : Set < string > | null = null
140+ if ( scopedUserId ) {
141+ try {
142+ const userPrivate = await fetchUserPrivateChannels ( accessToken , scopedUserId )
143+ allowedPrivateChannelIds = new Set ( userPrivate . channels . map ( ( c ) => c . id ) )
144+ if ( userPrivate . truncated ) {
145+ logger . warn (
146+ 'users.conversations hit pagination cap; some private channels the user belongs to may be hidden' ,
147+ { scopedUserId }
148+ )
149+ }
150+ logger . info ( 'Scoped private channels to installing user membership' , {
151+ scopedUserId,
152+ allowedCount : allowedPrivateChannelIds . size ,
153+ } )
154+ } catch ( scopeError ) {
155+ // Fail closed: if membership can't be verified, hide all private channels.
156+ logger . warn ( 'Failed to scope private channels to user, hiding all private channels' , {
157+ error : ( scopeError as Error ) . message ,
158+ } )
159+ allowedPrivateChannelIds = new Set ( )
160+ }
161+ }
162+
99163 const channels = ( data . channels || [ ] )
100164 . filter ( ( channel : SlackChannel ) => {
101- const canAccess = ! channel . is_archived && ( channel . is_member || ! channel . is_private )
165+ if ( channel . is_archived ) return false
102166
103- if ( ! canAccess ) {
104- logger . debug (
105- `Filtering out channel: ${ channel . name } (archived: ${ channel . is_archived } , private: ${ channel . is_private } , member: ${ channel . is_member } )`
106- )
167+ if ( channel . is_private ) {
168+ if ( allowedPrivateChannelIds ) {
169+ return allowedPrivateChannelIds . has ( channel . id )
170+ }
171+ return channel . is_member
107172 }
108173
109- return canAccess
174+ return true
110175 } )
111176 . filter ( ( channel : SlackChannel ) => {
112177 const validation = validateAlphanumericId ( channel . id , 'channelId' , 50 )
@@ -141,6 +206,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
141206 private : channels . filter ( ( c : { isPrivate : boolean } ) => c . isPrivate ) . length ,
142207 public : channels . filter ( ( c : { isPrivate : boolean } ) => ! c . isPrivate ) . length ,
143208 tokenType : isBotToken ? 'bot_token' : 'oauth' ,
209+ userScoped : ! ! scopedUserId ,
144210 } )
145211 return NextResponse . json ( { channels } )
146212 } catch ( error ) {
@@ -152,35 +218,86 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
152218 }
153219} )
154220
155- async function fetchSlackChannels ( accessToken : string , includePrivate = true ) {
156- const url = new URL ( 'https://slack.com/api/conversations.list' )
221+ const SLACK_PAGE_LIMIT = 200
222+ const SLACK_MAX_PAGES = 10
157223
158- if ( includePrivate ) {
159- url . searchParams . append ( 'types' , 'public_channel,private_channel' )
160- } else {
161- url . searchParams . append ( 'types' , 'public_channel' )
162- }
224+ interface SlackConversationsResult {
225+ channels : SlackChannel [ ]
226+ truncated : boolean
227+ }
163228
164- url . searchParams . append ( 'exclude_archived' , 'true' )
165- url . searchParams . append ( 'limit' , '200' )
229+ /**
230+ * Lists Slack conversations, following `response_metadata.next_cursor` so the
231+ * full set is returned. Bounded by `SLACK_MAX_PAGES`; sets `truncated` rather
232+ * than silently dropping channels when the cap is hit.
233+ */
234+ async function fetchAllConversations (
235+ method : 'conversations.list' | 'users.conversations' ,
236+ accessToken : string ,
237+ params : Record < string , string >
238+ ) : Promise < SlackConversationsResult > {
239+ const channels : SlackChannel [ ] = [ ]
240+ let cursor : string | undefined
241+ let truncated = false
166242
167- const response = await fetch ( url . toString ( ) , {
168- method : 'GET' ,
169- headers : {
170- Authorization : `Bearer ${ accessToken } ` ,
171- 'Content-Type' : 'application/json' ,
172- } ,
173- } )
243+ for ( let page = 0 ; page < SLACK_MAX_PAGES ; page ++ ) {
244+ const url = new URL ( `https://slack.com/api/${ method } ` )
245+ for ( const [ key , value ] of Object . entries ( params ) ) {
246+ url . searchParams . append ( key , value )
247+ }
248+ url . searchParams . append ( 'limit' , String ( SLACK_PAGE_LIMIT ) )
249+ if ( cursor ) {
250+ url . searchParams . append ( 'cursor' , cursor )
251+ }
174252
175- if ( ! response . ok ) {
176- throw new Error ( `Slack API error: ${ response . status } ${ response . statusText } ` )
177- }
253+ const response = await fetch ( url . toString ( ) , {
254+ method : 'GET' ,
255+ headers : { Authorization : `Bearer ${ accessToken } ` } ,
256+ } )
257+
258+ if ( ! response . ok ) {
259+ throw new Error ( `Slack API error: ${ response . status } ${ response . statusText } ` )
260+ }
261+
262+ const data = await response . json ( )
263+
264+ if ( ! data . ok ) {
265+ throw new Error ( data . error || `Failed to fetch ${ method } ` )
266+ }
178267
179- const data = await response . json ( )
268+ if ( Array . isArray ( data . channels ) ) {
269+ channels . push ( ...data . channels )
270+ }
180271
181- if ( ! data . ok ) {
182- throw new Error ( data . error || 'Failed to fetch channels' )
272+ cursor = data . response_metadata ?. next_cursor ?. trim ( ) || undefined
273+ if ( ! cursor ) {
274+ return { channels, truncated }
275+ }
276+ if ( page === SLACK_MAX_PAGES - 1 ) {
277+ truncated = true
278+ }
183279 }
184280
185- return data
281+ return { channels, truncated }
282+ }
283+
284+ async function fetchSlackChannels (
285+ accessToken : string ,
286+ includePrivate = true
287+ ) : Promise < SlackConversationsResult > {
288+ return fetchAllConversations ( 'conversations.list' , accessToken , {
289+ types : includePrivate ? 'public_channel,private_channel' : 'public_channel' ,
290+ exclude_archived : 'true' ,
291+ } )
292+ }
293+
294+ async function fetchUserPrivateChannels (
295+ accessToken : string ,
296+ userId : string
297+ ) : Promise < SlackConversationsResult > {
298+ return fetchAllConversations ( 'users.conversations' , accessToken , {
299+ user : userId ,
300+ types : 'private_channel' ,
301+ exclude_archived : 'true' ,
302+ } )
186303}
0 commit comments