Skip to content
Open
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
141 changes: 91 additions & 50 deletions apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,10 @@ import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
import {
preserveServerOwnedSourceConfig,
sanitizeConnectorSourceConfig,
} from '@/lib/knowledge/connectors/source-config'
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
Expand DownExpand Up@@ -103,6 +107,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
if (!parsed.success) return parsed.response
const body = parsed.data.body

/**
* Guarded on throughout instead of `body.sourceConfig` so the sanitized value is
* the only one that can reach validation or the database.
*/
const sourceConfigUpdate =
body.sourceConfig === undefined ? undefined : sanitizeConnectorSourceConfig(body.sourceConfig)
/** Sanitized update plus the server-owned keys carried over from the stored row. */
let sourceConfigToPersist: Record<string, unknown> | undefined

if (
body.syncIntervalMinutes !== undefined &&
body.syncIntervalMinutes > 0 &&
Expand All@@ -124,7 +137,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
}
}

if (body.sourceConfig !== undefined) {
if (sourceConfigUpdate !== undefined) {
const existingRows = await db
.select()
.from(knowledgeConnector)
Expand All@@ -143,6 +156,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
}

const existing = existingRows[0]
sourceConfigToPersist = preserveServerOwnedSourceConfig(
sourceConfigUpdate,
existing.sourceConfig
)
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]

if (!connectorConfig) {
Expand All@@ -152,60 +169,84 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
)
}

