chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,216 @@
/**
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
hybridAuthMockFns,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
requestUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
return { mockDbChain: chain }
})
const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@sim/audit', () => auditMock)
import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
describe('Connector Documents API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id')
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('GET')
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns documents list on success', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy.mockResolvedValueOnce([doc])
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
const req = createMockRequest('GET', undefined, undefined, url)
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(1)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(0)
})
it('includes excluded documents when includeExcluded=true', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
const url =
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
const req = createMockRequest('GET', undefined, undefined, url)
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(2)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('PATCH', { documentIds: [] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(400)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns success for restore operation', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.restoredCount).toBe(1)
})
it('returns success for exclude operation', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
const req = createMockRequest('PATCH', {
operation: 'exclude',
documentIds: ['doc-2', 'doc-3'],
})
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.excludedCount).toBe(2)
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
})
})
})
@@ -0,0 +1,247 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { document, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { patchKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorDocumentsAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* GET /api/knowledge/[id]/connectors/[connectorId]/documents
* Returns documents for a connector, optionally including user-excluded ones.
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = 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 connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true'
const activeDocs = await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
isNull(document.archivedAt),
isNull(document.deletedAt),
eq(document.userExcluded, false)
)
)
.orderBy(document.filename)
const excludedDocs = includeExcluded
? await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
eq(document.userExcluded, true),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.orderBy(document.filename)
: []
const docs = [...activeDocs, ...excludedDocs]
const activeCount = activeDocs.length
const excludedCount = excludedDocs.length
return NextResponse.json({
success: true,
data: {
documents: docs,
counts: { active: activeCount, excluded: excludedCount },
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId]/documents
* Restore or exclude connector documents.
*/
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = 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 connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const parsed = await parseRequest(patchKnowledgeConnectorDocumentsContract, request, context)
if (!parsed.success) return parsed.response
const { operation, documentIds } = parsed.data.body
if (operation === 'restore') {
const updated = await db
.update(document)
.set({ userExcluded: false, enabled: true })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, true),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_DOCUMENT_RESTORED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
description: `Restored ${updated.length} excluded document(s) for knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
operation: 'restore',
documentCount: updated.length,
documentIds: updated.map((d) => d.id),
},
request,
})
return NextResponse.json({
success: true,
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
})
}
const updated = await db
.update(document)
.set({ userExcluded: true, enabled: false })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_DOCUMENT_EXCLUDED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
description: `Excluded ${updated.length} document(s) from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
operation: 'exclude',
documentCount: updated.length,
documentIds: updated.map((d) => d.id),
},
request,
})
return NextResponse.json({
success: true,
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
})
} catch (error) {
logger.error(`[${requestId}] Error updating connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,246 @@
/**
* @vitest-environment node
*/
import {
auditMock,
authOAuthUtilsMock,
createMockRequest,
hybridAuthMockFns,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain, mockValidateConfig } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
execute: vi.fn().mockResolvedValue(undefined),
transaction: vi.fn(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
return {
mockDbChain: chain,
mockValidateConfig: vi.fn(),
}
})
const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/connectors/registry.server', () => ({
CONNECTOR_REGISTRY: {
jira: { validateConfig: mockValidateConfig },
},
}))
vi.mock('@/lib/knowledge/tags/service', () => ({
cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/knowledge/documents/service', () => ({
deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/audit', () => auditMock)
import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
describe('Knowledge Connector By ID API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockReturnThis()
mockDbChain.limit.mockResolvedValue([])
mockDbChain.execute.mockResolvedValue(undefined)
mockDbChain.transaction.mockImplementation(
async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain)
)
mockDbChain.update.mockReturnThis()
mockDbChain.delete.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when KB not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: false, notFound: true })
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns connector with sync logs on success', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
const mockLogs = [{ id: 'log-1', status: 'completed' }]
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('conn-456')
expect(data.data.syncLogs).toHaveLength(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('PATCH', { status: 'paused' })
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Validation error')
})
it('returns 404 when connector not found during sourceConfig validation', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 200 and updates status', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.status).toBe('paused')
})
})
describe('DELETE', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 200 on successful hard-delete', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.where
.mockReturnValueOnce(mockDbChain)
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
.mockReturnValueOnce(mockDbChain)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
})
@@ -0,0 +1,420 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
document,
embedding,
knowledgeBase,
knowledgeConnector,
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { decryptApiKey } 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 { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
const logger = createLogger('KnowledgeConnectorByIdAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = 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 connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const syncLogs = await db
.select()
.from(knowledgeConnectorSyncLog)
.where(eq(knowledgeConnectorSyncLog.connectorId, connectorId))
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
.limit(10)
const { encryptedApiKey: _, ...connectorData } = connectorRows[0]
return NextResponse.json({
success: true,
data: {
...connectorData,
syncLogs,
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
*/
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = 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(updateKnowledgeConnectorContract, request, context)
if (!parsed.success) return parsed.response
const body = parsed.data.body
if (
body.syncIntervalMinutes !== undefined &&
body.syncIntervalMinutes > 0 &&
body.syncIntervalMinutes < 60
) {
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
if (!canUseLiveSync) {
return NextResponse.json(
{ error: 'Live sync requires a Max or Enterprise plan' },
{ status: 403 }
)
}
}
if (body.sourceConfig !== undefined) {
const existingRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const existing = existingRows[0]
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${existing.connectorType}` },
{ status: 400 }
)
}
const kbRows = await db
.select({ userId: knowledgeBase.userId })
.from(knowledgeBase)
.where(eq(knowledgeBase.id, knowledgeBaseId))
.limit(1)
if (kbRows.length === 0) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
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 }
)
}
accessToken = await refreshAccessTokenIfNeeded(
existing.credentialId,
kbRows[0].userId,
`patch-${connectorId}`
)
}
if (!accessToken) {
return NextResponse.json(
{ error: 'Failed to refresh access token. Please reconnect your account.' },
{ status: 401 }
)
}
const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (body.sourceConfig !== undefined) {
updates.sourceConfig = body.sourceConfig
}
if (body.syncIntervalMinutes !== undefined) {
updates.syncIntervalMinutes = body.syncIntervalMinutes
if (body.syncIntervalMinutes > 0) {
updates.nextSyncAt = new Date(Date.now() + body.syncIntervalMinutes * 60 * 1000)
} else {
updates.nextSyncAt = null
}
}
if (body.status !== undefined) {
updates.status = body.status
if (body.status === 'active') {
updates.consecutiveFailures = 0
updates.lastSyncError = null
if (updates.nextSyncAt === undefined) {
updates.nextSyncAt = new Date()
}
}
}
await db
.update(knowledgeConnector)
.set(updates)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
const updated = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
const { encryptedApiKey: __, ...updatedData } = updated[0]
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_UPDATED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: updatedData.connectorType,
description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType: updatedData.connectorType,
updatedFields: Object.keys(parsed.data),
...(body.syncIntervalMinutes !== undefined && {
syncIntervalMinutes: body.syncIntervalMinutes,
}),
...(body.status !== undefined && { newStatus: body.status }),
},
request,
})
return NextResponse.json({ success: true, data: updatedData })
} catch (error) {
logger.error(`[${requestId}] Error updating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector
*/
export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await 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 existingConnector = await db
.select({ id: knowledgeConnector.id, connectorType: knowledgeConnector.connectorType })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (existingConnector.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
const docs = await tx
.select({ id: document.id, fileUrl: document.fileUrl })
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
if (deleteDocuments) {
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
}
}
const deletedConnectors = await tx
.delete(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.returning({ id: knowledgeConnector.id })
if (deletedConnectors.length === 0) {
throw new Error('Connector not found')
}
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
})
const kbWorkspaceId = writeCheck.knowledgeBase?.workspaceId ?? null
if (deleteDocuments) {
await Promise.all([
deletedDocs.length > 0
? deleteDocumentStorageFiles(
deletedDocs.map((doc) => ({ ...doc, workspaceId: kbWorkspaceId })),
requestId
)
: Promise.resolve(),
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
}),
])
}
logger.info(
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
captureServerEvent(
auth.userId,
'knowledge_base_connector_removed',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId ?? '',
connector_type: existingConnector[0].connectorType,
documents_deleted: deleteDocuments ? docCount : 0,
},
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_DELETED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: existingConnector[0].connectorType,
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType: existingConnector[0].connectorType,
deleteDocuments,
documentsDeleted: deleteDocuments ? docCount : 0,
documentsKept: deleteDocuments ? 0 : docCount,
},
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error deleting connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,117 @@
/**
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
hybridAuthMockFns,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
requestUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDispatchSync, mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
}
return {
mockDispatchSync: vi.fn().mockResolvedValue(undefined),
mockDbChain: chain,
}
})
const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
dispatchSync: mockDispatchSync,
}))
vi.mock('@sim/audit', () => auditMock)
import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route'
describe('Connector Manual Sync API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id')
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
})
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 409 when connector is syncing', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(409)
})
it('dispatches sync on valid request', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
})
})
@@ -0,0 +1,111 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
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 { captureServerEvent } from '@/lib/posthog/server'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorManualSyncAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync
*/
export const POST = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, connectorId } = parsed.data.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 connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
if (connectorRows[0].status === 'syncing') {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 })
}
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_synced',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId,
connector_type: connectorRows[0].connectorType,
},
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_SYNCED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: connectorRows[0].connectorType,
description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType: connectorRows[0].connectorType,
connectorStatus: connectorRows[0].status,
syncType: 'manual',
},
request,
})
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
error
)
})
return NextResponse.json({
success: true,
message: 'Sync triggered',
})
} catch (error) {
logger.error(`[${requestId}] Error triggering manual sync`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,320 @@
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 })
}
}
)