Skip to content

Commit 92e66ee

Browse files
committed
feat(search): simplify federated sources and add scoped secrets
1 parent 023f272 commit 92e66ee

113 files changed

Lines changed: 36706 additions & 1974 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/knowledge/connectors/member-sync/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { resourceScopeFromOwner } from '@/lib/core/resource-scope'
1212
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
import { connectorIndexingCondition } from '@/lib/knowledge/connectors/indexing-policy'
1516
import { sweepStaleMemberObservations } from '@/lib/knowledge/connectors/member-observations'
1617
import {
1718
dispatchMemberSync,
@@ -179,6 +180,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
179180
lte(knowledgeConnector.nextMemberSyncAt, now),
180181
isNull(knowledgeConnector.archivedAt),
181182
isNull(knowledgeConnector.deletedAt),
183+
connectorIndexingCondition(),
182184
isNull(knowledgeBase.deletedAt)
183185
)
184186
)

‎apps/sim/app/api/knowledge/connectors/sync/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { CONTENT_ENGINE_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes'
16+
import { connectorIndexingCondition } from '@/lib/knowledge/connectors/indexing-policy'
1617
import { dispatchSync } from '@/lib/knowledge/connectors/queue'
1718
import {
1819
CONNECTOR_AUTO_DISABLED_ERROR,
@@ -315,6 +316,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
315316
lte(knowledgeConnector.nextSyncAt, now),
316317
isNull(knowledgeConnector.archivedAt),
317318
isNull(knowledgeConnector.deletedAt),
319+
connectorIndexingCondition(),
318320
isNull(knowledgeBase.deletedAt)
319321
)
320322
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/** @vitest-environment node */
2+
import { member, organizationSecret, organizationSecretSource } from '@sim/db/schema'
3+
import {
4+
authMockFns,
5+
createMockRequest,
6+
dbChainMockFns,
7+
queueTableRows,
8+
resetDbChainMock,
9+
} from '@sim/testing'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
import { GET, PUT } from '@/app/api/organizations/[id]/secret-source/route'
12+
import { GET as getSecrets, PATCH } from '@/app/api/organizations/[id]/secret-source/secrets/route'
13+
14+
vi.mock('@/lib/permission-groups/resolve.server', () => ({
15+
getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null),
16+
}))
17+
18+
const context = { params: Promise.resolve({ id: 'org' }) }
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
resetDbChainMock()
22+
authMockFns.mockGetSession.mockResolvedValue({
23+
user: { id: 'actor' },
24+
session: { id: 'session' },
25+
})
26+
})
27+
describe('Generic Secrets HTTP boundary', () => {
28+
it('requires a session before parsing a mutation', async () => {
29+
authMockFns.mockGetSession.mockResolvedValue(null)
30+
const response = await PATCH(createMockRequest('PATCH', { invalid: true }), context)
31+
expect(response.status).toBe(401)
32+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
33+
})
34+
it('returns the source to an organization member without caching', async () => {
35+
queueTableRows(member, [{ role: 'member' }])
36+
queueTableRows(organizationSecretSource, [{ id: 'source', mode: 'member' }])
37+
const response = await GET(createMockRequest('GET'), context)
38+
expect(response.status).toBe(200)
39+
expect(response.headers.get('cache-control')).toBe('private, no-store')
40+
expect(await response.json()).toEqual({ source: { id: 'source', mode: 'member' } })
41+
})
42+
it('refuses source changes by ordinary members', async () => {
43+
queueTableRows(member, [{ role: 'member' }])
44+
const response = await PUT(
45+
createMockRequest('PUT', { sourceId: null, mode: 'organization' }),
46+
context
47+
)
48+
expect(response.status).toBe(403)
49+
expect(dbChainMockFns.insert).not.toHaveBeenCalledWith(organizationSecretSource)
50+
})
51+
it('refuses the organization editor to an ordinary member before loading secrets', async () => {
52+
queueTableRows(member, [{ role: 'member' }])
53+
const response = await getSecrets(
54+
createMockRequest(
55+
'GET',
56+
undefined,
57+
{},
58+
'http://localhost:3000/api/organizations/org/secret-source/secrets?mode=organization'
59+
),
60+
context
61+
)
62+
expect(response.status).toBe(403)
63+
expect(dbChainMockFns.from).not.toHaveBeenCalledWith(organizationSecret)
64+
})
65+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
configureSecretSourceContract,
3+
getSecretSourceContract,
4+
removeSecretSourceContract,
5+
} from '@/lib/api/contracts/organization-secrets'
6+
import {
7+
defineInternalJsonRoute,
8+
internalOrchestrationErrorPolicy,
9+
internalRateLimits,
10+
internalSessionAuth,
11+
} from '@/lib/api/server/routes'
12+
import {
13+
configureOrganizationSecretSource,
14+
readOrganizationSecretSource,
15+
removeOrganizationSecretSource,
16+
} from '@/lib/organization-secrets/application/use-cases'
17+
18+
const policy = {
19+
auth: internalSessionAuth,
20+
rateLimit: internalRateLimits.user({ bucketName: 'organization-secret-source' }),
21+
errorPolicy: internalOrchestrationErrorPolicy,
22+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
23+
}
24+
25+
export const GET = defineInternalJsonRoute({
26+
...policy,
27+
contract: getSecretSourceContract,
28+
operation: readOrganizationSecretSource.operation,
29+
useCase: readOrganizationSecretSource,
30+
mapInput: ({ params }) => ({ organizationId: params.id }),
31+
})
32+
33+
export const PUT = defineInternalJsonRoute({
34+
...policy,
35+
contract: configureSecretSourceContract,
36+
operation: configureOrganizationSecretSource.operation,
37+
useCase: configureOrganizationSecretSource,
38+
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
39+
})
40+
41+
export const DELETE = defineInternalJsonRoute({
42+
...policy,
43+
contract: removeSecretSourceContract,
44+
operation: removeOrganizationSecretSource.operation,
45+
useCase: removeOrganizationSecretSource,
46+
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
47+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
getOrganizationSecretsContract,
3+
saveOrganizationSecretsContract,
4+
} from '@/lib/api/contracts/organization-secrets'
5+
import {
6+
defineInternalJsonRoute,
7+
internalOrchestrationErrorPolicy,
8+
internalRateLimits,
9+
internalSessionAuth,
10+
} from '@/lib/api/server/routes'
11+
import {
12+
readOrganizationSecrets,
13+
saveOrganizationSecrets,
14+
} from '@/lib/organization-secrets/application/use-cases'
15+
16+
const policy = {
17+
auth: internalSessionAuth,
18+
rateLimit: internalRateLimits.user({ bucketName: 'organization-secrets' }),
19+
errorPolicy: internalOrchestrationErrorPolicy,
20+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
21+
}
22+
23+
export const GET = defineInternalJsonRoute({
24+
...policy,
25+
contract: getOrganizationSecretsContract,
26+
operation: readOrganizationSecrets.operation,
27+
useCase: readOrganizationSecrets,
28+
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
29+
})
30+
31+
export const PATCH = defineInternalJsonRoute({
32+
...policy,
33+
contract: saveOrganizationSecretsContract,
34+
operation: saveOrganizationSecrets.operation,
35+
useCase: saveOrganizationSecrets,
36+
parseOptions: { maxBodyBytes: 2 * 1024 * 1024 },
37+
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
38+
present: () => ({ success: true as const }),
39+
})

‎apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -977,41 +977,61 @@ describe('grouped member integrations', () => {
977977
describe('live integrations backend selection', () => {
978978
it('connects through existing OAuth enrollment without loading indexed sources', async () => {
979979
mocks.live = true
980+
mocks.integrations.mockReturnValue({
981+
data: [{ connectorType: 'google_drive', approved: true }],
982+
})
980983
mocks.organizationAccounts.mockReturnValue({
981984
data: {
982985
credentialGroup: {
983986
status: 'active',
987+
mcpServers: [],
984988
options: [
985-
{ id: 'drive', provider: 'google-drive', label: 'Google Drive', status: 'active' },
989+
{
990+
id: 'drive',
991+
provider: 'google-drive',
992+
label: 'Google Drive',
993+
status: 'active',
994+
configurationStatus: 'ready',
995+
},
986996
],
987997
},
988998
viewerAccounts: [],
989999
},
9901000
isError: false,
9911001
})
9921002
await render('', <OrganizationIntegrations />)
993-
expect(container.textContent).toContain('no indexing setup is needed')
9941003
await act(async () => buttons('Connect')[0].click())
9951004
expect(mocks.connectOrganizationAccount).toHaveBeenCalledWith(
9961005
{ organizationId: scope.organizationId, optionId: 'drive' },
9971006
expect.any(Object)
9981007
)
9991008
expect(mocks.sources).not.toHaveBeenCalled()
10001009
expect(mocks.overview).not.toHaveBeenCalled()
1001-
expect(mocks.integrations).not.toHaveBeenCalled()
1010+
expect(mocks.integrations).toHaveBeenCalledWith(scope.organizationId)
10021011
expect(mocks.connectSearchSource).not.toHaveBeenCalled()
10031012
})
10041013
it('offers reconnect for an existing personal grant', async () => {
10051014
mocks.live = true
1015+
mocks.integrations.mockReturnValue({ data: [{ connectorType: 'slack', approved: true }] })
10061016
mocks.organizationAccounts.mockReturnValue({
10071017
data: {
10081018
credentialGroup: {
10091019
status: 'active',
1010-
options: [{ id: 'slack', provider: 'slack', label: 'Slack', status: 'active' }],
1020+
mcpServers: [],
1021+
options: [
1022+
{
1023+
id: 'slack',
1024+
provider: 'slack',
1025+
label: 'Slack',
1026+
status: 'active',
1027+
configurationStatus: 'ready',
1028+
},
1029+
],
10111030
},
10121031
viewerAccounts: [
10131032
{
10141033
credentialId: 'my-slack',
1034+
providerId: 'slack',
10151035
optionId: 'slack',
10161036
displayName: 'My Slack',
10171037
status: 'needs_reauth',
@@ -1023,6 +1043,6 @@ describe('live integrations backend selection', () => {
10231043
await render('', <OrganizationIntegrations />)
10241044
await act(async () => buttons('Reconnect')[0].click())
10251045
expect(mocks.reconnectOrganizationAccount).toHaveBeenCalledWith('my-slack', expect.any(Object))
1026-
expect(container.textContent).toContain('reconnect needed')
1046+
expect(container.textContent).toContain('Reconnect needed')
10271047
})
10281048
})

‎apps/sim/app/o/[organizationId]/integrations/integrations.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ export function OrganizationIntegrations({
3333
<OrganizationPage
3434
title='Integrations'
3535
description={
36-
features.liveEnterpriseSearch
37-
? 'Search documents, messages, and code from your connected apps'
38-
: 'Connect your accounts for Sim Search'
36+
features.liveEnterpriseSearch ? undefined : 'Connect your accounts for Sim Search'
3937
}
4038
searchMode='expanded'
4139
searchPlaceholder='Search integrations'

‎apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
const mocks = vi.hoisted(() => ({
77
inventory: vi.fn(),
88
policies: vi.fn(),
9+
secrets: vi.fn(),
10+
saveSecrets: vi.fn(),
911
connect: vi.fn(),
1012
reconnect: vi.fn(),
1113
refetch: vi.fn(),
@@ -15,6 +17,11 @@ vi.mock('@/hooks/queries/organization-accounts', () => ({
1517
useConnectOrganizationAccount: () => ({ mutate: mocks.connect }),
1618
useReconnectPersonalOrganizationAccount: () => ({ mutate: mocks.reconnect }),
1719
}))
20+
vi.mock('@/hooks/queries/organization-secrets', () => ({
21+
useOrganizationSecretSource: mocks.secrets,
22+
useConfigureOrganizationSecretSource: () => ({ mutate: mocks.saveSecrets }),
23+
useRemoveOrganizationSecretSource: () => ({ mutate: vi.fn() }),
24+
}))
1825
vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.policies }))
1926
vi.mock('@/app/o/[organizationId]/integrations/disconnect-account-menu', () => ({
2027
DisconnectAccountMenu: ({ accounts }: { accounts: { displayName: string }[] }) => (
@@ -25,6 +32,7 @@ vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-show
2532
IntegrationTile: () => null,
2633
}))
2734

35+
import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
2836
import { LiveMemberIntegrations } from '@/app/o/[organizationId]/integrations/live-member-integrations'
2937

3038
let root: Root
@@ -50,6 +58,7 @@ const inventory = (overrides = {}) => ({
5058
})
5159
beforeEach(() => {
5260
vi.clearAllMocks()
61+
mocks.secrets.mockReturnValue({ data: { source: null } })
5362
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
5463
mocks.inventory.mockReturnValue({ data: inventory(), refetch: mocks.refetch })
5564
mocks.policies.mockReturnValue({
@@ -73,6 +82,28 @@ const render = async (search = '') => {
7382
const button = (label: string) =>
7483
[...container.querySelectorAll('button')].find((button) => button.textContent === label)
7584
describe('live member connection states', () => {
85+
it.each(['organization', 'member'] as const)(
86+
'shows Generic Secrets in %s mode without OAuth sources',
87+
async (mode) => {
88+
mocks.policies.mockReturnValue({ data: [] })
89+
mocks.inventory.mockReturnValue({ data: inventory({ credentialGroup: null }) })
90+
mocks.secrets.mockReturnValue({ data: { source: { id: 'source', mode } } })
91+
await render()
92+
expect(container.textContent).toContain('Generic Secrets')
93+
if (mode === 'member')
94+
expect(container.querySelector('a')?.getAttribute('href')).toBe(
95+
'/o/org/integrations/secrets'
96+
)
97+
else {
98+
expect(container.textContent).toContain('Organization managed')
99+
expect(container.querySelector('a')).toBeNull()
100+
}
101+
expect(button('Configure')).toBeUndefined()
102+
expect(button('Connect')).toBeUndefined()
103+
await render('unrelated')
104+
expect(container.textContent).not.toContain('Generic Secrets')
105+
}
106+
)
76107
it('keeps GitLab organization-managed with no personal token or connect action', async () => {
77108
mocks.policies.mockReturnValue({ data: [{ connectorType: 'gitlab', approved: true }] })
78109
await render()
@@ -145,7 +176,7 @@ describe('live member connection states', () => {
145176
})
146177
it('distinguishes filtered empty, loading, and failed states', async () => {
147178
await render('nothing')
148-
expect(container.textContent).toContain('No integrations match')
179+
expect(container.textContent).toContain('No matching integrations')
149180
mocks.inventory.mockReturnValue({})
150181
await render()
151182
expect(container.textContent).toContain('Loading your connections')
@@ -156,4 +187,46 @@ describe('live member connection states', () => {
156187
await render()
157188
expect(container.textContent).toContain('Network unavailable')
158189
})
190+
it('shows no empty-state copy when the organization has no sources', async () => {
191+
mocks.policies.mockReturnValue({ data: [], refetch: mocks.refetch })
192+
await render()
193+
expect(container.textContent).toBe('')
194+
})
195+
it('sends admins to Credential Groups when member sign-in is not configured', async () => {
196+
mocks.inventory.mockReturnValue({ data: inventory({ credentialGroup: null, canManage: true }) })
197+
await render()
198+
expect(container.querySelector('a')?.getAttribute('href')).toBe(
199+
'/o/org/settings/connected-accounts'
200+
)
201+
})
202+
it('explains service-account scope without hiding member connection controls', async () => {
203+
mocks.policies.mockReturnValue({
204+
data: [
205+
{
206+
connectorType: 'github',
207+
approved: true,
208+
policy: {
209+
...defaultLiveSearchPolicy(),
210+
accessMode: 'service_account',
211+
sourceId: 'source',
212+
},
213+
},
214+
],
215+
})
216+
mocks.inventory.mockReturnValue({
217+
data: inventory({
218+
viewerAccounts: [
219+
{
220+
credentialId: 'own',
221+
providerId: 'github-repositories',
222+
displayName: 'reader',
223+
status: 'active',
224+
},
225+
],
226+
}),
227+
})
228+
await render()
229+
expect(container.textContent).toContain('Selected resources you can access')
230+
expect(button('Add account')).toBeDefined()
231+
})
159232
})

0 commit comments

Comments
 (0)