Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/sim/app/api/knowledge/member-connectors/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const GET = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: knowledgeOperations.listWorkspaceMemberConnectors,
rateLimit: internalRateLimits.none({ reason: 'One small read per visit to the Search tab' }),
errorPolicy: internalKnowledgeErrorPolicies.connectors,
errorPolicy: internalKnowledgeErrorPolicies.memberConnectors,
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
useCase: listWorkspaceMemberConnectors,
present: ({ connectors }) => ({ success: true as const, data: connectors }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,12 @@ export const DELETE = withRouteHandler(
)
if (result instanceof NextResponse) return result

const doc = await getKnowledgeDocument(
knowledgeBaseId,
documentId,
await resolveV1KnowledgeAccessScope(userId, rateLimit, parsed.data.query.workspaceId)
const access = await resolveV1KnowledgeAccessScope(
userId,
rateLimit,
parsed.data.query.workspaceId
)
const doc = await getKnowledgeDocument(knowledgeBaseId, documentId, access)

if (!doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
Expand All @@ -130,6 +131,7 @@ export const DELETE = withRouteHandler(
workspaceId: parsed.data.query.workspaceId,
},
document: { id: documentId, filename: doc.filename },
access,
userId,
source: 'api',
requestId,
Expand Down
17 changes: 11 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -543,23 +543,28 @@ export function Home({ chatId, userName, userId }: HomeProps) {
]
)

/** An emptied search box returns to the sources; a send in any other mode has no search to clear. */
const clearSearch = useCallback(() => {
if (searchQueryValue !== null) setSearchQuery('')
}, [searchQueryValue, setSearchQuery])

/**
* A queued message re-enters the composer in the mode it was written in: an
* Assistant question edits as an Assistant question, and never as a Search,
* which submits nothing and would leave the edit stranded.
*
* The live query goes with it. A restored mode is never Search, and a query
* left in the URL pulls the composer straight back to Search, which is the
* one mode the edit cannot be sent from.
*/
const restoreQueuedMode = useCallback(
(requestMode: QueuedMessage['requestMode']) => {
void setComposerMode(requestMode === 'ask' ? 'assistant' : 'build')
clearSearch()
},
[setComposerMode]
[clearSearch, setComposerMode]
)

/** An emptied search box returns to the sources; a send in any other mode has no search to clear. */
const clearSearch = useCallback(() => {
if (searchQueryValue !== null) setSearchQuery('')
}, [searchQueryValue, setSearchQuery])

/**
* Summarize or Answer on a result: switch to Assistant and hand the question
* to it. The submit reads the mode from this render, so it is sent as an
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/hooks/queries/credential-groups', () => ({ useCredentialGroups: vi.fn() }))

import { connectorMemberGroupProvider } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorMeta } from '@/connectors/types'

const permissionScoped = Object.values(CONNECTOR_META_REGISTRY).filter(
(meta) => meta.permissionScopedListing !== undefined
)

describe('connectorMemberGroupProvider', () => {
it.each(permissionScoped.map((meta) => [meta.id, meta] as const))(
'resolves %s, so its Access control renders',
(_id, meta) => {
expect(connectorMemberGroupProvider(meta)).not.toBeNull()
}
)

/**
* Slack authorizes through the workspace's own app rather than Sim's OAuth
* client. Resolving only the providers Sim owns a client for left it with no
* way into per-member access and no way back out of it.
*/
it('resolves Slack, which no standard OAuth client backs', () => {
const slack = CONNECTOR_META_REGISTRY.slack
expect(slack.permissionScopedListing).toBeDefined()
expect(connectorMemberGroupProvider(slack)).toBe('slack')
})

it('resolves nothing for a connector no Credential Group collects accounts for', () => {
const sftp = CONNECTOR_META_REGISTRY.sftp
expect(connectorMemberGroupProvider(sftp)).toBeNull()
})

it('resolves nothing for a connector whose listing is not permission scoped', () => {
const withoutScopedListing = {
...CONNECTOR_META_REGISTRY.slack,
permissionScopedListing: undefined,
} as ConnectorMeta
expect(connectorMemberGroupProvider(withoutScopedListing)).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import { useMemo } from 'react'
import type { ComboboxOption } from '@sim/emcn'
import {
type CredentialGroupStandardOAuthProvider,
type CredentialGroupProvider,
findCredentialGroupProviderByProviderId,
getCredentialGroupProviderId,
getCredentialGroupStandardOAuthProviderFromProviderId,
isCredentialGroupProvider,
isSelectableCredentialGroupOption,
} from '@/lib/credential-groups/providers'
import type { ConnectorMeta } from '@/connectors/types'
import { useCredentialGroups } from '@/hooks/queries/credential-groups'
Expand All @@ -30,16 +30,18 @@ export function decodeConnectorMemberGroupOption(
}
}

/** The credential-group provider that collects accounts for this connector, if any. */
function connectorMemberGroupProvider(
/**
* The credential-group provider that collects accounts for this connector, if
* any. Every provider counts, not only those Sim authorizes with its own OAuth
* client: Slack collects accounts through the workspace's own app, and asking
* only about the standard ones leaves a Slack connector with no Access control
* at all.
*/
export function connectorMemberGroupProvider(
connectorConfig: ConnectorMeta
): CredentialGroupStandardOAuthProvider | null {
): CredentialGroupProvider | null {
if (connectorConfig.auth.mode !== 'oauth' || !connectorConfig.permissionScopedListing) return null
try {
return getCredentialGroupStandardOAuthProviderFromProviderId(connectorConfig.auth.provider)
} catch {
return null
}
return findCredentialGroupProviderByProviderId(connectorConfig.auth.provider)
}

/** The config fields a per-member connector hides: its listing caps, which the server clears. */
Expand Down Expand Up @@ -94,9 +96,8 @@ export function useConnectorMemberGroupOptions({
for (const group of settings.credentialGroups) {
if (group.status !== 'active') continue
for (const option of group.options) {
if (option.status !== 'active') continue
if (!isCredentialGroupProvider(option.provider)) continue
if (getCredentialGroupProviderId(option.provider) !== providerId) continue
/** The server's own rule, so the picker never offers an option it would refuse. */
if (!isSelectableCredentialGroupOption(option, providerId)) continue
entries.push({
label: `${group.name} · ${option.label}`,
value: encodeConnectorMemberGroupOption(group.id, option.id),
Expand Down
10 changes: 2 additions & 8 deletions apps/sim/lib/credential-groups/application/create-invite-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
CredentialGroupEnrollmentError,
createCredentialGroupInvitationLink,
rethrowEnrollmentErrorAsOrchestrationError,
} from '@/lib/credential-groups/enrollments'

export interface CreateCredentialGroupInviteLinkInput {
Expand Down Expand Up @@ -45,13 +45,7 @@ export const createCredentialGroupInviteLink = defineAuthorizedWorkspaceUseCase(
email
)
} catch (error) {
if (error instanceof CredentialGroupEnrollmentError) {
throw new OrchestrationError(
error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal',
error.message
)
}
throw error
rethrowEnrollmentErrorAsOrchestrationError(error)
}
},
projectAudit: ({ context, result }) => ({
Expand Down
16 changes: 4 additions & 12 deletions apps/sim/lib/credential-groups/application/manage-enrollments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation'
import {
CredentialGroupEnrollmentError,
deleteCredentialGroupEnrollment,
inviteCredentialGroupEnrollments,
loadCredentialGroupInviterIdentity,
resendCredentialGroupEnrollment,
rethrowEnrollmentErrorAsOrchestrationError,
} from '@/lib/credential-groups/enrollments'
import { mcpService } from '@/lib/mcp/service'

Expand All @@ -21,14 +21,6 @@ interface CredentialGroupEnrollmentSettingsInput {
credentialGroupId: string
}

function normalizeEnrollmentError(error: unknown): never {
if (error instanceof CredentialGroupEnrollmentError) {
if (error.status === 404) throw new OrchestrationError('not_found', error.message)
if (error.status === 409) throw new OrchestrationError('conflict', error.message)
}
throw error
}

async function requireInviterIdentity(userId: string): Promise<string> {
const inviter = await loadCredentialGroupInviterIdentity(userId)
const inviterName = inviter?.name?.trim() || inviter?.email
Expand Down Expand Up @@ -61,7 +53,7 @@ export const inviteCredentialGroupEnrollmentsSettings = defineAuthorizedWorkspac
{ emails }
)
} catch (error) {
normalizeEnrollmentError(error)
rethrowEnrollmentErrorAsOrchestrationError(error)
}
},
projectAudit: ({ context, result }) => ({
Expand Down Expand Up @@ -97,7 +89,7 @@ export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace
)
return { credentialGroupEnrollment }
} catch (error) {
normalizeEnrollmentError(error)
rethrowEnrollmentErrorAsOrchestrationError(error)
}
},
projectAudit: ({ context, result }) => ({
Expand Down Expand Up @@ -129,7 +121,7 @@ export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace
input.enrollmentId
)
} catch (error) {
normalizeEnrollmentError(error)
rethrowEnrollmentErrorAsOrchestrationError(error)
}
},
projectAudit: ({ context, result }) => ({
Expand Down
10 changes: 2 additions & 8 deletions apps/sim/lib/credential-groups/application/send-invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
CredentialGroupEnrollmentError,
inviteCredentialGroupEnrollment,
loadCredentialGroupInviterIdentity,
rethrowEnrollmentErrorAsOrchestrationError,
} from '@/lib/credential-groups/enrollments'

export interface SendCredentialGroupInviteInput {
Expand Down Expand Up @@ -57,13 +57,7 @@ export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({
)
return { enrollment }
} catch (error) {
if (error instanceof CredentialGroupEnrollmentError) {
throw new OrchestrationError(
error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal',
error.message
)
}
throw error
rethrowEnrollmentErrorAsOrchestrationError(error)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
},
projectAudit: ({ input, context, result }) => ({
Expand Down
31 changes: 31 additions & 0 deletions apps/sim/lib/credential-groups/enrollments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { and, asc, count, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle
import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render'
import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects'
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors'
Expand Down Expand Up @@ -182,6 +183,36 @@ export class CredentialGroupEnrollmentError extends Error {
}
}

/**
* Rethrows an enrollment failure as the orchestration error its status already
* implies. The mapping belongs to the error rather than to each caller: every
* surface that issues an invitation reports a missing group, a disabled one, or
* one collecting no accounts the same way, and a status added here reaches all
* of them at once. Anything that is not an enrollment failure passes through
* untouched.
*/
export function rethrowEnrollmentErrorAsOrchestrationError(error: unknown): never {
if (error instanceof CredentialGroupEnrollmentError) {
/**
* Every status the error can carry is mapped, so none falls through to a
* bare rethrow and loses its message to a generic internal failure. A 502
* is upstream rather than the caller's fault, but saying which upstream
* step failed is still the useful half of the answer.
*/
switch (error.status) {
case 400:
throw new OrchestrationError('validation', error.message)
case 404:
throw new OrchestrationError('not_found', error.message)
case 409:
throw new OrchestrationError('conflict', error.message)
case 502:
throw new OrchestrationError('internal', error.message)
}
}
throw error
}

interface CredentialGroupEnrollmentCursor {
id: string
invitedAt: Date
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/lib/credential-groups/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,39 @@ export function getCredentialGroupProviderId(provider: CredentialGroupProvider):
return getCredentialGroupProviderService(provider).providerId
}

/**
* The credential-group provider collecting accounts for a service, or null when
* none does. The throwing form below stays for callers that have already
* established the provider exists; this one is for asking the question.
*/
export function findCredentialGroupProviderByProviderId(
providerId: string
): CredentialGroupProvider | null {
return (
CREDENTIAL_GROUP_PROVIDER_IDS.find(
(candidate) => getCredentialGroupProviderId(candidate) === providerId
) ?? null
)
}

/**
* Whether an option is one a connector may actually sync through: live, and —
* for a provider configured per group, such as Slack through the workspace's
* own app — configured. Shared so the settings UI offers exactly the options
* the server would go on to pick, rather than one it will refuse.
*/
export function isSelectableCredentialGroupOption(
option: { provider: string; status: string; configurationStatus: string },
providerId: string
): boolean {
return (
option.status === 'active' &&
option.configurationStatus === 'ready' &&
isCredentialGroupProvider(option.provider) &&
getCredentialGroupProviderId(option.provider) === providerId
)
}

export function getCredentialGroupProviderFromProviderId(
providerId: string
): CredentialGroupProvider {
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/knowledge/api/route-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export const internalKnowledgeErrorPolicies = {
*/
bulkMove: internalKnowledgeErrorPolicy('Failed to move knowledge bases'),
bulkDelete: internalKnowledgeErrorPolicy('Failed to delete knowledge bases'),
/** Workspace-scoped, so unconcealed for the same reason as the bulk routes above. */
memberConnectors: internalKnowledgeErrorPolicy('Failed to fetch member connectors'),
default: internalKnowledgeErrorPolicy('Internal server error'),
documents: concealKnowledgeBase(
internalKnowledgeErrorPolicy('Failed to process knowledge document request')
Expand Down
Loading
Loading