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,29 @@ 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 ] [ 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+ if ( match ) return match [ 1 ]
39+ // Marker present but unparseable — surface it rather than silently falling
40+ // back to the bot `is_member` filter and bypassing the privacy scope.
41+ if ( accountId . includes ( '-usr_' ) ) {
42+ logger . warn ( 'Slack accountId carries usr_ marker but did not parse; using is_member fallback' , {
43+ accountId,
44+ } )
45+ }
46+ return null
47+ }
48+
2349export const POST = withRouteHandler ( async ( request : NextRequest ) => {
2450 try {
2551 const requestId = generateRequestId ( )
@@ -32,6 +58,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3258
3359 let accessToken : string
3460 let isBotToken = false
61+ let scopedUserId : string | null = null
3562
3663 if ( credential . startsWith ( 'xoxb-' ) ) {
3764 accessToken = credential
@@ -65,11 +92,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6592 }
6693 accessToken = resolvedToken
6794 logger . info ( 'Using OAuth token for Slack API' )
95+
96+ if ( authz . resolvedCredentialId ) {
97+ const [ accountRow ] = await db
98+ . select ( { accountId : account . accountId } )
99+ . from ( account )
100+ . where ( eq ( account . id , authz . resolvedCredentialId ) )
101+ . limit ( 1 )
102+ if ( accountRow ) {
103+ scopedUserId = parseScopedSlackUserId ( accountRow . accountId )
104+ }
105+ }
68106 }
69107
70- let data
108+ let data : SlackConversationsResult
71109 try {
72110 data = await fetchSlackChannels ( accessToken , true )
111+ if ( data . truncated ) {
112+ logger . warn ( 'conversations.list hit pagination cap; channel list may be incomplete' )
113+ }
73114 logger . info ( 'Successfully fetched channels including private channels' )
74115 } catch ( error ) {
75116 if ( isBotToken ) {
@@ -96,17 +137,49 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
96137 }
97138 }
98139
140+ /**
141+ * Slack Marketplace privacy: a private channel may only be shown to a user
142+ * whose own Slack account is a member, even when the bot has been invited.
143+ * `users.conversations?user=` returns the channels the bot AND that user
144+ * share, giving us the allowed set. Public channels are never restricted.
145+ * Without a scoped user id (legacy credentials), fall back to bot membership.
146+ */
147+ let allowedPrivateChannelIds : Set < string > | null = null
148+ if ( scopedUserId ) {
149+ try {
150+ const userPrivate = await fetchUserPrivateChannels ( accessToken , scopedUserId )
151+ allowedPrivateChannelIds = new Set ( userPrivate . channels . map ( ( c ) => c . id ) )
152+ if ( userPrivate . truncated ) {
153+ logger . warn (
154+ 'users.conversations hit pagination cap; some private channels the user belongs to may be hidden' ,
155+ { scopedUserId }
156+ )
157+ }
158+ logger . info ( 'Scoped private channels to installing user membership' , {
159+ scopedUserId,
160+ allowedCount : allowedPrivateChannelIds . size ,
161+ } )
162+ } catch ( scopeError ) {
163+ // Fail closed: if membership can't be verified, hide all private channels.
164+ logger . warn ( 'Failed to scope private channels to user, hiding all private channels' , {
165+ error : ( scopeError as Error ) . message ,
166+ } )
167+ allowedPrivateChannelIds = new Set ( )
168+ }
169+ }
170+
99171 const channels = ( data . channels || [ ] )
100172 . filter ( ( channel : SlackChannel ) => {
101- const canAccess = ! channel . is_archived && ( channel . is_member || ! channel . is_private )
173+ if ( channel . is_archived ) return false
102174
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- )
175+ if ( channel . is_private ) {
176+ if ( allowedPrivateChannelIds ) {
177+ return allowedPrivateChannelIds . has ( channel . id )
178+ }
179+ return channel . is_member
107180 }
108181
109- return canAccess
182+ return true
110183 } )
111184 . filter ( ( channel : SlackChannel ) => {
112185 const validation = validateAlphanumericId ( channel . id , 'channelId' , 50 )
@@ -141,6 +214,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
141214 private : channels . filter ( ( c : { isPrivate : boolean } ) => c . isPrivate ) . length ,
142215 public : channels . filter ( ( c : { isPrivate : boolean } ) => ! c . isPrivate ) . length ,
143216 tokenType : isBotToken ? 'bot_token' : 'oauth' ,
217+ userScoped : ! ! scopedUserId ,
144218 } )
145219 return NextResponse . json ( { channels } )
146220 } catch ( error ) {
@@ -152,35 +226,86 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
152226 }
153227} )
154228
155- async function fetchSlackChannels ( accessToken : string , includePrivate = true ) {
156- const url = new URL ( 'https://slack.com/api/conversations.list' )
229+ const SLACK_PAGE_LIMIT = 200
230+ const SLACK_MAX_PAGES = 10
157231
158- if ( includePrivate ) {
159- url . searchParams . append ( 'types' , 'public_channel,private_channel' )
160- } else {
161- url . searchParams . append ( 'types' , 'public_channel' )
162- }
232+ interface SlackConversationsResult {
233+ channels : SlackChannel [ ]
234+ truncated : boolean
235+ }
163236
164- url . searchParams . append ( 'exclude_archived' , 'true' )
165- url . searchParams . append ( 'limit' , '200' )
237+ /**
238+ * Lists Slack conversations, following `response_metadata.next_cursor` so the
239+ * full set is returned. Bounded by `SLACK_MAX_PAGES`; sets `truncated` rather
240+ * than silently dropping channels when the cap is hit.
241+ */
242+ async function fetchAllConversations (
243+ method : 'conversations.list' | 'users.conversations' ,
244+ accessToken : string ,
245+ params : Record < string , string >
246+ ) : Promise < SlackConversationsResult > {
247+ const channels : SlackChannel [ ] = [ ]
248+ let cursor : string | undefined
249+ let truncated = false
166250
167- const response = await fetch ( url . toString ( ) , {
168- method : 'GET' ,
169- headers : {
170- Authorization : `Bearer ${ accessToken } ` ,
171- 'Content-Type' : 'application/json' ,
172- } ,
173- } )
251+ for ( let page = 0 ; page < SLACK_MAX_PAGES ; page ++ ) {
252+ const url = new URL ( `https://slack.com/api/${ method } ` )
253+ for ( const [ key , value ] of Object . entries ( params ) ) {
254+ url . searchParams . append ( key , value )
255+ }
256+ url . searchParams . append ( 'limit' , String ( SLACK_PAGE_LIMIT ) )
257+ if ( cursor ) {
258+ url . searchParams . append ( 'cursor' , cursor )
259+ }
174260
175- if ( ! response . ok ) {
176- throw new Error ( `Slack API error: ${ response . status } ${ response . statusText } ` )
177- }
261+ const response = await fetch ( url . toString ( ) , {
262+ method : 'GET' ,
263+ headers : { Authorization : `Bearer ${ accessToken } ` } ,
264+ } )
178265
179- const data = await response . json ( )
266+ if ( ! response . ok ) {
267+ throw new Error ( `Slack API error: ${ response . status } ${ response . statusText } ` )
268+ }
269+
270+ const data = await response . json ( )
180271
181- if ( ! data . ok ) {
182- throw new Error ( data . error || 'Failed to fetch channels' )
272+ if ( ! data . ok ) {
273+ throw new Error ( data . error || `Failed to fetch ${ method } ` )
274+ }
275+
276+ if ( Array . isArray ( data . channels ) ) {
277+ channels . push ( ...data . channels )
278+ }
279+
280+ cursor = data . response_metadata ?. next_cursor ?. trim ( ) || undefined
281+ if ( ! cursor ) {
282+ return { channels, truncated }
283+ }
284+ if ( page === SLACK_MAX_PAGES - 1 ) {
285+ truncated = true
286+ }
183287 }
184288
185- return data
289+ return { channels, truncated }
290+ }
291+
292+ async function fetchSlackChannels (
293+ accessToken : string ,
294+ includePrivate = true
295+ ) : Promise < SlackConversationsResult > {
296+ return fetchAllConversations ( 'conversations.list' , accessToken , {
297+ types : includePrivate ? 'public_channel,private_channel' : 'public_channel' ,
298+ exclude_archived : 'true' ,
299+ } )
300+ }
301+
302+ async function fetchUserPrivateChannels (
303+ accessToken : string ,
304+ userId : string
305+ ) : Promise < SlackConversationsResult > {
306+ return fetchAllConversations ( 'users.conversations' , accessToken , {
307+ user : userId ,
308+ types : 'private_channel' ,
309+ exclude_archived : 'true' ,
310+ } )
186311}
0 commit comments