diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index aec2144c459..3c79d316448 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -103,6 +103,7 @@ Update Credential (personal API key required) | `--service-account-json ` | No | Write-only Google service-account JSON key. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | | `--client-id ` | No | OAuth client identifier. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 28bab99672f..20bfdf4d0e7 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -463,6 +463,7 @@ sim credentials update [options] | `--service-account-json ` | No | Write-only Google service-account JSON key. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | | `--client-id ` | No | OAuth client identifier. | diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx index e5aa393b91c..c2663178d2f 100644 --- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx +++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx @@ -49,6 +49,7 @@ Point cron at an **internal** address where possible (the in-cluster Service, or | Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search | | Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs | | Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors | +| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes the directory groups administrator-mode connectors mirror, so a membership change takes effect without waiting for a content sync | | Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers | | Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired | | Data drains | `/api/cron/run-data-drains` | `0 * * * *` | Enterprise data drains | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index c2ff564af75..e67e9c7cd0d 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -9330,6 +9330,11 @@ "minLength": 1, "maxLength": 2048 }, + "atlassianProduct": { + "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", + "type": "string", + "enum": ["jira", "confluence"] + }, "signingSecret": { "description": "Write-only webhook signing secret.", "writeOnly": true, diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 70d2ec4e50c..13200bbf7f6 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -177,7 +177,7 @@ describe('OAuth Utils', () => { ).rejects.toThrow('Failed to refresh token') }) - it('should not attempt refresh if no refresh token', async () => { + it('requires reconnection for an expired token without attempting an unavailable refresh', async () => { const mockCredential = { id: 'credential-id', accessToken: 'token', @@ -186,10 +186,11 @@ describe('OAuth Utils', () => { providerId: 'google', } - const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') + await expect( + refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') + ).rejects.toThrow('OAuth access token expired and cannot be refreshed; reconnect the account') expect(mockRefreshOAuthToken).not.toHaveBeenCalled() - expect(result).toEqual({ accessToken: 'token', refreshed: false }) }) it('keeps a legacy non-expiring Monday credential usable without refreshing it', async () => { diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index dbac5ed031e..9e7440c505f 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, or } from 'drizzle-orm' import { NextResponse } from 'next/server' import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { @@ -511,6 +511,9 @@ async function hasActiveKbDocumentForKey( isNull(document.archivedAt), isNull(document.deletedAt), isNull(knowledgeBase.deletedAt), + access.kind === 'system' + ? undefined + : or(isNull(document.connectorId), isNotNull(document.contentHash)), knowledgeAccessCondition(access) ) ) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts index ab89571ab7e..af895bf5a0d 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts @@ -24,8 +24,6 @@ export const PATCH = defineInternalJsonRoute({ connectorId: params.connectorId, knowledgeBaseId: params.id, accessMode: body.accessMode, - credentialGroupId: body.credentialGroupId, - credentialGroupOptionId: body.credentialGroupOptionId, credentialId: body.credentialId, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 28199775a22..10eeff6ea5a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -50,8 +50,6 @@ export const POST = defineInternalJsonRoute({ sourceConfig: body.sourceConfig, syncIntervalMinutes: body.syncIntervalMinutes, accessMode: body.accessMode, - credentialGroupId: body.credentialGroupId, - credentialGroupOptionId: body.credentialGroupOptionId, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), source: 'ui' as const, diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts new file mode 100644 index 00000000000..4048c35577e --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth, mockConnectorRows, mockDispatch } = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn(() => null), + mockConnectorRows: vi.fn(), + mockDispatch: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) +vi.mock('@/lib/knowledge/connectors/directory-queue', () => ({ + dispatchDirectorySync: mockDispatch, +})) +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ orderBy: () => ({ limit: () => mockConnectorRows() }) }), + }), + }), + }), + }, +})) + +import { GET } from '@/app/api/knowledge/connectors/directory-sync/route' + +function connector(overrides: Record = {}) { + return { id: 'connector-1', ...overrides } +} + +async function run() { + const response = await GET(createMockRequest('GET')) + return response.json() +} + +describe('connector directory sync scheduler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronAuth.mockReturnValue(null) + mockDispatch.mockResolvedValue(undefined) + }) + + /** + * Every eligible connector is offered under one tick time; the tenant-level + * freshness check in the refresh, not the scheduler, decides which walk. + */ + it('dispatches a refresh for every admin-mode connector under the same tick', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + + await expect(run()).resolves.toMatchObject({ considered: 2, dispatched: 2, failed: 0 }) + expect(mockDispatch).toHaveBeenCalledTimes(2) + const [, first] = mockDispatch.mock.calls[0] + const [, second] = mockDispatch.mock.calls[1] + expect(first.tickAt).toBe(second.tickAt) + }) + + it('contains a dispatch failure to the connector that caused it', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + mockDispatch.mockRejectedValueOnce(new Error('queue unreachable')) + + await expect(run()).resolves.toMatchObject({ dispatched: 1, failed: 1 }) + }) + + it('refuses an unauthenticated tick', async () => { + mockVerifyCronAuth.mockReturnValue(new Response('nope', { status: 401 })) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(401) + expect(mockConnectorRows).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts new file mode 100644 index 00000000000..cf11790a382 --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts @@ -0,0 +1,83 @@ +import { db } from '@sim/db' +import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, eq, inArray, isNotNull, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MIRRORING_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' +import { dispatchDirectorySync } from '@/lib/knowledge/connectors/directory-queue' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ConnectorDirectorySyncSchedulerAPI') + +/** Connectors offered per tick, and how many dispatches are in flight at once. */ +const MAX_DIRECTORIES_PER_TICK = 200 +const DISPATCH_CONCURRENCY = 8 + +/** + * Refreshes the external directories that admin-mode connectors mirror. + * + * Group membership decides who can read an already-indexed document, so it has + * to move on its own clock: someone leaving a group should lose access in + * minutes, not on whatever schedule the corpus happens to be re-crawled on. The + * admin crawl refreshes the directory too — so a crawl can never publish grants + * against membership nobody has read — but that is a floor, not the cadence. + * + * Every eligible connector is offered each tick, and + * `syncExternalDirectoryGroups` decides whether its directory is actually due: + * a tenant is the credential's own site or domain, which the row does not + * carry, so connectors sharing one cost a refresh and a skip rather than a + * refresh each. The walk itself runs in the background, like every other + * connector job, because a large domain takes longer than a scheduler request + * lives. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickAt = new Date() + logger.info(`[${requestId}] Connector directory sync scheduler triggered`) + + const authError = verifyCronAuth(request, 'Connector directory sync scheduler') + if (authError) return authError + + const connectors = await db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeBase.deletedAt), + isNotNull(knowledgeBase.workspaceId) + ) + ) + .orderBy(asc(knowledgeConnector.createdAt)) + .limit(MAX_DIRECTORIES_PER_TICK) + + let dispatched = 0 + let failed = 0 + await mapWithConcurrency(connectors, DISPATCH_CONCURRENCY, async ({ id: connectorId }) => { + try { + await dispatchDirectorySync(connectorId, { requestId, tickAt }) + dispatched += 1 + } catch (error) { + failed += 1 + logger.error(`[${requestId}] Failed to dispatch a directory refresh`, { + connectorId, + error: getErrorMessage(error), + }) + } + }) + + const summary = { considered: connectors.length, dispatched, failed } + logger.info(`[${requestId}] Connector directory sync scheduler finished`, summary) + return Response.json({ success: true, ...summary }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts index 076344f81dd..5b65a397f65 100644 --- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts @@ -20,6 +20,7 @@ import { MAX_CONSECUTIVE_FAILURES, MEMBER_SYNC_STALE_LOCK_TTL_MS, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -168,7 +169,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where( and( eq(knowledgeConnector.accessMode, 'members'), - inArray(knowledgeConnector.status, ['active', 'error']), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES), lte(knowledgeConnector.nextMemberSyncAt, now), isNull(knowledgeConnector.archivedAt), diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 7d3d5afcbd6..8ea5880fdb2 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -8,6 +8,7 @@ import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attr import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CONTENT_ENGINE_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' import { dispatchSync } from '@/lib/knowledge/connectors/queue' import { CONNECTOR_AUTO_DISABLED_ERROR, @@ -16,6 +17,7 @@ import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -303,8 +305,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( - inArray(knowledgeConnector.status, ['active', 'error']), - eq(knowledgeConnector.accessMode, 'workspace'), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), lte(knowledgeConnector.nextSyncAt, now), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt), diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts new file mode 100644 index 00000000000..501735bbecb --- /dev/null +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ search: vi.fn() })) +vi.mock('@/lib/knowledge/application/search', () => ({ + searchKnowledge: { operation: { id: 'knowledge.search' }, execute: mocks.search }, +})) + +import { POST } from '@/app/api/knowledge/search/route' + +describe('workspace search route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' }, + session: { id: 'session-1' }, + }) + mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] }) + }) + + it('passes the authenticated request cancellation signal through the existing operation', async () => { + const controller = new AbortController() + const request = new NextRequest('http://localhost/api/knowledge/search', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: 'workspace-1', + knowledgeBaseIds: ['kb-1'], + query: 'Orion', + }), + signal: controller.signal, + }) + const response = await POST(request) + expect(response.status).toBe(200) + const call = mocks.search.mock.calls[0][0] + expect(call.principal).toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + expect(call.input.signal).toBe(request.signal) + controller.abort() + expect(call.input.signal.aborted).toBe(true) + await expect(response.json()).resolves.toEqual({ + success: true, + data: { query: 'Orion', results: [] }, + }) + }) + + it('authenticates before parsing and never enters search for an anonymous request', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const response = await POST( + new NextRequest('http://localhost/api/knowledge/search', { + method: 'POST', + body: '{', + headers: { 'content-type': 'application/json' }, + }) + ) + expect(response.status).toBe(401) + expect(mocks.search).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 96d78f3d1d7..4c434bbc9b7 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -17,11 +17,13 @@ export const POST = defineInternalJsonRoute({ reason: 'A person typing queries; the embedding call is metered against their workspace', }), errorPolicy: internalKnowledgeErrorPolicies.search, - mapInput: ({ body }) => ({ + mapInput: ({ body }, { request }) => ({ workspaceId: body.workspaceId, knowledgeBaseIds: body.knowledgeBaseIds, query: body.query, topK: body.topK, + surface: 'dashboard' as const, + signal: request.signal, }), useCase: searchKnowledge, present: ({ results, knowledgeBases }, { input }) => { diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index fd09d744f64..e5184169e8f 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -18,6 +18,14 @@ import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({ + PROVIDER_QUOTA_COOLDOWN_MS: 300_000, + ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {}, + isProviderQuotaExhausted: vi.fn().mockResolvedValue(false), + recordProviderCooldown: vi.fn().mockResolvedValue(undefined), + waitForProviderAdmission: vi.fn().mockResolvedValue(undefined), +})) + /** * Spy on the real documents/utils namespace instead of vi.mock: the shared * `@/lib/knowledge/embeddings` module may be cached bound to the real module, @@ -196,6 +204,27 @@ describe('Knowledge Search Utils', () => { }) describe('handleTagAndVectorSearch', () => { + it('returns only bounded ranked rows without first materializing every matching tag ID', async () => { + resetDbChainMock() + queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)]) + + const results = await handleTagAndVectorSearch({ + knowledgeBaseIds: ['kb-1', 'kb-2'], + access: WORKSPACE_ACCESS_SCOPE, + topK: 2, + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' }, + ], + queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 }, + distanceThreshold: 0.8, + }) + + expect(results.map((row) => row.id)).toEqual(['first', 'second']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) + }) + it('should throw error when no filters provided', async () => { const params = { knowledgeBaseIds: ['kb-123'], diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts index c13ebd49c16..3ca9327f9e7 100644 --- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts +++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts @@ -17,6 +17,7 @@ export const POST = defineInternalJsonRoute({ mapInput: ({ body }) => ({ workspaceId: body.workspaceId, connectorType: body.connectorType, + connectorId: body.connectorId, sourceConfig: body.sourceConfig, }), useCase: connectSimSearchConnector, diff --git a/apps/sim/app/api/knowledge/sim-search/prepare/route.ts b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts new file mode 100644 index 00000000000..ffad32fe0c5 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts @@ -0,0 +1,20 @@ +import { prepareSearchSourceContract } from '@/lib/api/contracts/knowledge/connectors' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { prepareSearchSource } from '@/lib/knowledge/application/sim-search' + +export const POST = defineInternalJsonRoute({ + contract: prepareSearchSourceContract, + auth: internalSessionAuth, + operation: knowledgeOperations.prepareSearchSource, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.sources.prepare' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ body }) => body, + useCase: prepareSearchSource, + present: (data) => ({ success: true as const, data }), +}) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 2fca7aa4ecc..47a3671dca8 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -19,6 +19,14 @@ import { env } from '@/lib/core/config/env' import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' import * as workspacesUtilsModule from '@/lib/workspaces/utils' +vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({ + PROVIDER_QUOTA_COOLDOWN_MS: 300_000, + ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {}, + isProviderQuotaExhausted: vi.fn().mockResolvedValue(false), + recordProviderCooldown: vi.fn().mockResolvedValue(undefined), + waitForProviderAdmission: vi.fn().mockResolvedValue(undefined), +})) + const envSnapshot = { ...env } afterAll(() => { diff --git a/apps/sim/app/api/mcp/search/[workspaceId]/route.ts b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts new file mode 100644 index 00000000000..432764767d1 --- /dev/null +++ b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts @@ -0,0 +1,99 @@ +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import type { NextRequest } from 'next/server' +import { knowledgeMcpContract } from '@/lib/api/contracts/knowledge/mcp' +import { parseRequest } from '@/lib/api/server' +import { + authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' +import { admitV2Request, v2RateLimits } from '@/lib/api/server/routes/v2-json-route' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readSearchIndex } from '@/lib/knowledge/application/sim-search' +import { createKnowledgeMcpServer } from '@/lib/knowledge/mcp/server' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' + +const mcpApiKeyAuth = { + authenticate(request: NextRequest) { + const apiKey = request.headers.get('x-api-key') + const authorization = request.headers.get('authorization') + const bearer = authorization?.match(/^Bearer ([^\s]+)$/i)?.[1] + if ((authorization && !bearer) || (apiKey && bearer && apiKey !== bearer)) { + throw new V2ApiKeyUnauthenticatedError('Provide one valid API key') + } + return authenticateV2ApiKey(apiKey ?? bearer ?? null) + }, +} + +/** JSON-RPC is a protocol boundary; SDK dispatch calls the same authorized knowledge use cases. */ +const handler = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ workspaceId: string }> }) => { + const admission = await admitV2Request( + request, + knowledgeOperations.readSearchIndex, + mcpApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + const origin = request.headers.get('origin') + if (origin && origin !== new URL(getBaseUrl()).origin) { + return v2Error('FORBIDDEN', 'Origin is not allowed') + } + try { + const parsed = await parseRequest(knowledgeMcpContract, request, context, { + maxBodyBytes: 64 * 1024, + }) + if (!parsed.success) return parsed.response + const index = await readSearchIndex.execute({ + principal: admission.auth.principal, + input: { workspaceId: parsed.data.params.workspaceId }, + request, + }) + const server = createKnowledgeMcpServer({ + request, + auth: admission.auth, + workspaceId: index.workspaceId, + searchIndexId: index.knowledgeBaseId, + }) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + try { + await server.connect(transport) + const response = await transport.handleRequest(request, { parsedBody: parsed.data.body }) + response.headers.set('Cache-Control', 'private, no-store') + return response + } finally { + await server.close() + } + } catch (error) { + const response = v2CaughtOrchestrationError(error) + if (response) return response + throw error + } + } +) + +export const POST = handler + +/** Stateless clients use POST only; authenticate unsupported methods before returning 405. */ +const unsupportedMethod = withRouteHandler(async (request: NextRequest) => { + const admission = await admitV2Request( + request, + knowledgeOperations.readSearchIndex, + mcpApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + return new Response(null, { + status: 405, + headers: { Allow: 'POST', 'Cache-Control': 'private, no-store' }, + }) +}) + +export const GET = unsupportedMethod +export const DELETE = unsupportedMethod diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 2f17fb5ea34..ed6f09d4352 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -326,6 +326,7 @@ describe('MCP Serve Route', () => { workflowId: 'wf-1', userId: 'user-1', triggerType: 'mcp', + principal: PERSONAL_API_KEY_PRINCIPAL, useAuthenticatedUserAsActor: true, deploymentVersionId: 'deployment-1', includeFileBase64: false, @@ -427,6 +428,7 @@ describe('MCP Serve Route', () => { expect(mockExecuteWorkflowService).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', + principal: WORKSPACE_API_KEY_PRINCIPAL, useAuthenticatedUserAsActor: false, }) ) diff --git a/apps/sim/app/api/v1/admin/dashboard/actor.ts b/apps/sim/app/api/v1/admin/dashboard/actor.ts index c3237cd200a..57b0cb0d912 100644 --- a/apps/sim/app/api/v1/admin/dashboard/actor.ts +++ b/apps/sim/app/api/v1/admin/dashboard/actor.ts @@ -1,16 +1,18 @@ import { db } from '@sim/db' -import { user } from '@sim/db/schema' -import { eq, or } from 'drizzle-orm' +import { foldedEmail, user } from '@sim/db/schema' +import { normalizeEmail } from '@sim/utils/string' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { AdminMutationActor } from '@/lib/admin/dashboard' export async function getAdminAuditActor(request: NextRequest): Promise { - const email = request.headers.get('x-admin-email')?.trim().toLowerCase() + const rawEmail = request.headers.get('x-admin-email') + const email = rawEmail ? normalizeEmail(rawEmail) : '' if (!email) return { id: null, name: 'Admin API', email: null } const [admin] = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) - .where(or(eq(user.email, email), eq(user.normalizedEmail, email))) + .where(eq(foldedEmail(user.email), email)) .limit(1) return admin ?? { id: null, name: 'Admin Panel', email } } diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index d63e5f2ae4f..56d10d72095 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -75,8 +75,8 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, - // Read at module scope by `lib/uploads/utils/file-utils`, which the route now - // reaches transitively through the knowledge orchestration module. + /** Shared upload/connector limits are read by the knowledge orchestration imports. */ + MAX_FILE_SIZE: 100 * 1024 * 1024, SUPPORTED_ARCHIVE_EXTENSIONS: [], })) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts index fe52256bca6..54187076a46 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts @@ -325,9 +325,10 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunk }) describe('chunk operation policy', () => { - it('denies workspace API keys on every chunk operation', () => { + it('allows ACL-filtered chunk listing while retaining the other chunk policies', () => { + expect(knowledgeOperations.listChunks.workspaceApiKey).toBe('allow') + expect(knowledgeOperations.listChunks.principalKinds).toContain('workspace_api_key') for (const operation of [ - knowledgeOperations.listChunks, knowledgeOperations.readChunk, knowledgeOperations.createChunk, knowledgeOperations.updateChunk, diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index 562c995d3aa..1688466dcc6 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -87,6 +87,7 @@ describe('POST /api/v2/knowledge/search', () => { expect(mockSearch).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { + surface: 'api', workspaceId: WORKSPACE_ID, knowledgeBaseIds: ['kb-1'], query: 'hello', diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 4cb0bb11edb..28b2d299740 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -27,6 +27,7 @@ export const POST = defineV2JsonRoute({ : [body.knowledgeBaseIds], query: body.query, topK: body.topK, + surface: 'api' as const, tagFilters: body.tagFilters, searchMode: body.searchMode, rerankerEnabled: body.rerankerEnabled, diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts index d47add6d93a..647fe51daa1 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -1,5 +1,4 @@ import { - deleteCredentialGroupContract, getCredentialGroupContract, updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' @@ -9,7 +8,6 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { - deleteCredentialGroupSettings, getCredentialGroupSettings, updateCredentialGroupSettings, } from '@/lib/credential-groups/application/manage-groups' @@ -49,17 +47,3 @@ export const PATCH = defineInternalJsonRoute({ useCase: updateCredentialGroupSettings, present: ({ credentialGroup }) => ({ credentialGroup }), }) - -export const DELETE = defineInternalJsonRoute({ - contract: deleteCredentialGroupContract, - auth: internalSessionAuth, - operation: credentialGroupOperations.delete, - rateLimit, - errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'), - mapInput: ({ params }) => ({ - assertedWorkspaceId: params.id, - credentialGroupId: params.groupId, - }), - useCase: deleteCredentialGroupSettings, - present: () => ({ success: true as const }), -}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts index 21a9e594ede..fb02424e456 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts @@ -33,6 +33,7 @@ export const POST = defineInternalJsonRoute({ slackBotCredentialId: body.slackBotCredentialId, clientId: body.clientId, clientSecret: body.clientSecret, + requiredScopes: body.requiredScopes, }), useCase: startSlackCredentialGroupConfiguration, }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts new file mode 100644 index 00000000000..4f6d7ba70bb --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ ensure: vi.fn(), getSession: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ + ensureWorkspaceAccounts: { + operation: { id: 'credential_groups.workspace.ensure' }, + execute: mocks.ensure, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/workspaces/[id]/credential-groups/ensure/route' + +const workspaceId = '11111111-1111-4111-8111-111111111111' +const credentialGroup = { + id: '22222222-2222-4222-8222-222222222222', + workspaceId, + name: 'Connected accounts', + description: null, + options: [], + mcpServers: [], + status: 'active', + createdAt: '2026-09-04T00:00:00.000Z', + updatedAt: '2026-09-04T00:00:00.000Z', +} +const context = { params: Promise.resolve({ id: workspaceId }) } +const request = () => + new NextRequest(`http://localhost:3000/api/workspaces/${workspaceId}/credential-groups/ensure`, { + method: 'POST', + }) + +describe('workspace connected accounts setup route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.ensure.mockResolvedValue({ credentialGroup, created: true }) + }) + + it('authenticates before validating workspace parameters', async () => { + mocks.getSession.mockResolvedValue(null) + const response = await POST(request(), { params: Promise.resolve({ id: '' }) }) + expect(response.status).toBe(401) + expect(mocks.ensure).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'returns the same account shape when newly created is %s', + async (created) => { + mocks.ensure.mockResolvedValue({ credentialGroup, created }) + const input = request() + const response = await POST(input, context) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentialGroup }) + expect(mocks.ensure).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { workspaceId }, + request: input, + }) + } + ) + + it('preserves the application authorization refusal', async () => { + mocks.ensure.mockRejectedValue(new OrchestrationError('forbidden', 'Admin access required')) + const response = await POST(request(), context) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Admin access required' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts new file mode 100644 index 00000000000..f4bc337bbd2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts @@ -0,0 +1,23 @@ +import { ensureWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { ensureWorkspaceAccounts } from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: ensureWorkspaceAccountsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.ensureWorkspaceAccounts, + rateLimit: internalRateLimits.none({ reason: 'Idempotent, admin-only workspace account setup' }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to set up connected accounts', + 'Workspace not found' + ), + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: ensureWorkspaceAccounts, + present: ({ credentialGroup }) => ({ credentialGroup }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts index 6259951e256..0016c90d4a2 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -6,7 +6,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - create: vi.fn(), getSession: vi.fn(), list: vi.fn(), })) @@ -14,30 +13,20 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ - createCredentialGroupSettings: { - operation: { id: 'credential_groups.create' }, - execute: mocks.create, - }, - listCredentialGroupSettings: { - operation: { id: 'credential_groups.settings.list' }, + getWorkspaceAccountsSettings: { + operation: { id: 'credential_groups.workspace.read' }, execute: mocks.list, }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' -import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route' +import { GET } from '@/app/api/workspaces/[id]/credential-groups/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } -function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest { - return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, { - method, - ...(body - ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } } - : {}), - }) +function createRequest(): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`) } describe('credential groups collection route', () => { @@ -47,24 +36,15 @@ describe('credential groups collection route', () => { user: { id: 'user-1' }, session: { id: 'session-1' }, }) - mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] }) - }) - - it('authenticates before parsing the request body', async () => { - mocks.getSession.mockResolvedValue(null) - - const response = await POST(createRequest('POST', {}), context) - - expect(response.status).toBe(401) - expect(mocks.create).not.toHaveBeenCalled() + mocks.list.mockResolvedValue({ credentialGroup: null, availableProviders: ['gmail'] }) }) it('enters the application use case with the authenticated session principal', async () => { - const request = createRequest('GET') + const request = createRequest() const response = await GET(request, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] }) + expect(await response.json()).toEqual({ credentialGroup: null, availableProviders: ['gmail'] }) expect(mocks.list).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: WORKSPACE_ID }, @@ -77,28 +57,9 @@ describe('credential groups collection route', () => { new OrchestrationError('not_found', 'Credential Groups are not available') ) - const response = await GET(createRequest('GET'), context) + const response = await GET(createRequest(), context) expect(response.status).toBe(404) expect(await response.json()).toEqual({ error: 'Credential Groups are not available' }) }) - - it('fails fast when managed Gmail OAuth is not configured', async () => { - mocks.create.mockRejectedValue( - new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured') - ) - - const response = await POST( - createRequest('POST', { - name: 'Support inboxes', - options: [{ provider: 'gmail', label: 'Gmail', required: true }], - }), - context - ) - - expect(response.status).toBe(503) - expect(await response.json()).toEqual({ - error: 'Managed Gmail authorization is not configured', - }) - }) }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts index c776f985698..27b7c06beea 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts @@ -1,45 +1,24 @@ -import { - createCredentialGroupContract, - listCredentialGroupsContract, -} from '@/lib/api/contracts/credential-groups' +import { getWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { - createCredentialGroupSettings, - listCredentialGroupSettings, -} from '@/lib/credential-groups/application/manage-groups' +import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' export const GET = defineInternalJsonRoute({ - contract: listCredentialGroupsContract, + contract: getWorkspaceAccountsContract, auth: internalSessionAuth, - operation: credentialGroupOperations.listSettings, + operation: credentialGroupOperations.workspaceSettings, rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group list behavior', + reason: 'Workspace account settings do not require additional admission limits', }), errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to list credential groups', + 'Failed to load connected accounts', 'Workspace not found' ), mapInput: ({ params }) => ({ workspaceId: params.id }), - useCase: listCredentialGroupSettings, -}) - -export const POST = defineInternalJsonRoute({ - contract: createCredentialGroupContract, - auth: internalSessionAuth, - operation: credentialGroupOperations.create, - rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group create behavior', - }), - errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to create credential group', - 'Workspace not found' - ), - mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }), - useCase: createCredentialGroupSettings, + useCase: getWorkspaceAccountsSettings, }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 35f72a05818..d74c543210e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -10,6 +10,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + Tooltip, } from '@sim/emcn' import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options' @@ -30,6 +31,8 @@ interface FolderContextMenuProps { pinned: boolean moveOptions?: MoveOptionNode[] canEdit: boolean + canDelete?: boolean + deleteDisabledReason?: string selectedCount: number } @@ -58,12 +61,14 @@ export const FolderContextMenu = memo(function FolderContextMenu({ pinned, moveOptions, canEdit, + canDelete = canEdit, + deleteDisabledReason, selectedCount, }: FolderContextMenuProps) { const isMultiSelect = selectedCount > 1 const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0) const hasActionsAboveDestructive = !isMultiSelect || hasMove - const hasAvailableActions = !isMultiSelect || canEdit + const hasAvailableActions = !isMultiSelect || (canEdit && (hasMove || canDelete)) return ( !open && onClose()} modal={false}> @@ -122,11 +127,29 @@ export const FolderContextMenu = memo(function FolderContextMenu({ )} - {hasActionsAboveDestructive && } - - - {selectionActionLabel('Delete', selectedCount)} - + {canDelete && ( + <> + {hasActionsAboveDestructive && } + {deleteDisabledReason ? ( + + +
+ + + {selectionActionLabel('Delete', selectedCount)} + +
+
+ {deleteDisabledReason} +
+ ) : ( + + + {selectionActionLabel('Delete', selectedCount)} + + )} + + )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx index 5a792131fd5..499b185fc72 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx @@ -6,13 +6,20 @@ vi.mock('@sim/emcn', () => ({ DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) => open ? <>{children} : null, DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuItem: ({ children }: { children: ReactNode }) => {children}, + DropdownMenuItem: ({ children, disabled }: { children: ReactNode; disabled?: boolean }) => ( + {children} + ), DropdownMenuSeparator: () =>
, DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => {children}, DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, Upload: () => null, + Tooltip: { + Root: ({ children }: { children: ReactNode }) => <>{children}, + Trigger: ({ children }: { children: ReactNode }) => <>{children}, + Content: ({ children }: { children: ReactNode }) => <>{children}, + }, })) vi.mock('@sim/emcn/icons', () => ({ @@ -47,6 +54,48 @@ const POSITION = { x: 0, y: 0 } const MOVE_OPTIONS = [{ value: '__root__', label: 'Root', children: [] }] describe('selection-aware resource context menus', () => { + it('hides a protected mixed-folder delete while retaining movement', () => { + const menu = renderToStaticMarkup( + {}} + onOpen={() => {}} + onRename={() => {}} + onDelete={() => {}} + onMove={() => {}} + onTogglePin={() => {}} + pinned={false} + canEdit + canDelete={false} + moveOptions={MOVE_OPTIONS} + selectedCount={2} + /> + ) + expect(menu).toContain('Move 2 items') + expect(menu).not.toContain('Delete') + }) + + it('explains a blocked folder cascade while retaining ordinary folder actions', () => { + const menu = renderToStaticMarkup( + {}} + onOpen={() => {}} + onRename={() => {}} + onDelete={() => {}} + onTogglePin={() => {}} + pinned={false} + canEdit + deleteDisabledReason='Delete the search knowledge base first' + selectedCount={1} + /> + ) + expect(menu).toContain('Rename') + expect(menu).toContain('Delete the search knowledge base first') + expect(menu).toContain('aria-disabled="true"') + }) it('limits a multi-table menu to actions that can target the selection', () => { const menu = renderToStaticMarkup( ({ + rows: vi.fn(), + admin: vi.fn(), + enabled: vi.fn(), + connect: vi.fn(), +})) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useWorkspaceHostContext: () => ({ features: { knowledgeMemberAccess: mocks.enabled() } }), +})) +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.admin() } } }), +})) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ integrationAvailability: new Map() }), +})) +vi.mock('@/hooks/queries/kb/connectors', () => ({ + useWorkspaceMemberConnectors: () => ({ data: mocks.rows() }), + memberConnectorKeys: { list: (id: string) => ['member-connectors', id] }, +})) +vi.mock('@/hooks/use-member-enrollment', () => ({ + CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), + useMemberEnrollment: () => ({ + connectSearchSource: mocks.connect, + isAwaiting: () => false, + isAwaitingSource: () => false, + isPending: false, + setupConnector: null, + }), +})) +vi.mock('@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal', () => ({ + SourceSetupModal: () => null, +})) +vi.mock('@/lib/integrations/credential-display', () => ({ + getIntegrationsForCredentialProvider: () => [], +})) +vi.mock('@/lib/oauth', () => ({ + getCanonicalScopesForProvider: () => [], + getServiceConfigByProviderId: () => undefined, + getServiceConfigByServiceId: (id: string) => ({ providerId: id, name: id, icon: () => null }), +})) +vi.mock('@/connectors/registry', () => ({ + CONNECTOR_META_REGISTRY: Object.fromEntries( + ['confluence', 'google_drive', 'slack'].map((id) => [ + id, + { + id, + name: id, + search: true, + icon: () => null, + auth: { mode: 'oauth', provider: id }, + permissionScopedListing: { capFieldIds: [] }, + configFields: [], + }, + ]) + ), +})) + +import { SearchSources } from '@/app/workspace/[workspaceId]/home/components/search-sources/search-sources' + +let container: HTMLDivElement +let root: Root +const source = (overrides: Partial = {}): WorkspaceMemberConnector => ({ + knowledgeBaseId: 'canonical-index', + knowledgeBaseName: 'Renamed company index', + knowledgeBaseIsSearchIndex: true, + connectorId: 'source-one', + connectorType: 'confluence', + sourceDescription: 'company.atlassian.net · ENG', + memberSyncStatus: 'idle', + viewerMembership: 'not_enrolled', + viewerDocumentCount: 0, + ...overrides, +}) +function mount(rows: WorkspaceMemberConnector[]) { + mocks.rows.mockReturnValue(rows) + act(() => root.render()) +} +function chips() { + return [...container.querySelectorAll('button')] +} +beforeEach(() => { + vi.clearAllMocks() + mocks.admin.mockReturnValue(false) + mocks.enabled.mockReturnValue(true) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('home Search source connections', () => { + it('lets a reader connect a configured source after the canonical index is renamed', () => { + const connection = source() + mount([connection]) + const chip = chips().find((button) => button.textContent === 'confluence')! + expect(chip.disabled).toBe(false) + act(() => chip.click()) + expect(mocks.connect).toHaveBeenCalledWith( + 'workspace', + expect.objectContaining({ type: 'confluence' }), + connection + ) + expect(chips().find((button) => button.textContent === 'google_drive')?.disabled).toBe(true) + }) + + it('keeps distinct configured sites visible and connects only the selected source', () => { + const first = source({ viewerMembership: 'connected', viewerDocumentCount: 2 }) + const second = source({ + connectorId: 'source-two', + sourceDescription: 'other.atlassian.net · OPS', + }) + mount([first, second]) + expect(container.textContent).toContain('company.atlassian.net · ENG') + expect(container.textContent).toContain('other.atlassian.net · OPS') + const chip = chips().find((button) => button.textContent?.includes('other.atlassian.net'))! + act(() => chip.click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith( + 'workspace', + expect.objectContaining({ type: 'confluence' }), + second + ) + }) + + it('does not use a same-named ordinary knowledge base as the canonical index', () => { + mount([source({ knowledgeBaseIsSearchIndex: false, knowledgeBaseName: 'Sim Search' })]) + expect(chips().every((button) => button.disabled)).toBe(true) + expect(mocks.connect).not.toHaveBeenCalled() + }) + + it('does not offer stale cached connections after member access is disabled', () => { + mocks.enabled.mockReturnValue(false) + mount([source({ viewerMembership: 'connected', viewerDocumentCount: 99 })]) + expect(container.textContent).not.toContain('99 documents') + expect(chips().every((button) => button.disabled)).toBe(true) + }) + + it.each(['revoked', 'unverified_email'] as const)( + 'does not re-enroll an account with %s access', + (viewerMembership) => { + mount([source({ viewerMembership })]) + const chip = chips().find((button) => button.textContent?.startsWith('confluence'))! + expect(chip.getAttribute('aria-disabled')).toBe('true') + act(() => chip.click()) + expect(mocks.connect).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx index e59ce49e348..c60a8ab739d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx @@ -3,11 +3,11 @@ import { useMemo } from 'react' import { Chip, chipContentGap, cn } from '@sim/emcn' import { Loader, Plus } from '@sim/emcn/icons' +import { groupSearchConnections } from '@/lib/sim-search/connections' import { canConnectPersonally, SEARCH_CONNECTORS, type SearchConnector, - SIM_SEARCH_KNOWLEDGE_BASE_NAME, searchConnectorUnavailableReason, } from '@/lib/sim-search/connectors' import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' @@ -29,18 +29,6 @@ const PERSONAL_SEARCH_CONNECTORS = SEARCH_CONNECTORS.filter((connector) => canConnectPersonally(connector.meta) ) -/** The Sim Search connection per source, keyed by connector type. */ -function simSearchConnectionsByType( - connectors: readonly WorkspaceMemberConnector[] -): Map { - const byType = new Map() - for (const connector of connectors) { - if (connector.knowledgeBaseName !== SIM_SEARCH_KNOWLEDGE_BASE_NAME) continue - if (!byType.has(connector.connectorType)) byType.set(connector.connectorType, connector) - } - return byType -} - /** Whether a connected source is still indexing for the viewer. */ export function isIndexing(connection: WorkspaceMemberConnector | undefined): boolean { return ( @@ -77,6 +65,7 @@ function sourceState( interface SourceChipProps { connector: SearchConnector connection: WorkspaceMemberConnector | undefined + showSource: boolean /** Why the source cannot be connected here, shown as the chip's title; null when it can. */ unavailableReason: string | null waiting: boolean @@ -87,6 +76,7 @@ interface SourceChipProps { function SourceChip({ connector, connection, + showSource, unavailableReason, waiting, disabled, @@ -99,9 +89,11 @@ function SourceChip({ !unavailable && !waiting && (!connection || CONNECTABLE_MEMBERSHIPS.has(connection.viewerMembership)) - const title = - unavailableReason ?? - (connected ? `${connector.meta.name}: ${state}` : `Connect ${connector.meta.name}`) + const name = + showSource && connection?.sourceDescription + ? `${connector.meta.name} · ${connection.sourceDescription}` + : connector.meta.name + const title = unavailableReason ?? (connected ? `${name}: ${state}` : `Connect ${name}`) const busy = waiting || isIndexing(connection) return ( - {connector.meta.name} + {name} {state && {state}} @@ -156,8 +148,8 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) { const memberConnectors = memberAccessAvailable ? (memberConnectorRows ?? EMPTY_MEMBER_CONNECTORS) : EMPTY_MEMBER_CONNECTORS - const connectionByType = useMemo( - () => simSearchConnectionsByType(memberConnectors), + const { connectionByType } = useMemo( + () => groupSearchConnections(memberConnectors), [memberConnectors] ) const connectedConnectorIds = useMemo( @@ -183,7 +175,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) { /** Connected sources first; the catalog is already alphabetical, so the partition keeps the order. */ const isConnected = (connector: SearchConnector) => - connectionByType.get(connector.type)?.viewerMembership === 'connected' + connectionByType.get(connector.type)?.some((source) => source.viewerMembership === 'connected') const ordered = [ ...PERSONAL_SEARCH_CONNECTORS.filter(isConnected), ...PERSONAL_SEARCH_CONNECTORS.filter((connector) => !isConnected(connector)), @@ -192,13 +184,14 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) { return (
- {ordered.map((connector) => { - const connection = connectionByType.get(connector.type) - return ( + {ordered.flatMap((connector) => { + const connections = connectionByType.get(connector.type) ?? [] + return (connections.length ? connections : [undefined]).map((connection) => ( 1} unavailableReason={searchConnectorUnavailableReason( connector, integrationAvailability, @@ -210,7 +203,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) { disabled={isPending} onConnect={() => connectSearchSource(workspaceId, connector, connection)} /> - ) + )) })}
{error &&

{error}

} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx index 240becccf96..dfcb9bd08c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx @@ -62,8 +62,19 @@ vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', a return { usePromptEditor, ModeSwitcher, - PromptEditor: ({ editor }: { editor: PromptEditorInstance }) => ( -