Skip to content

Commit 0bba808

Browse files
feat(slack-search): stream enterprise answers through one Slack app (#7644)
* feat: serve organization search through Slack custom bots * feat(slack-search): stream enterprise answers through one Slack app * fix(slack-search): simplify setup and align source readiness * fix(slack-search): supply query props in page gate test * fix(slack-search): finalize failed streams and explain query limits * fix(tests): isolate embedding provider credentials
1 parent 42b1bad commit 0bba808

156 files changed

Lines changed: 36569 additions & 386 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/files/uploads/purposes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ function requireSessionScope(value: string | null, label = 'scope'): string {
235235

236236
async function principalUserId(principal: Principal, workspaceId?: string): Promise<string> {
237237
switch (principal.kind) {
238+
case 'slack_app':
239+
case 'slack_installation':
240+
throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads')
238241
case 'session':
239242
case 'personal_api_key':
240243
case 'oauth_access_token':
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { removeSlackSearchContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { removeSlackSearchInstallation } from '@/lib/knowledge/application/slack-search/installations'
10+
11+
export const DELETE = defineInternalJsonRoute({
12+
contract: removeSlackSearchContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.removeSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ query, params }) => ({ ...query, ...params }),
18+
useCase: removeSlackSearchInstallation,
19+
present: (result) => result,
20+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { NextResponse } from 'next/server'
2+
import { slackSearchOAuthCallbackContract } from '@/lib/api/contracts/knowledge/slack'
3+
import { parseRequest } from '@/lib/api/server'
4+
import {
5+
InternalUnauthenticatedError,
6+
internalOrchestrationErrorPolicy,
7+
internalRateLimits,
8+
internalSessionAuth,
9+
} from '@/lib/api/server/routes'
10+
import { getBaseUrl } from '@/lib/core/utils/urls'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
13+
import { organizationRoutes } from '@/lib/navigation/paths'
14+
15+
/** OAuth is a redirect protocol; protected configuration remains in the application use case. */
16+
export const GET = withRouteHandler(async (request) => {
17+
try {
18+
const principal = await internalSessionAuth.authenticate()
19+
const rateResponse = await internalRateLimits
20+
.user({ bucketName: 'slack-search-settings' })
21+
.enforce(request, principal)
22+
if (rateResponse) return rateResponse
23+
const parsed = await parseRequest(slackSearchOAuthCallbackContract, request, {})
24+
if (!parsed.success) return parsed.response
25+
const result = await completeSlackSearchSetup.execute({
26+
principal,
27+
input: parsed.data.query,
28+
request,
29+
})
30+
const url = new URL(
31+
organizationRoutes(result.organizationId).settingsSection('search-slack'),
32+
getBaseUrl()
33+
)
34+
url.searchParams.set('slackSetup', 'complete')
35+
return NextResponse.redirect(url, 303)
36+
} catch (error) {
37+
if (error instanceof InternalUnauthenticatedError)
38+
return NextResponse.json(
39+
{ error: 'Sign in to Sim and restart Slack setup.' },
40+
{ status: 401 }
41+
)
42+
const projected = internalOrchestrationErrorPolicy.project(error)
43+
if (projected) return NextResponse.json(projected.body, { status: projected.status })
44+
throw error
45+
}
46+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { startSlackSearchOAuthContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { startSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: startSlackSearchOAuthContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.startSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ body }) => body,
18+
useCase: startSlackSearchSetup,
19+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { retrySlackSearchOnboardingContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
retrySlackSearchOnboarding,
10+
slackSearchOnboardingOperations,
11+
} from '@/lib/knowledge/application/slack-search/onboarding'
12+
13+
export const POST = defineInternalJsonRoute({
14+
contract: retrySlackSearchOnboardingContract,
15+
auth: internalSessionAuth,
16+
operation: slackSearchOnboardingOperations.retry,
17+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-onboarding-retry' }),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
staticResponseHeaders: { 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' },
20+
mapInput: ({ body }) => body,
21+
useCase: retrySlackSearchOnboarding,
22+
})
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/** @vitest-environment node */
2+
import { authMockFns, createMockRequest } from '@sim/testing'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const mocks = vi.hoisted(() => ({ read: vi.fn(), retry: vi.fn() }))
6+
vi.mock('@/lib/knowledge/application/slack-search/onboarding', () => {
7+
const read = {
8+
id: 'knowledge.slack.onboarding.read',
9+
capability: 'none',
10+
principalKinds: ['session'],
11+
}
12+
const retry = {
13+
id: 'knowledge.slack.onboarding.retry',
14+
capability: 'knowledge.use',
15+
principalKinds: ['session'],
16+
}
17+
return {
18+
slackSearchOnboardingOperations: { read, retry },
19+
getSlackSearchOnboarding: { operation: read, execute: mocks.read },
20+
retrySlackSearchOnboarding: { operation: retry, execute: mocks.retry },
21+
}
22+
})
23+
24+
import { OrchestrationError } from '@/lib/core/orchestration/types'
25+
import { POST } from '@/app/api/knowledge/slack/onboarding/retry/route'
26+
import { GET } from '@/app/api/knowledge/slack/onboarding/route'
27+
28+
const token = '11111111-1111-4111-8111-111111111111'
29+
const url = `http://localhost/api/knowledge/slack/onboarding?token=${token}`
30+
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
authMockFns.mockGetSession.mockResolvedValue({
34+
user: { id: 'sender' },
35+
session: { id: 'session' },
36+
})
37+
mocks.read.mockResolvedValue({ status: 'membership_required' })
38+
mocks.retry.mockResolvedValue({ slackUrl: 'https://example.slack.com/archives/D1/p1' })
39+
})
40+
41+
describe('Slack onboarding routes', () => {
42+
it('authenticates both routes before parsing invalid input', async () => {
43+
authMockFns.mockGetSession.mockResolvedValue(null)
44+
expect((await GET(createMockRequest('GET'))).status).toBe(401)
45+
expect((await POST(createMockRequest('POST', {}))).status).toBe(401)
46+
expect(mocks.read).not.toHaveBeenCalled()
47+
expect(mocks.retry).not.toHaveBeenCalled()
48+
})
49+
50+
it('rejects malformed tokens before entering the application', async () => {
51+
expect((await GET(createMockRequest('GET'))).status).toBe(400)
52+
expect((await POST(createMockRequest('POST', { token: 'invalid' }))).status).toBe(400)
53+
expect(mocks.read).not.toHaveBeenCalled()
54+
expect(mocks.retry).not.toHaveBeenCalled()
55+
})
56+
57+
it('projects only the blocked view and keeps the response private', async () => {
58+
mocks.read.mockResolvedValue({
59+
status: 'membership_required',
60+
question: 'private question',
61+
organizationId: 'private organization',
62+
email: 'private@example.test',
63+
})
64+
const response = await GET(createMockRequest('GET', undefined, {}, url))
65+
expect(response.status).toBe(200)
66+
expect(await response.json()).toEqual({ status: 'membership_required' })
67+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
68+
expect(response.headers.get('Referrer-Policy')).toBe('no-referrer')
69+
expect(mocks.read).toHaveBeenCalledWith(
70+
expect.objectContaining({
71+
principal: { kind: 'session', userId: 'sender', sessionId: 'session' },
72+
input: { token },
73+
})
74+
)
75+
expect(mocks.retry).not.toHaveBeenCalled()
76+
})
77+
78+
it('passes explicit retries to the same signed-in principal', async () => {
79+
const response = await POST(createMockRequest('POST', { token }))
80+
expect(response.status).toBe(200)
81+
expect(await response.json()).toEqual({
82+
slackUrl: 'https://example.slack.com/archives/D1/p1',
83+
})
84+
expect(mocks.retry).toHaveBeenCalledWith(
85+
expect.objectContaining({
86+
principal: { kind: 'session', userId: 'sender', sessionId: 'session' },
87+
input: { token },
88+
})
89+
)
90+
})
91+
92+
it('preserves authorization denial without returning a question', async () => {
93+
mocks.retry.mockRejectedValue(new OrchestrationError('forbidden', 'Complete account setup'))
94+
const response = await POST(createMockRequest('POST', { token }))
95+
expect(response.status).toBe(403)
96+
expect(await response.json()).not.toHaveProperty('slackUrl')
97+
})
98+
99+
it('conceals unexpected infrastructure errors', async () => {
100+
mocks.read.mockRejectedValue(new Error('private database connection'))
101+
const response = await GET(createMockRequest('GET', undefined, {}, url))
102+
expect(response.status).toBe(500)
103+
expect(await response.json()).toMatchObject({ error: 'Internal server error' })
104+
})
105+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getSlackSearchOnboardingContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
getSlackSearchOnboarding,
10+
slackSearchOnboardingOperations,
11+
} from '@/lib/knowledge/application/slack-search/onboarding'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getSlackSearchOnboardingContract,
15+
auth: internalSessionAuth,
16+
operation: slackSearchOnboardingOperations.read,
17+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-onboarding' }),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
staticResponseHeaders: { 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' },
20+
mapInput: ({ query }) => query,
21+
useCase: getSlackSearchOnboarding,
22+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
configureSlackSearchContract,
3+
listSlackSearchContract,
4+
} from '@/lib/api/contracts/knowledge/slack'
5+
import {
6+
defineInternalJsonRoute,
7+
internalOrchestrationErrorPolicy,
8+
internalRateLimits,
9+
internalSessionAuth,
10+
} from '@/lib/api/server/routes'
11+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
12+
import {
13+
configureSlackSearchInstallation,
14+
listSlackSearchInstallations,
15+
} from '@/lib/knowledge/application/slack-search/installations'
16+
17+
export const GET = defineInternalJsonRoute({
18+
contract: listSlackSearchContract,
19+
auth: internalSessionAuth,
20+
operation: knowledgeOperations.listSlackInstallations,
21+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
22+
errorPolicy: internalOrchestrationErrorPolicy,
23+
mapInput: ({ query }) => query,
24+
useCase: listSlackSearchInstallations,
25+
present: ({ installations, bots }) => ({
26+
bots,
27+
installations: installations.map((row) => ({
28+
...row,
29+
lastEventAt: row.lastEventAt?.toISOString() ?? null,
30+
})),
31+
}),
32+
})
33+
34+
export const POST = defineInternalJsonRoute({
35+
contract: configureSlackSearchContract,
36+
auth: internalSessionAuth,
37+
operation: knowledgeOperations.configureSlackInstallation,
38+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
39+
errorPolicy: internalOrchestrationErrorPolicy,
40+
mapInput: ({ body }) => body,
41+
useCase: configureSlackSearchInstallation,
42+
present: (result) => result,
43+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/** @vitest-environment node */
2+
import { authMockFns, createMockRequest } from '@sim/testing'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const mocks = vi.hoisted(() => ({ prepare: vi.fn(), start: vi.fn() }))
6+
vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
7+
const { knowledgeOperations } = await import('@/lib/knowledge/application/operations')
8+
return {
9+
prepareSlackSearchSetup: {
10+
operation: knowledgeOperations.prepareSlackInstallation,
11+
execute: mocks.prepare,
12+
},
13+
startSlackSearchSetup: {
14+
operation: knowledgeOperations.startSlackInstallation,
15+
execute: mocks.start,
16+
},
17+
}
18+
})
19+
20+
import { createSlackSearchManifest } from '@/lib/slack-search/manifest'
21+
import { POST as start } from '@/app/api/knowledge/slack/oauth/route'
22+
import { POST as prepare } from '@/app/api/knowledge/slack/setup/route'
23+
24+
const input = { organizationId: 'organization-1', name: 'Sim Search', description: 'Search' }
25+
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
authMockFns.mockGetSession.mockResolvedValue({
29+
user: { id: 'admin' },
30+
session: { id: 'session' },
31+
})
32+
})
33+
34+
describe.each([
35+
['prepare', prepare, mocks.prepare],
36+
['OAuth', start, mocks.start],
37+
] as const)('Slack %s route errors', (_name, route, execute) => {
38+
it('returns an actionable 400 for a non-HTTPS app URL', async () => {
39+
execute.mockImplementation(() =>
40+
createSlackSearchManifest(input.name, input.description, 'http://localhost:3000')
41+
)
42+
const response = await route(createMockRequest('POST', input))
43+
expect(response.status).toBe(400)
44+
expect(await response.json()).toMatchObject({ error: expect.stringContaining('public HTTPS') })
45+
expect(execute).toHaveBeenCalledOnce()
46+
})
47+
48+
it('still conceals unexpected errors', async () => {
49+
execute.mockRejectedValue(new Error('private database configuration'))
50+
const response = await route(createMockRequest('POST', input))
51+
expect(response.status).toBe(500)
52+
expect(await response.json()).toMatchObject({ error: 'Internal server error' })
53+
})
54+
55+
it('authenticates before exposing setup configuration', async () => {
56+
authMockFns.mockGetSession.mockResolvedValue(null)
57+
const response = await route(createMockRequest('POST', {}))
58+
expect(response.status).toBe(401)
59+
expect(execute).not.toHaveBeenCalled()
60+
})
61+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { prepareSlackSearchContract } from '@/lib/api/contracts/knowledge/slack'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { prepareSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: prepareSlackSearchContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.prepareSlackInstallation,
15+
rateLimit: internalRateLimits.user({ bucketName: 'slack-search-settings' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ body }) => body,
18+
useCase: prepareSlackSearchSetup,
19+
})

0 commit comments

Comments
 (0)