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
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:
@@ -0,0 +1,304 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user