chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
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<string> | 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<string, string>
|
||||
): Promise<SlackConversationsResult> {
|
||||
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<SlackConversationsResult> {
|
||||
return fetchAllConversations('conversations.list', accessToken, {
|
||||
types: includePrivate ? 'public_channel,private_channel' : 'public_channel',
|
||||
exclude_archived: 'true',
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchUserPrivateChannels(
|
||||
accessToken: string,
|
||||
userId: string
|
||||
): Promise<SlackConversationsResult> {
|
||||
return fetchAllConversations('users.conversations', accessToken, {
|
||||
user: userId,
|
||||
types: 'private_channel',
|
||||
exclude_archived: 'true',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user