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
305 lines
11 KiB
TypeScript
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
|
|
}
|