d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db } from '@sim/db'
|
|
import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
|
|
import { parseRequest } from '@/lib/api/server'
|
|
import { encryptApiKey } from '@/lib/api-key/crypto'
|
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
|
import { hasLiveSyncAccess } 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/sync-engine'
|
|
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
|
import { createTagDefinition } from '@/lib/knowledge/tags/service'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
import { getCredential } from '@/app/api/auth/oauth/utils'
|
|
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
|
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
|
|
|
|
const logger = createLogger('KnowledgeConnectorsAPI')
|
|
|
|
/**
|
|
* GET /api/knowledge/[id]/connectors - List connectors for a knowledge base
|
|
*/
|
|
export const GET = withRouteHandler(
|
|
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
|
const requestId = generateRequestId()
|
|
const { id: knowledgeBaseId } = await params
|
|
|
|
try {
|
|
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
|
if (!auth.success || !auth.userId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
|
if (!accessCheck.hasAccess) {
|
|
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
|
|
return NextResponse.json(
|
|
{ error: status === 404 ? 'Not found' : 'Unauthorized' },
|
|
{ status }
|
|
)
|
|
}
|
|
|
|
const connectors = await db
|
|
.select()
|
|
.from(knowledgeConnector)
|
|
.where(
|
|
and(
|
|
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
|
|
isNull(knowledgeConnector.archivedAt),
|
|
isNull(knowledgeConnector.deletedAt)
|
|
)
|
|
)
|
|
.orderBy(desc(knowledgeConnector.createdAt))
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest),
|
|
})
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error listing connectors`, error)
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
}
|
|
}
|
|
)
|
|
|
|
/**
|
|
* POST /api/knowledge/[id]/connectors - Create a new connector
|
|
*/
|
|
export const POST = withRouteHandler(
|
|
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
|
const requestId = generateRequestId()
|
|
const { id: knowledgeBaseId } = await context.params
|
|
|
|
try {
|
|
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
|
if (!auth.success || !auth.userId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
|
|
if (!writeCheck.hasAccess) {
|
|
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
|
|
return NextResponse.json(
|
|
{ error: status === 404 ? 'Not found' : 'Unauthorized' },
|
|
{ status }
|
|
)
|
|
}
|
|
|
|
const parsed = await parseRequest(createKnowledgeConnectorContract, request, context)
|
|
if (!parsed.success) return parsed.response
|
|
|
|
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } =
|
|
parsed.data.body
|
|
|
|
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
|
|
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
|
if (!canUseLiveSync) {
|
|
return NextResponse.json(
|
|
{ error: 'Live sync requires a Max or Enterprise plan' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
}
|
|
|
|
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
|
|
if (!connectorConfig) {
|
|
return NextResponse.json(
|
|
{ error: `Unknown connector type: ${connectorType}` },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
let resolvedCredentialId: string | null = null
|
|
let resolvedEncryptedApiKey: string | null = null
|
|
let accessToken: string
|
|
|
|
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 })
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig)
|
|
if (!configValidation.valid) {
|
|
return NextResponse.json(
|
|
{ error: configValidation.error || 'Invalid source configuration' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
let finalSourceConfig: Record<string, unknown> = { ...sourceConfig }
|
|
|
|
if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
|
|
const { encrypted } = await encryptApiKey(apiKey)
|
|
resolvedEncryptedApiKey = encrypted
|
|
}
|
|
|
|
const tagSlotMapping: Record<string, string> = {}
|
|
let newTagSlots: Record<string, string> = {}
|
|
|
|
if (connectorConfig.tagDefinitions?.length) {
|
|
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
|
|
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
|
|
|
|
const existingDefs = await db
|
|
.select({
|
|
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
|
displayName: knowledgeBaseTagDefinitions.displayName,
|
|
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
|
})
|
|
.from(knowledgeBaseTagDefinitions)
|
|
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
|
|
|
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
|
const existingByName = new Map(
|
|
existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }])
|
|
)
|
|
|
|
const defsNeedingSlots: typeof enabledDefs = []
|
|
for (const td of enabledDefs) {
|
|
const existing = existingByName.get(td.displayName)
|
|
if (existing && existing.fieldType === td.fieldType) {
|
|
tagSlotMapping[td.id] = existing.tagSlot
|
|
} else {
|
|
defsNeedingSlots.push(td)
|
|
}
|
|
}
|
|
|
|
const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots)
|
|
Object.assign(tagSlotMapping, mapping)
|
|
newTagSlots = mapping
|
|
|
|
for (const name of skippedTags) {
|
|
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
|
}
|
|
|
|
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
|
|
return NextResponse.json(
|
|
{ error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
|
|
{ status: 422 }
|
|
)
|
|
}
|
|
|
|
finalSourceConfig = { ...finalSourceConfig, tagSlotMapping }
|
|
}
|
|
|
|
const now = new Date()
|
|
const connectorId = generateId()
|
|
const nextSyncAt =
|
|
syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
|
|
|
|
const activeKb = await tx
|
|
.select({ id: knowledgeBase.id })
|
|
.from(knowledgeBase)
|
|
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
|
.limit(1)
|
|
|
|
if (activeKb.length === 0) {
|
|
throw new Error('Knowledge base not found')
|
|
}
|
|
|
|
for (const [semanticId, slot] of Object.entries(newTagSlots)) {
|
|
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
|
|
await createTagDefinition(
|
|
{
|
|
knowledgeBaseId,
|
|
tagSlot: slot,
|
|
displayName: td.displayName,
|
|
fieldType: td.fieldType,
|
|
},
|
|
requestId,
|
|
tx
|
|
)
|
|
}
|
|
|
|
await tx.insert(knowledgeConnector).values({
|
|
id: connectorId,
|
|
knowledgeBaseId,
|
|
connectorType,
|
|
credentialId: resolvedCredentialId,
|
|
encryptedApiKey: resolvedEncryptedApiKey,
|
|
sourceConfig: finalSourceConfig,
|
|
syncIntervalMinutes,
|
|
status: 'active',
|
|
nextSyncAt,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
})
|
|
|
|
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
|
|
|
|
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
|
|
captureServerEvent(
|
|
auth.userId,
|
|
'knowledge_base_connector_added',
|
|
{
|
|
knowledge_base_id: knowledgeBaseId,
|
|
workspace_id: kbWorkspaceId,
|
|
connector_type: connectorType,
|
|
sync_interval_minutes: syncIntervalMinutes,
|
|
},
|
|
{
|
|
groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined,
|
|
setOnce: { first_connector_added_at: new Date().toISOString() },
|
|
}
|
|
)
|
|
|
|
recordAudit({
|
|
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
|
actorId: auth.userId,
|
|
actorName: auth.userName,
|
|
actorEmail: auth.userEmail,
|
|
action: AuditAction.CONNECTOR_CREATED,
|
|
resourceType: AuditResourceType.CONNECTOR,
|
|
resourceId: connectorId,
|
|
resourceName: connectorType,
|
|
description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
|
|
metadata: {
|
|
knowledgeBaseId,
|
|
knowledgeBaseName: writeCheck.knowledgeBase.name,
|
|
connectorType,
|
|
syncIntervalMinutes,
|
|
authMode: connectorConfig.auth.mode,
|
|
},
|
|
request,
|
|
})
|
|
|
|
dispatchSync(connectorId, { requestId }).catch((error) => {
|
|
logger.error(
|
|
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
|
|
error
|
|
)
|
|
})
|
|
|
|
const created = await db
|
|
.select()
|
|
.from(knowledgeConnector)
|
|
.where(eq(knowledgeConnector.id, connectorId))
|
|
.limit(1)
|
|
|
|
const { encryptedApiKey: _, ...createdData } = created[0]
|
|
return NextResponse.json({ success: true, data: createdData }, { status: 201 })
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Knowledge base not found') {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
logger.error(`[${requestId}] Error creating connector`, error)
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
}
|
|
}
|
|
)
|