Files
simstudioai--sim/apps/sim/lib/copilot/validation/selector-validator.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

305 lines
11 KiB
TypeScript

import { db } from '@sim/db'
import {
account,
credential,
credentialMember,
document,
knowledgeBase,
mcpServers,
workflow,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('SelectorValidator')
/**
* Result of selector ID validation
*/
export interface SelectorValidationResult {
valid: string[]
invalid: string[]
warning?: string
}
/**
* Validates that selector IDs exist in the database
* Returns valid IDs, invalid IDs, and optional warning for unknown selector types
*/
export async function validateSelectorIds(
selectorType: string,
ids: string | string[],
context: { userId: string; workspaceId?: string }
): Promise<SelectorValidationResult> {
const idsArray = (Array.isArray(ids) ? ids : [ids]).filter((id) => id && id.trim() !== '')
if (idsArray.length === 0) {
return { valid: [], invalid: [] }
}
let existingIds: string[] = []
try {
switch (selectorType) {
case 'oauth-input': {
if (context.workspaceId) {
// In workspace workflows, oauth-input values are workspace credential IDs.
// Accept both current credential IDs and legacy account IDs. Workspace
// admins (incl. derived org admins) can reference any shared credential;
// other members need active credential membership.
const isWorkspaceAdmin = (await checkWorkspaceAccess(context.workspaceId, context.userId))
.canAdmin
const matchWhere = and(
eq(credential.workspaceId, context.workspaceId),
inArray(credential.type, ['oauth', 'service_account']),
or(inArray(credential.id, idsArray), inArray(credential.accountId, idsArray))
)
const results = await db
.select({ credentialId: credential.id, accountId: credential.accountId })
.from(credential)
.leftJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, context.userId),
eq(credentialMember.status, 'active')
)
)
.where(and(matchWhere, isWorkspaceAdmin ? undefined : isNotNull(credentialMember.id)))
existingIds = Array.from(
new Set(
results.flatMap((result) => {
const matches: string[] = []
if (idsArray.includes(result.credentialId)) {
matches.push(result.credentialId)
}
if (result.accountId && idsArray.includes(result.accountId)) {
matches.push(result.accountId)
}
return matches
})
)
)
const existingSet = new Set(existingIds)
const invalidIds = idsArray.filter((id) => !existingSet.has(id))
if (invalidIds.length > 0) {
const accessibleSelect = {
id: credential.id,
displayName: credential.displayName,
accountId: credential.accountId,
credentialProviderId: credential.providerId,
accountProviderId: account.providerId,
}
const accessibleWhere = and(
eq(credential.workspaceId, context.workspaceId),
inArray(credential.type, ['oauth', 'service_account'])
)
const allAccessibleCredentials = await db
.select(accessibleSelect)
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.leftJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, context.userId),
eq(credentialMember.status, 'active')
)
)
.where(
and(accessibleWhere, isWorkspaceAdmin ? undefined : isNotNull(credentialMember.id))
)
const availableCredentials = allAccessibleCredentials
.map((cred) => {
const providerId =
cred.accountProviderId || cred.credentialProviderId || 'unknown-provider'
const legacyId = cred.accountId ? `, legacy ${cred.accountId}` : ''
return `${cred.displayName} [${cred.id}] (${providerId}${legacyId})`
})
.join(', ')
const noCredentialsMessage = 'User has no accessible credentials in this workspace.'
return {
valid: existingIds,
invalid: invalidIds,
warning:
allAccessibleCredentials.length > 0
? `Accessible workspace credentials: ${availableCredentials}`
: noCredentialsMessage,
}
}
break
}
// Fallback for older callers that still validate against legacy account IDs.
const results = await db
.select({ id: account.id })
.from(account)
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
existingIds = results.map((r) => r.id)
// If any IDs are invalid, fetch user's available credentials to include in error message
const existingSet = new Set(existingIds)
const invalidIds = idsArray.filter((id) => !existingSet.has(id))
if (invalidIds.length > 0) {
// Fetch all of the user's credentials to provide helpful feedback
const allUserCredentials = await db
.select({ id: account.id, providerId: account.providerId })
.from(account)
.where(eq(account.userId, context.userId))
const availableCredentials = allUserCredentials
.map((c) => `${c.id} (${c.providerId})`)
.join(', ')
const noCredentialsMessage = 'User has no credentials configured.'
return {
valid: existingIds,
invalid: invalidIds,
warning:
allUserCredentials.length > 0
? `Available credentials for this user: ${availableCredentials}`
: noCredentialsMessage,
}
}
break
}
case 'knowledge-base-selector': {
const conditions = [inArray(knowledgeBase.id, idsArray), isNull(knowledgeBase.deletedAt)]
if (context.workspaceId) {
conditions.push(eq(knowledgeBase.workspaceId, context.workspaceId))
}
const results = await db
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(and(...conditions))
existingIds = results.map((r) => r.id)
break
}
case 'workflow-selector': {
const results = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(inArray(workflow.id, idsArray), isNull(workflow.archivedAt)))
existingIds = results.map((r) => r.id)
break
}
case 'document-selector': {
// Documents in knowledge bases - check if they exist and are not deleted
const results = await db
.select({ id: document.id })
.from(document)
.where(
and(
inArray(document.id, idsArray),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
existingIds = results.map((r) => r.id)
break
}
case 'mcp-server-selector': {
// MCP servers - check if they exist, are enabled, and not deleted in the workspace
const conditions = [
inArray(mcpServers.id, idsArray),
eq(mcpServers.enabled, true),
isNull(mcpServers.deletedAt),
]
if (context.workspaceId) {
conditions.push(eq(mcpServers.workspaceId, context.workspaceId))
}
const results = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(and(...conditions))
existingIds = results.map((r) => r.id)
break
}
// MCP tools are dynamically fetched from external MCP servers at runtime
// We can't validate tool IDs locally - only the server connection validates them
case 'mcp-tool-selector': {
return { valid: idsArray, invalid: [] }
}
// These selectors use external IDs from third-party systems (Slack, Google, Jira, etc.)
// We can't validate them locally - they're validated at runtime when calling the external API
case 'file-selector':
case 'project-selector':
case 'channel-selector':
case 'folder-selector': {
// External/dynamic selectors - skip validation, IDs are validated at runtime
// These IDs come from: Slack channels, Google Drive files, Jira projects, etc.
return { valid: idsArray, invalid: [] }
}
default: {
// Unknown selector type - skip validation but warn
logger.warn(`Unknown selector type "${selectorType}" - ID validation skipped`, {
selectorType,
idCount: idsArray.length,
})
return {
valid: idsArray,
invalid: [],
warning: `Unknown selector type "${selectorType}" - ID validation skipped`,
}
}
}
} catch (error) {
// On DB error, skip validation rather than failing the edit
logger.error(`Failed to validate selector IDs for type "${selectorType}"`, {
error: toError(error).message,
selectorType,
idCount: idsArray.length,
})
return {
valid: idsArray,
invalid: [],
warning: `Failed to validate ${selectorType} IDs - validation skipped`,
}
}
const existingSet = new Set(existingIds)
return {
valid: idsArray.filter((id) => existingSet.has(id)),
invalid: idsArray.filter((id) => !existingSet.has(id)),
}
}
/**
* Batch validate multiple selector fields
* Returns a map of field name to validation result
*/
async function validateAllSelectorFields(
fields: Array<{ fieldName: string; selectorType: string; value: string | string[] }>,
context: { userId: string; workspaceId?: string }
): Promise<Map<string, SelectorValidationResult>> {
const results = new Map<string, SelectorValidationResult>()
// Run validations in parallel for better performance
const validationPromises = fields.map(async ({ fieldName, selectorType, value }) => {
const result = await validateSelectorIds(selectorType, value, context)
return { fieldName, result }
})
const validationResults = await Promise.all(validationPromises)
for (const { fieldName, result } of validationResults) {
results.set(fieldName, result)
}
return results
}