let accessToken: string | null = null
if (connectorConfig.auth.mode === 'apiKey') {
if (!existing.encryptedApiKey) {
return NextResponse.json(
{ error: 'API key not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted
} else {
if (!existing.credentialId) {
return NextResponse.json(
{ error: 'OAuth credential not found. Please reconfigure the connector.' },
{ status: 400 }
)
const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId
if (!connectorWorkspaceId) {
return NextResponse.json(
{ error: 'Knowledge base is missing workspace context' },
{ status: 409 }
)
}

/**
* Empty for `sim` connectors, which have no credential. Deliberately not
* guarded with a falsy check afterwards: every failure mode below returns its
* own response, so a falsy token here would only ever be a valid `sim` one.
*/
let accessToken = ''

switch (connectorConfig.auth.mode) {
case 'sim':
break

case 'apiKey': {
if (!existing.encryptedApiKey) {
return NextResponse.json(
{ error: 'API key not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted
break
}
const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId
if (!connectorWorkspaceId) {
return NextResponse.json(
{ error: 'Knowledge base is missing workspace context' },
{ status: 409 }

case 'oauth': {
if (!existing.credentialId) {
return NextResponse.json(
{ error: 'OAuth credential not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
/**
* Resolve the credential's own account owner, not the knowledge base owner:
* workspace credentials are shared, and token reads are scoped to
* `account.userId`.
*/
const identity = await resolveCredentialTokenIdentity(
existing.credentialId,
connectorWorkspaceId
)
}
/**
* Resolve the credential's own account owner, not the knowledge base owner:
* workspace credentials are shared, and token reads are scoped to
* `account.userId`.
*/
const identity = await resolveCredentialTokenIdentity(
existing.credentialId,
connectorWorkspaceId
)
if (!identity) {
return NextResponse.json(
{ error: 'Credential is no longer usable in this workspace. Please reconnect it.' },
{ status: 400 }
if (!identity) {
return NextResponse.json(
{ error: 'Credential is no longer usable in this workspace. Please reconnect it.' },
{ status: 400 }
)
}
const refreshed = await refreshAccessTokenIfNeeded(
existing.credentialId,
// Service accounts mint their own token and ignore the acting user.
identity.kind === 'oauth' ? identity.userId : auth.userId,
`patch-${connectorId}`
)
if (!refreshed) {
return NextResponse.json(
{ error: 'Failed to refresh access token. Please reconnect your account.' },
{ status: 401 }
)
}
accessToken = refreshed
break
}
accessToken = await refreshAccessTokenIfNeeded(
existing.credentialId,
// Service accounts mint their own token and ignore the acting user.
identity.kind === 'oauth' ? identity.userId : auth.userId,
`patch-${connectorId}`
)
}

if (!accessToken) {
return NextResponse.json(
{ error: 'Failed to refresh access token. Please reconnect your account.' },
{ status: 401 }
)
default: {
const _exhaustive: never = connectorConfig.auth
return NextResponse.json({ error: 'Unsupported connector auth mode' }, { status: 400 })
}
}

const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig)
const validation = await connectorConfig.validateConfig(accessToken, sourceConfigUpdate, {
workspaceId: connectorWorkspaceId,
knowledgeBaseId,
})
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
Expand All@@ -215,8 +256,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
}

const updates: Record<string, unknown> = { updatedAt: new Date() }
if (body.sourceConfig !== undefined) {
updates.sourceConfig = body.sourceConfig
if (sourceConfigToPersist !== undefined) {
updates.sourceConfig = sourceConfigToPersist
}
if (body.syncIntervalMinutes !== undefined) {
updates.syncIntervalMinutes = body.syncIntervalMinutes
Expand Down
79 changes: 55 additions & 24 deletions apps/sim/app/api/knowledge/[id]/connectors/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { dispatchSync } from '@/lib/knowledge/connectors/queue'
import { sanitizeConnectorSourceConfig } from '@/lib/knowledge/connectors/source-config'
import { allocateTagSlots } from '@/lib/knowledge/constants'
import { createTagDefinition } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
Expand DownExpand Up@@ -140,43 +141,73 @@ export const POST = withRouteHandler(

let resolvedCredentialId: string | null = null
let resolvedEncryptedApiKey: string | null = null
let accessToken: string
let accessToken = ''

switch (connectorConfig.auth.mode) {
case 'sim':
/**
* Sim connectors read this workspace's own data, so there is nothing to
* store. Reject a supplied credential rather than ignoring it: persisting
* an unused `credentialId` would leave a connector row pointing at another
* workspace's credential, and an unused encrypted key is dead secret material.
*/
if (credentialId || apiKey) {
return NextResponse.json(
{ error: 'This source reads your workspace directly and takes no credential' },
{ status: 400 }
)
}
break

if (connectorConfig.auth.mode === 'apiKey') {
if (!apiKey) {
return NextResponse.json({ error: 'API key is required' }, { status: 400 })
}
accessToken = apiKey
} else {
if (!credentialId) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
case 'apiKey':
if (!apiKey) {
return NextResponse.json({ error: 'API key is required' }, { status: 400 })
}
accessToken = apiKey
break

const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
}
case 'oauth': {
if (!credentialId) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}

if (!credential.accessToken) {
return NextResponse.json(
{ error: 'Credential has no access token. Please reconnect your account.' },
{ status: 400 }
)
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
}

if (!credential.accessToken) {
return NextResponse.json(
{ error: 'Credential has no access token. Please reconnect your account.' },
{ status: 400 }
)
}

accessToken = credential.accessToken
resolvedCredentialId = credentialId
break
}

accessToken = credential.accessToken
resolvedCredentialId = credentialId
default: {
const _exhaustive: never = connectorConfig.auth
return NextResponse.json({ error: 'Unsupported connector auth mode' }, { status: 400 })
}
}

const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig)
const safeSourceConfig = sanitizeConnectorSourceConfig(sourceConfig)

const configValidation = await connectorConfig.validateConfig(accessToken, safeSourceConfig, {
workspaceId: kbWorkspaceId,
knowledgeBaseId,
})
if (!configValidation.valid) {
return NextResponse.json(
{ error: configValidation.error || 'Invalid source configuration' },
{ status: 400 }
)
}

let finalSourceConfig: Record<string, unknown> = { ...sourceConfig }
let finalSourceConfig: Record<string, unknown> = { ...safeSourceConfig }

if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
const { encrypted } = await encryptApiKey(apiKey)
Expand All@@ -187,7 +218,7 @@ export const POST = withRouteHandler(
let newTagSlots: Record<string, string> = {}

if (connectorConfig.tagDefinitions?.length) {
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
const disabledIds = new Set((safeSourceConfig.disabledTagIds as string[] | undefined) ?? [])
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))

const existingDefs = await db
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,10 @@ describe('chunked parse — property test over randomized documents', () => {
}
}
expect(failures).toEqual([])
// 400 docs each parsed+serialized twice — generous timeout so it can't flake under parallel load.
},30000)
/**
* 400 docs each parsed+serialized twice. ~10s in isolation, so 30s left barely 3x
* headroom and still timed out during a full-suite run; 60s restores real margin
* without dropping seeds, since coverage here is the number of documents fuzzed.
*/
},60000)
})
Loading
Loading