Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/sim/app/api/files/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,21 +436,28 @@ describe('KB file live source authorization', () => {
async (allowed) => {
const scope = { kind: 'user' as const, userId: USER_ID, tokens: ['reader-token'] }
const getForConnectors = vi.fn().mockResolvedValue(scope)
const liveSources = { type: 'live-sources' }
const access: KnowledgeAccessProvider = {
get: async () => scope,
getForConnectors,
getForDocuments: async () => scope,
liveSourceConnectorCondition: async () => liveSources as never,
}
queueTableRows(schemaMock.document, [])
queueTableRows(schemaMock.document, [{ connectorId: 'confluence-source' }])
queueTableRows(schemaMock.knowledgeConnector, [{ connectorId: 'confluence-source' }])
queueTableRows(schemaMock.document, allowed ? [{ id: 'doc-1' }] : [])
await expect(
verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, {
knowledgeAccess: access,
})
).resolves.toBe(allowed)
expect(getForConnectors).toHaveBeenCalledExactlyOnceWith(['confluence-source'], undefined)
const discovery = dbChainMockFns.where.mock.calls.filter(([condition]) =>
hasMockCondition(condition, (node) => node === liveSources)
)
expect(discovery).toHaveLength(1)
for (const [condition] of dbChainMockFns.where.mock.calls) {
if (discovery.some(([live]) => live === condition)) continue
expect(
hasMockCondition(
condition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const source = {
isSyncing: false,
lastSyncAt: null,
hasSyncError: false,
viewerDocumentCount: 0,
hasViewerDocuments: false,
viewerFailedDocumentCount: 0,
viewerEmailVerified: true,
viewerAccounts: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const memberSource: SearchSourceSummary = {
isSyncing: false,
lastSyncAt: null,
hasSyncError: false,
viewerDocumentCount: 0,
hasViewerDocuments: false,
viewerFailedDocumentCount: 0,
viewerEmailVerified: true,
viewerAccounts: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const source = {
lastSyncAt: '2026-09-08T12:00:00.000Z',
connectionRequired: true,
viewerMembership: 'needs_reauth',
viewerDocumentCount: 0,
hasViewerDocuments: false,
}
const credentialGroup = {
id: 'accounts-one',
Expand Down Expand Up @@ -429,7 +429,6 @@ describe('organization provider management', () => {
)
expect(container.textContent).not.toContain('Connect account')
expect(container.textContent).not.toContain('Reconnect')
expect(container.textContent).not.toContain('0 searchable documents')
})

it('loads sources for a nonpersonal provider even when an accounts view URL is supplied', async () => {
Expand Down
18 changes: 12 additions & 6 deletions apps/sim/hooks/queries/kb/search-source-progress.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function Probe() {
const sync = useTriggerSync()
return (
<div>
<span>{result.data?.[0]?.viewerDocumentCount ?? 0}</span>
<span>{String(result.data?.[0]?.hasViewerDocuments ?? false)}</span>
<button
disabled={sync.isPending}
onClick={() => sync.mutate({ knowledgeBaseId: 'kb', connectorId: 'source' })}
Expand Down Expand Up @@ -83,7 +83,7 @@ beforeEach(() => {
isSyncing: syncing,
hasSyncError: false,
viewerFailedDocumentCount: 0,
viewerDocumentCount: syncing ? 0 : 1,
hasViewerDocuments: !syncing,
},
],
nextCursor: null,
Expand Down Expand Up @@ -127,7 +127,7 @@ describe('source progress polling', () => {
await advance(1)
expect(
client.getQueryData(searchSourceKeys.pages('workspace', { search: '', mine: false }))
).toMatchObject({ pages: [{ sources: [{ isSyncing: false, viewerDocumentCount: 1 }] }] })
).toMatchObject({ pages: [{ sources: [{ isSyncing: false, hasViewerDocuments: true }] }] })
const total = mocks.requestJson.mock.calls.length
await advance(60_000)
expect(mocks.requestJson).toHaveBeenCalledTimes(total)
Expand Down Expand Up @@ -157,7 +157,9 @@ describe('source progress polling', () => {
expect(
client.getQueryData(searchSourceKeys.pages('workspace', { search: '', mine: false }))
).toMatchObject({
pages: [{ sources: [expect.objectContaining({ isSyncing: false, viewerDocumentCount: 1 })] }],
pages: [
{ sources: [expect.objectContaining({ isSyncing: false, hasViewerDocuments: true })] },
],
})
const total = mocks.requestJson.mock.calls.length
await advance(60_000)
Expand All @@ -173,7 +175,9 @@ describe('source progress polling', () => {
expect(
client.getQueryData(searchSourceKeys.pages('workspace', { search: '', mine: false }))
).toMatchObject({
pages: [{ sources: [expect.objectContaining({ isSyncing: false, viewerDocumentCount: 1 })] }],
pages: [
{ sources: [expect.objectContaining({ isSyncing: false, hasViewerDocuments: true })] },
],
})
})
it('lets a slow summary refresh finish while progress keeps polling', async () => {
Expand Down Expand Up @@ -201,7 +205,9 @@ describe('source progress polling', () => {
expect(
client.getQueryData(searchSourceKeys.pages('workspace', { search: '', mine: false }))
).toMatchObject({
pages: [{ sources: [expect.objectContaining({ isSyncing: false, viewerDocumentCount: 1 })] }],
pages: [
{ sources: [expect.objectContaining({ isSyncing: false, hasViewerDocuments: true })] },
],
})
const total = mocks.requestJson.mock.calls.length
await advance(60_000)
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/lib/api/contracts/knowledge/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,11 @@ const searchSourceSummaryFields = {
isSyncing: z.boolean(),
lastSyncAt: z.string().datetime().nullable(),
hasSyncError: z.boolean(),
viewerDocumentCount: z.number().int().nonnegative(),
/**
* Whether the viewer can search at least one indexed document from this source. An
* existence flag rather than a count: counting means access-checking every visible document.
*/
hasViewerDocuments: z.boolean(),
viewerFailedDocumentCount: z.number().int().nonnegative().default(0),
viewerEmailVerified: z.boolean(),
viewerAccounts: z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const personalSearchIntegrationSchema = z.object({
.max(100),
connectionStatus: z.enum(['connected', 'reconnect_needed', 'not_connected', 'unavailable']),
indexingStatus: z.enum(['indexing', 'indexed', 'not_indexed', 'sync_failed', 'paused']),
searchableDocuments: z.number().int().nonnegative(),
action: searchConnectionTargetSchema.nullable(),
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ describe('loadCopilotSearchIntegrations', () => {
],
connectionStatus: 'reconnect_needed',
indexingStatus: 'indexed',
searchableDocuments: 7,
action: null,
}
listIntegrations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
{
connectorId,
viewerMembership: 'connected',
viewerDocumentCount: userId === ids.aliceId ? 1 : 0,
hasViewerDocuments: userId === ids.aliceId,
},
])
}
Expand Down Expand Up @@ -919,7 +919,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
})
).sources[0]
expect(await summary(ids.aliceId)).toMatchObject({
viewerDocumentCount: 1,
hasViewerDocuments: true,
viewerFailedDocumentCount: 0,
hasSyncError: false,
})
Expand Down Expand Up @@ -977,7 +977,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
)
}
expect(await summary(userId)).toMatchObject({
viewerDocumentCount: userId === ids.aliceId ? 1 : 0,
hasViewerDocuments: userId === ids.aliceId,
viewerFailedDocumentCount: userId === ids.aliceId ? 1 : 0,
})
}
Expand Down Expand Up @@ -1327,13 +1327,13 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
expect(
summaries.sources.map((source) => ({
connectorId: source.connectorId,
viewerDocumentCount: source.viewerDocumentCount,
hasViewerDocuments: source.hasViewerDocuments,
}))
).toEqual(
expect.arrayContaining([
{ connectorId: enrolled.connectorId, viewerDocumentCount: 1 },
{ connectorId: privateId, viewerDocumentCount: userId === ids.aliceId ? 1 : 0 },
{ connectorId: blockedId, viewerDocumentCount: 0 },
{ connectorId: enrolled.connectorId, hasViewerDocuments: true },
{ connectorId: privateId, hasViewerDocuments: userId === ids.aliceId },
{ connectorId: blockedId, hasViewerDocuments: false },
])
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe('organization operational overview with real SQL', () => {
input: { ...input, connectorType: 'google_drive' },
})
expect(visible.sources).toHaveLength(2)
expect(visible.sources.every((source) => source.viewerDocumentCount === 0)).toBe(true)
expect(visible.sources.every((source) => !source.hasViewerDocuments)).toBe(true)
})
it('keeps explicit approvals and deactivations visible before source creation', async () => {
await db.insert(organizationSearchIntegration).values([
Expand Down
13 changes: 6 additions & 7 deletions apps/sim/lib/knowledge/access/confluence-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { decryptSecret } from '@/lib/core/security/encryption'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth'
import { confluenceSubjectToken } from '@/lib/knowledge/access/confluence-permissions'
import {
confluenceSiteSourceCondition,
liveSourceKnowledgeBaseCondition,
} from '@/lib/knowledge/access/live-sources'
import {
type ConfluenceSiteReadGrant,
MAX_KNOWLEDGE_ACCESS_CANDIDATES,
Expand Down Expand Up @@ -152,14 +156,9 @@ export async function resolveConfluenceSiteReadGrants(input: {
.innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId))
.where(
and(
resourceScopeCondition(knowledgeBase, input.scope),
liveSourceKnowledgeBaseCondition(input.scope, input.knowledgeBaseIds),
inArray(knowledgeConnector.id, [...new Set(input.connectorIds)]),
input.knowledgeBaseIds ? inArray(knowledgeBase.id, [...input.knowledgeBaseIds]) : undefined,
isNull(knowledgeBase.deletedAt),
eq(knowledgeConnector.connectorType, 'confluence'),
eq(knowledgeConnector.accessMode, 'admin'),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
confluenceSiteSourceCondition()
)
)
.orderBy(asc(knowledgeConnector.id))
Expand Down
16 changes: 7 additions & 9 deletions apps/sim/lib/knowledge/access/github-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
import { decryptSecret } from '@/lib/core/security/encryption'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth'
import {
githubInstallationSourceCondition,
liveSourceKnowledgeBaseCondition,
} from '@/lib/knowledge/access/live-sources'
import {
type GitHubInstallationReadGrant,
MAX_KNOWLEDGE_ACCESS_CANDIDATES,
Expand Down Expand Up @@ -183,17 +187,11 @@ export async function resolveGitHubInstallationReadGrants(input: {
)
.where(
and(
resourceScopeCondition(knowledgeBase, input.scope),
liveSourceKnowledgeBaseCondition(input.scope, input.knowledgeBaseIds),
inArray(knowledgeConnector.id, [...new Set(input.connectorIds)]),
input.knowledgeBaseIds ? inArray(knowledgeBase.id, [...input.knowledgeBaseIds]) : undefined,
isNull(knowledgeBase.deletedAt),
eq(knowledgeConnector.connectorType, 'github'),
eq(knowledgeConnector.accessMode, 'members'),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt),
githubInstallationSourceCondition(),
eq(knowledgeConnectorMember.status, 'active'),
inArray(knowledgeConnectorMember.credentialId, [...readers.keys()]),
sql`${knowledgeConnector.sourceConfig}::jsonb ? 'githubRepositoryId'`
inArray(knowledgeConnectorMember.credentialId, [...readers.keys()])
)
)
.orderBy(asc(knowledgeConnector.id), asc(knowledgeConnectorMember.id))
Expand Down
41 changes: 41 additions & 0 deletions apps/sim/lib/knowledge/access/live-sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { knowledgeBase, knowledgeConnector } from '@sim/db/schema'
import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm'
import type { ResourceScope } from '@/lib/core/resource-scope'
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'

/**
* Repository sources indexed through a GitHub installation, the only ones a GitHub grant
* authorizes. The grant resolvers select only from these source conditions and candidate
* discovery searches nowhere else, so discovery can never skip an authorizable source.
*/
export function githubInstallationSourceCondition(): SQL {
return and(
eq(knowledgeConnector.connectorType, 'github'),
eq(knowledgeConnector.accessMode, 'members'),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt),
sql`${knowledgeConnector.sourceConfig}::jsonb ? 'githubRepositoryId'`
)!
}

/** Central Confluence crawls, the only sources a Confluence site grant authorizes. */
export function confluenceSiteSourceCondition(): SQL {
return and(
eq(knowledgeConnector.connectorType, 'confluence'),
eq(knowledgeConnector.accessMode, 'admin'),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)!
}

/** Live knowledge bases within the resolved owner, narrowed to the operation's bases when given. */
export function liveSourceKnowledgeBaseCondition(
scope: ResourceScope,
knowledgeBaseIds: readonly string[] | undefined
): SQL {
return and(
resourceScopeCondition(knowledgeBase, scope),
knowledgeBaseIds ? inArray(knowledgeBase.id, [...knowledgeBaseIds]) : undefined,
isNull(knowledgeBase.deletedAt)
)!
}
Loading
Loading