From 4edb3be52dd2de1b0ed66903f86b0148ca8eb15a Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 12 Dec 2025 21:09:12 -0800 Subject: [PATCH 01/14] fix(spotify): added missing human readable scopes to oauth required modal (#2355) --- .../components/oauth-required-modal.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 37bae394f7..3a3078c951 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -262,6 +262,24 @@ const SCOPE_DESCRIPTIONS: Record = { 'sharing.write': 'Share files and folders with others', // WordPress.com scopes global: 'Full access to manage your WordPress.com sites, posts, pages, media, and settings', + // Spotify scopes + 'user-read-private': 'View your Spotify account details', + 'user-read-email': 'View your email address on Spotify', + 'user-library-read': 'View your saved tracks and albums', + 'user-library-modify': 'Save and remove tracks and albums from your library', + 'playlist-read-private': 'View your private playlists', + 'playlist-read-collaborative': 'View collaborative playlists you have access to', + 'playlist-modify-public': 'Create and manage your public playlists', + 'playlist-modify-private': 'Create and manage your private playlists', + 'user-read-playback-state': 'View your current playback state', + 'user-modify-playback-state': 'Control playback on your Spotify devices', + 'user-read-currently-playing': 'View your currently playing track', + 'user-read-recently-played': 'View your recently played tracks', + 'user-top-read': 'View your top artists and tracks', + 'user-follow-read': 'View artists and users you follow', + 'user-follow-modify': 'Follow and unfollow artists and users', + 'user-read-playback-position': 'View your playback position in podcasts', + 'ugc-image-upload': 'Upload images to your Spotify playlists', } function getScopeDescription(scope: string): string { From 690be530c84349d665bec499304d1848bf122260 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 13 Dec 2025 02:11:42 -0800 Subject: [PATCH 02/14] chore(icons): update spotify icon (#2356) --- apps/docs/components/icons.tsx | 18 ++- apps/docs/components/ui/icon-mapping.ts | 184 ++++++++++++------------ apps/sim/blocks/blocks/spotify.ts | 17 ++- apps/sim/components/icons.tsx | 18 ++- 4 files changed, 126 insertions(+), 111 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index f9e690a726..12ead996f7 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4206,12 +4206,20 @@ export function RssIcon(props: SVGProps) { export function SpotifyIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 9771381871..77b3769a99 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -119,116 +119,116 @@ import { type IconComponent = ComponentType> export const blockTypeToIconMap: Record = { - calendly: CalendlyIcon, - mailchimp: MailchimpIcon, - postgresql: PostgresIcon, - twilio_voice: TwilioIcon, - elasticsearch: ElasticsearchIcon, - rds: RDSIcon, - translate: TranslateIcon, - dynamodb: DynamoDBIcon, - wordpress: WordpressIcon, - tavily: TavilyIcon, + zoom: ZoomIcon, + zep: ZepIcon, zendesk: ZendeskIcon, youtube: YouTubeIcon, - supabase: SupabaseIcon, - vision: EyeIcon, - zoom: ZoomIcon, - confluence: ConfluenceIcon, - arxiv: ArxivIcon, - webflow: WebflowIcon, - pinecone: PineconeIcon, - apollo: ApolloIcon, + x: xIcon, + wordpress: WordpressIcon, + wikipedia: WikipediaIcon, whatsapp: WhatsAppIcon, + webflow: WebflowIcon, + wealthbox: WealthboxIcon, + vision: EyeIcon, + video_generator: VideoIcon, typeform: TypeformIcon, - qdrant: QdrantIcon, - shopify: ShopifyIcon, - asana: AsanaIcon, + twilio_voice: TwilioIcon, + twilio_sms: TwilioIcon, + tts: TTSIcon, + trello: TrelloIcon, + translate: TranslateIcon, + thinking: BrainIcon, + telegram: TelegramIcon, + tavily: TavilyIcon, + supabase: SupabaseIcon, + stt: STTIcon, + stripe: StripeIcon, + stagehand: StagehandIcon, + ssh: SshIcon, sqs: SQSIcon, - apify: ApifyIcon, - memory: BrainIcon, - gitlab: GitLabIcon, - polymarket: PolymarketIcon, + spotify: SpotifyIcon, + smtp: SmtpIcon, + slack: SlackIcon, + shopify: ShopifyIcon, + sharepoint: MicrosoftSharepointIcon, + sftp: SftpIcon, serper: SerperIcon, - linear: LinearIcon, - exa: ExaAIIcon, - telegram: TelegramIcon, + sentry: SentryIcon, + sendgrid: SendgridIcon, + search: SearchIcon, salesforce: SalesforceIcon, - hubspot: HubspotIcon, - hunter: HunterIOIcon, - linkup: LinkupIcon, - mongodb: MongoDBIcon, - airtable: AirtableIcon, - discord: DiscordIcon, - ahrefs: AhrefsIcon, - neo4j: Neo4jIcon, - tts: TTSIcon, - jina: JinaAIIcon, - google_docs: GoogleDocsIcon, - perplexity: PerplexityIcon, - google_search: GoogleIcon, - x: xIcon, - kalshi: KalshiIcon, - google_calendar: GoogleCalendarIcon, - zep: ZepIcon, + s3: S3Icon, + resend: ResendIcon, + reddit: RedditIcon, + rds: RDSIcon, + qdrant: QdrantIcon, posthog: PosthogIcon, - grafana: GrafanaIcon, - google_slides: GoogleSlidesIcon, - microsoft_planner: MicrosoftPlannerIcon, - thinking: BrainIcon, + postgresql: PostgresIcon, + polymarket: PolymarketIcon, pipedrive: PipedriveIcon, - dropbox: DropboxIcon, - stagehand: StagehandIcon, - google_forms: GoogleFormsIcon, - file: DocumentIcon, - mistral_parse: MistralIcon, - gmail: GmailIcon, - openai: OpenAIIcon, + pinecone: PineconeIcon, + perplexity: PerplexityIcon, + parallel_ai: ParallelIcon, outlook: OutlookIcon, - incidentio: IncidentioIcon, + openai: OpenAIIcon, onedrive: MicrosoftOneDriveIcon, - resend: ResendIcon, - google_vault: GoogleVaultIcon, - sharepoint: MicrosoftSharepointIcon, - huggingface: HuggingFaceIcon, - sendgrid: SendgridIcon, - video_generator: VideoIcon, - smtp: SmtpIcon, - google_groups: GoogleGroupsIcon, - mailgun: MailgunIcon, - clay: ClayIcon, - jira: JiraIcon, - search: SearchIcon, - linkedin: LinkedInIcon, - wealthbox: WealthboxIcon, notion: NotionIcon, - elevenlabs: ElevenLabsIcon, + neo4j: Neo4jIcon, + mysql: MySQLIcon, + mongodb: MongoDBIcon, + mistral_parse: MistralIcon, microsoft_teams: MicrosoftTeamsIcon, - github: GithubIcon, - sftp: SftpIcon, - ssh: SshIcon, - google_drive: GoogleDriveIcon, - sentry: SentryIcon, - reddit: RedditIcon, - parallel_ai: ParallelIcon, - spotify: SpotifyIcon, - stripe: StripeIcon, - s3: S3Icon, - trello: TrelloIcon, + microsoft_planner: MicrosoftPlannerIcon, + microsoft_excel: MicrosoftExcelIcon, + memory: BrainIcon, mem0: Mem0Icon, + mailgun: MailgunIcon, + mailchimp: MailchimpIcon, + linkup: LinkupIcon, + linkedin: LinkedInIcon, + linear: LinearIcon, knowledge: PackageSearchIcon, + kalshi: KalshiIcon, + jira: JiraIcon, + jina: JinaAIIcon, intercom: IntercomIcon, - twilio_sms: TwilioIcon, - duckduckgo: DuckDuckGoIcon, - slack: SlackIcon, - datadog: DatadogIcon, - microsoft_excel: MicrosoftExcelIcon, + incidentio: IncidentioIcon, image_generator: ImageIcon, + hunter: HunterIOIcon, + huggingface: HuggingFaceIcon, + hubspot: HubspotIcon, + grafana: GrafanaIcon, + google_vault: GoogleVaultIcon, + google_slides: GoogleSlidesIcon, google_sheets: GoogleSheetsIcon, - wikipedia: WikipediaIcon, - cursor: CursorIcon, + google_groups: GoogleGroupsIcon, + google_forms: GoogleFormsIcon, + google_drive: GoogleDriveIcon, + google_docs: GoogleDocsIcon, + google_calendar: GoogleCalendarIcon, + google_search: GoogleIcon, + gmail: GmailIcon, + gitlab: GitLabIcon, + github: GithubIcon, firecrawl: FirecrawlIcon, - mysql: MySQLIcon, + file: DocumentIcon, + exa: ExaAIIcon, + elevenlabs: ElevenLabsIcon, + elasticsearch: ElasticsearchIcon, + dynamodb: DynamoDBIcon, + duckduckgo: DuckDuckGoIcon, + dropbox: DropboxIcon, + discord: DiscordIcon, + datadog: DatadogIcon, + cursor: CursorIcon, + confluence: ConfluenceIcon, + clay: ClayIcon, + calendly: CalendlyIcon, browser_use: BrowserUseIcon, - stt: STTIcon, + asana: AsanaIcon, + arxiv: ArxivIcon, + apollo: ApolloIcon, + apify: ApifyIcon, + airtable: AirtableIcon, + ahrefs: AhrefsIcon, } diff --git a/apps/sim/blocks/blocks/spotify.ts b/apps/sim/blocks/blocks/spotify.ts index 3987dc1b2c..ac810fdc56 100644 --- a/apps/sim/blocks/blocks/spotify.ts +++ b/apps/sim/blocks/blocks/spotify.ts @@ -153,6 +153,14 @@ export const SpotifyBlock: BlockConfig = { value: () => 'spotify_search', }, + { + id: 'credential', + title: 'Spotify Account', + type: 'oauth-input', + serviceId: 'spotify', + required: true, + }, + // === SEARCH === { id: 'query', @@ -647,15 +655,6 @@ export const SpotifyBlock: BlockConfig = { ], }, }, - - // === OAUTH CREDENTIAL === - { - id: 'credential', - title: 'Spotify Account', - type: 'oauth-input', - serviceId: 'spotify', - required: true, - }, ], tools: { access: [ diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index f9e690a726..12ead996f7 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4206,12 +4206,20 @@ export function RssIcon(props: SVGProps) { export function SpotifyIcon(props: SVGProps) { return ( - - + - ) } From f111dac02065c6fb5f74ce1ebe796a1e4beece4d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 13 Dec 2025 11:06:42 -0800 Subject: [PATCH 03/14] improvement(autolayout): reduce horizontal spacing (#2357) --- apps/sim/lib/workflows/autolayout/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts index 7616fb0944..838d8315ce 100644 --- a/apps/sim/lib/workflows/autolayout/constants.ts +++ b/apps/sim/lib/workflows/autolayout/constants.ts @@ -11,7 +11,7 @@ export { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b /** * Horizontal spacing between layers (columns) */ -export const DEFAULT_HORIZONTAL_SPACING = 350 +export const DEFAULT_HORIZONTAL_SPACING = 250 /** * Vertical spacing between blocks in the same layer From 73940ab3905f1c693d8884c250398440128c379b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 13 Dec 2025 12:31:03 -0800 Subject: [PATCH 04/14] fix(deployed-chat): voice mode (#2358) * fix(deployed-chat): voice mode * remove redundant check * consolidate query * invalidate session on password change + race condition fix --- apps/sim/app/(landing)/actions/github.ts | 2 +- apps/sim/app/api/chat/[identifier]/route.ts | 4 +- apps/sim/app/api/chat/utils.ts | 41 +++++++--- apps/sim/app/api/proxy/tts/stream/route.ts | 69 ++++++++++++++-- apps/sim/app/api/stars/route.ts | 6 +- apps/sim/app/chat/[identifier]/chat.tsx | 14 +++- .../voice-interface/voice-interface.tsx | 80 ++++--------------- .../sim/app/chat/hooks/use-audio-streaming.ts | 11 ++- 8 files changed, 136 insertions(+), 91 deletions(-) diff --git a/apps/sim/app/(landing)/actions/github.ts b/apps/sim/app/(landing)/actions/github.ts index 527f29ea44..42f586a956 100644 --- a/apps/sim/app/(landing)/actions/github.ts +++ b/apps/sim/app/(landing)/actions/github.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' -const DEFAULT_STARS = '18.6k' +const DEFAULT_STARS = '19.4k' const logger = createLogger('GitHubStars') diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index eefb9ca997..44e9e524a9 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -132,7 +132,7 @@ export async function POST( if ((password || email) && !input) { const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) - setChatAuthCookie(response, deployment.id, deployment.authType) + setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) return response } @@ -315,7 +315,7 @@ export async function GET( if ( deployment.authType !== 'public' && authCookie && - validateAuthToken(authCookie.value, deployment.id) + validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { return addCorsHeaders( createSuccessResponse({ diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 1e41f92012..c8b76d92fc 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { eq } from 'drizzle-orm' @@ -9,6 +10,10 @@ import { hasAdminPermission } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ChatAuthUtils') +function hashPassword(encryptedPassword: string): string { + return createHash('sha256').update(encryptedPassword).digest('hex').substring(0, 8) +} + /** * Check if user has permission to create a chat for a specific workflow * Either the user owns the workflow directly OR has admin permission for the workflow's workspace @@ -77,14 +82,20 @@ export async function checkChatAccess( return { hasAccess: false } } -const encryptAuthToken = (chatId: string, type: string): string => { - return Buffer.from(`${chatId}:${type}:${Date.now()}`).toString('base64') +function encryptAuthToken(chatId: string, type: string, encryptedPassword?: string | null): string { + const pwHash = encryptedPassword ? hashPassword(encryptedPassword) : '' + return Buffer.from(`${chatId}:${type}:${Date.now()}:${pwHash}`).toString('base64') } -export const validateAuthToken = (token: string, chatId: string): boolean => { +export function validateAuthToken( + token: string, + chatId: string, + encryptedPassword?: string | null +): boolean { try { const decoded = Buffer.from(token, 'base64').toString() - const [storedId, _type, timestamp] = decoded.split(':') + const parts = decoded.split(':') + const [storedId, _type, timestamp, storedPwHash] = parts if (storedId !== chatId) { return false @@ -92,20 +103,32 @@ export const validateAuthToken = (token: string, chatId: string): boolean => { const createdAt = Number.parseInt(timestamp) const now = Date.now() - const expireTime = 24 * 60 * 60 * 1000 // 24 hours + const expireTime = 24 * 60 * 60 * 1000 if (now - createdAt > expireTime) { return false } + if (encryptedPassword) { + const currentPwHash = hashPassword(encryptedPassword) + if (storedPwHash !== currentPwHash) { + return false + } + } + return true } catch (_e) { return false } } -export const setChatAuthCookie = (response: NextResponse, chatId: string, type: string): void => { - const token = encryptAuthToken(chatId, type) +export function setChatAuthCookie( + response: NextResponse, + chatId: string, + type: string, + encryptedPassword?: string | null +): void { + const token = encryptAuthToken(chatId, type, encryptedPassword) response.cookies.set({ name: `chat_auth_${chatId}`, value: token, @@ -113,7 +136,7 @@ export const setChatAuthCookie = (response: NextResponse, chatId: string, type: secure: !isDev, sameSite: 'lax', path: '/', - maxAge: 60 * 60 * 24, // 24 hours + maxAge: 60 * 60 * 24, }) } @@ -145,7 +168,7 @@ export async function validateChatAuth( const cookieName = `chat_auth_${deployment.id}` const authCookie = request.cookies.get(cookieName) - if (authCookie && validateAuthToken(authCookie.value, deployment.id)) { + if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { return { authorized: true } } diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index 84c8c05b0f..316c0d0a0a 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -1,26 +1,81 @@ +import { db } from '@sim/db' +import { chat } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' +import { validateAuthToken } from '@/app/api/chat/utils' const logger = createLogger('ProxyTTSStreamAPI') +/** + * Validates chat-based authentication for deployed chat voice mode + * Checks if the user has a valid chat auth cookie for the given chatId + */ +async function validateChatAuth(request: NextRequest, chatId: string): Promise { + try { + const chatResult = await db + .select({ + id: chat.id, + isActive: chat.isActive, + authType: chat.authType, + password: chat.password, + }) + .from(chat) + .where(eq(chat.id, chatId)) + .limit(1) + + if (chatResult.length === 0 || !chatResult[0].isActive) { + logger.warn('Chat not found or inactive for TTS auth:', chatId) + return false + } + + const chatData = chatResult[0] + + if (chatData.authType === 'public') { + return true + } + + const cookieName = `chat_auth_${chatId}` + const authCookie = request.cookies.get(cookieName) + + if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) { + return true + } + + return false + } catch (error) { + logger.error('Error validating chat auth for TTS:', error) + return false + } +} + export async function POST(request: NextRequest) { try { - const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { - logger.error('Authentication failed for TTS stream proxy:', authResult.error) - return new Response('Unauthorized', { status: 401 }) + let body: any + try { + body = await request.json() + } catch { + return new Response('Invalid request body', { status: 400 }) } - const body = await request.json() - const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body + const { text, voiceId, modelId = 'eleven_turbo_v2_5', chatId } = body + + if (!chatId) { + return new Response('chatId is required', { status: 400 }) + } if (!text || !voiceId) { return new Response('Missing required parameters', { status: 400 }) } + const isChatAuthed = await validateChatAuth(request, chatId) + if (!isChatAuthed) { + logger.warn('Chat authentication failed for TTS, chatId:', chatId) + return new Response('Unauthorized', { status: 401 }) + } + const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255) if (!voiceIdValidation.isValid) { logger.error(`Invalid voice ID: ${voiceIdValidation.error}`) diff --git a/apps/sim/app/api/stars/route.ts b/apps/sim/app/api/stars/route.ts index e0e9d48ea8..fb02b20d47 100644 --- a/apps/sim/app/api/stars/route.ts +++ b/apps/sim/app/api/stars/route.ts @@ -23,13 +23,13 @@ export async function GET() { if (!response.ok) { console.warn('GitHub API request failed:', response.status) - return NextResponse.json({ stars: formatStarCount(14500) }) + return NextResponse.json({ stars: formatStarCount(19400) }) } const data = await response.json() - return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 14500)) }) + return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 19400)) }) } catch (error) { console.warn('Error fetching GitHub stars:', error) - return NextResponse.json({ stars: formatStarCount(14500) }) + return NextResponse.json({ stars: formatStarCount(19400) }) } } diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index fe63fbf18f..cb70cbbb91 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -39,6 +39,7 @@ interface ChatConfig { interface AudioStreamingOptions { voiceId: string + chatId?: string onError: (error: Error) => void } @@ -62,16 +63,19 @@ function fileToBase64(file: File): Promise { * Creates an audio stream handler for text-to-speech conversion * @param streamTextToAudio - Function to stream text to audio * @param voiceId - The voice ID to use for TTS + * @param chatId - Optional chat ID for deployed chat authentication * @returns Audio stream handler function or undefined */ function createAudioStreamHandler( streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise, - voiceId: string + voiceId: string, + chatId?: string ) { return async (text: string) => { try { await streamTextToAudio(text, { voiceId, + chatId, onError: (error: Error) => { logger.error('Audio streaming error:', error) }, @@ -113,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [error, setError] = useState(null) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) - const [starCount, setStarCount] = useState('3.4k') + const [starCount, setStarCount] = useState('19.4k') const [conversationId, setConversationId] = useState('') const [showScrollButton, setShowScrollButton] = useState(false) @@ -391,7 +395,11 @@ export default function ChatClient({ identifier }: { identifier: string }) { // Use the streaming hook with audio support const shouldPlayAudio = isVoiceInput || isVoiceFirstMode const audioHandler = shouldPlayAudio - ? createAudioStreamHandler(streamTextToAudio, DEFAULT_VOICE_SETTINGS.voiceId) + ? createAudioStreamHandler( + streamTextToAudio, + DEFAULT_VOICE_SETTINGS.voiceId, + chatConfig?.id + ) : undefined logger.info('Starting to handle streamed response:', { shouldPlayAudio }) diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx index a4f2ad095e..d4dc002ff2 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx @@ -68,7 +68,6 @@ export function VoiceInterface({ messages = [], className, }: VoiceInterfaceProps) { - // Simple state machine const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle') const [isInitialized, setIsInitialized] = useState(false) const [isMuted, setIsMuted] = useState(false) @@ -76,12 +75,10 @@ export function VoiceInterface({ const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>( 'prompt' ) - - // Current turn transcript (subtitle) const [currentTranscript, setCurrentTranscript] = useState('') - // State tracking const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle') + const isCallEndedRef = useRef(false) useEffect(() => { currentStateRef.current = state @@ -98,12 +95,10 @@ export function VoiceInterface({ const isSupported = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition) - // Update muted ref useEffect(() => { isMutedRef.current = isMuted }, [isMuted]) - // Timeout to handle cases where agent doesn't provide audio response const setResponseTimeout = useCallback(() => { if (responseTimeoutRef.current) { clearTimeout(responseTimeoutRef.current) @@ -113,7 +108,7 @@ export function VoiceInterface({ if (currentStateRef.current === 'listening') { setState('idle') } - }, 5000) // 5 second timeout (increased from 3) + }, 5000) }, []) const clearResponseTimeout = useCallback(() => { @@ -123,14 +118,12 @@ export function VoiceInterface({ } }, []) - // Sync with external state useEffect(() => { if (isPlayingAudio && state !== 'agent_speaking') { - clearResponseTimeout() // Clear timeout since agent is responding + clearResponseTimeout() setState('agent_speaking') setCurrentTranscript('') - // Mute microphone immediately setIsMuted(true) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -138,7 +131,6 @@ export function VoiceInterface({ }) } - // Stop speech recognition completely if (recognitionRef.current) { try { recognitionRef.current.abort() @@ -150,7 +142,6 @@ export function VoiceInterface({ setState('idle') setCurrentTranscript('') - // Re-enable microphone setIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -160,7 +151,6 @@ export function VoiceInterface({ } }, [isPlayingAudio, state, clearResponseTimeout]) - // Audio setup const setupAudio = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ @@ -175,7 +165,6 @@ export function VoiceInterface({ setPermissionStatus('granted') mediaStreamRef.current = stream - // Setup audio context for visualization if (!audioContextRef.current) { const AudioContext = window.AudioContext || window.webkitAudioContext audioContextRef.current = new AudioContext() @@ -194,7 +183,6 @@ export function VoiceInterface({ source.connect(analyser) analyserRef.current = analyser - // Start visualization const updateVisualization = () => { if (!analyserRef.current) return @@ -223,7 +211,6 @@ export function VoiceInterface({ } }, []) - // Speech recognition setup const setupSpeechRecognition = useCallback(() => { if (!isSupported) return @@ -259,14 +246,11 @@ export function VoiceInterface({ } } - // Update live transcript setCurrentTranscript(interimTranscript || finalTranscript) - // Send final transcript (but keep listening state until agent responds) if (finalTranscript.trim()) { - setCurrentTranscript('') // Clear transcript + setCurrentTranscript('') - // Stop recognition to avoid interference while waiting for response if (recognitionRef.current) { try { recognitionRef.current.stop() @@ -275,7 +259,6 @@ export function VoiceInterface({ } } - // Start timeout in case agent doesn't provide audio response setResponseTimeout() onVoiceTranscript?.(finalTranscript) @@ -283,13 +266,14 @@ export function VoiceInterface({ } recognition.onend = () => { + if (isCallEndedRef.current) return + const currentState = currentStateRef.current - // Only restart recognition if we're in listening state and not muted if (currentState === 'listening' && !isMutedRef.current) { - // Add a delay to avoid immediate restart after sending transcript setTimeout(() => { - // Double-check state hasn't changed during delay + if (isCallEndedRef.current) return + if ( recognitionRef.current && currentStateRef.current === 'listening' && @@ -301,14 +285,12 @@ export function VoiceInterface({ logger.debug('Error restarting speech recognition:', error) } } - }, 1000) // Longer delay to give agent time to respond + }, 1000) } } recognition.onerror = (event: SpeechRecognitionErrorEvent) => { - // Filter out "aborted" errors - these are expected when we intentionally stop recognition if (event.error === 'aborted') { - // Ignore return } @@ -320,7 +302,6 @@ export function VoiceInterface({ recognitionRef.current = recognition }, [isSupported, onVoiceTranscript, setResponseTimeout]) - // Start/stop listening const startListening = useCallback(() => { if (!isInitialized || isMuted || state !== 'idle') { return @@ -351,17 +332,12 @@ export function VoiceInterface({ } }, []) - // Handle interrupt const handleInterrupt = useCallback(() => { if (state === 'agent_speaking') { - // Clear any subtitle timeouts and text - // (No longer needed after removing subtitle system) - onInterrupt?.() setState('listening') setCurrentTranscript('') - // Unmute microphone for user input setIsMuted(false) if (mediaStreamRef.current) { mediaStreamRef.current.getAudioTracks().forEach((track) => { @@ -369,7 +345,6 @@ export function VoiceInterface({ }) } - // Start listening immediately if (recognitionRef.current) { try { recognitionRef.current.start() @@ -380,14 +355,13 @@ export function VoiceInterface({ } }, [state, onInterrupt]) - // Handle call end with proper cleanup const handleCallEnd = useCallback(() => { - // Stop everything immediately + isCallEndedRef.current = true + setState('idle') setCurrentTranscript('') setIsMuted(false) - // Stop speech recognition if (recognitionRef.current) { try { recognitionRef.current.abort() @@ -396,17 +370,11 @@ export function VoiceInterface({ } } - // Clear timeouts clearResponseTimeout() - - // Stop audio playback and streaming immediately onInterrupt?.() - - // Call the original onCallEnd onCallEnd?.() }, [onCallEnd, onInterrupt, clearResponseTimeout]) - // Keyboard handler useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === 'Space') { @@ -419,7 +387,6 @@ export function VoiceInterface({ return () => document.removeEventListener('keydown', handleKeyDown) }, [handleInterrupt]) - // Mute toggle const toggleMute = useCallback(() => { if (state === 'agent_speaking') { handleInterrupt() @@ -442,7 +409,6 @@ export function VoiceInterface({ } }, [isMuted, state, handleInterrupt, stopListening, startListening]) - // Initialize useEffect(() => { if (isSupported) { setupSpeechRecognition() @@ -450,47 +416,40 @@ export function VoiceInterface({ } }, [isSupported, setupSpeechRecognition, setupAudio]) - // Auto-start listening when ready useEffect(() => { if (isInitialized && !isMuted && state === 'idle') { startListening() } }, [isInitialized, isMuted, state, startListening]) - // Cleanup when call ends or component unmounts useEffect(() => { return () => { - // Stop speech recognition + isCallEndedRef.current = true + if (recognitionRef.current) { try { recognitionRef.current.abort() - } catch (error) { + } catch (_e) { // Ignore } recognitionRef.current = null } - // Stop media stream if (mediaStreamRef.current) { - mediaStreamRef.current.getTracks().forEach((track) => { - track.stop() - }) + mediaStreamRef.current.getTracks().forEach((track) => track.stop()) mediaStreamRef.current = null } - // Stop audio context if (audioContextRef.current) { audioContextRef.current.close() audioContextRef.current = null } - // Cancel animation frame if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current) animationFrameRef.current = null } - // Clear timeouts if (responseTimeoutRef.current) { clearTimeout(responseTimeoutRef.current) responseTimeoutRef.current = null @@ -498,7 +457,6 @@ export function VoiceInterface({ } }, []) - // Get status text const getStatusText = () => { switch (state) { case 'listening': @@ -510,7 +468,6 @@ export function VoiceInterface({ } } - // Get button content const getButtonContent = () => { if (state === 'agent_speaking') { return ( @@ -524,9 +481,7 @@ export function VoiceInterface({ return (
- {/* Main content */}
- {/* Voice visualization */}
- {/* Live transcript - subtitle style */}
{currentTranscript && (
@@ -549,17 +503,14 @@ export function VoiceInterface({ )}
- {/* Status */}

{getStatusText()} {isMuted && (Muted)}

- {/* Controls */}
- {/* End call */} - {/* Mic/Stop button */} - -
+ {!isAuthDisabled && ( +
+ + +
+ )} {/* Password Reset Confirmation Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx index 6bb09130a8..d01fb51d84 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx @@ -6,7 +6,7 @@ import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn' import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client/utils' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 1d1997c663..d1debd8d21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -26,7 +26,7 @@ import { McpIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { getUserRole } from '@/lib/workspaces/organization' import { ApiKeys, diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index d57b7d3021..3e321d2cd0 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -1,5 +1,5 @@ import { AgentIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index fa291521c9..e809ed0476 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -1,5 +1,5 @@ import { ChartBarIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig, ParamType } from '@/blocks/types' import type { ProviderId } from '@/providers/types' diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts index 5af165f3ee..af9f7a7d46 100644 --- a/apps/sim/blocks/blocks/guardrails.ts +++ b/apps/sim/blocks/blocks/guardrails.ts @@ -1,5 +1,5 @@ import { ShieldCheckIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import type { BlockConfig } from '@/blocks/types' import { getHostedModels, getProviderIcon } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 31daa27e2d..744aa53950 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -1,5 +1,5 @@ import { ConnectIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ProviderId } from '@/providers/types' import { diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts index 5f7c954a1e..bd984b8601 100644 --- a/apps/sim/blocks/blocks/translate.ts +++ b/apps/sim/blocks/blocks/translate.ts @@ -1,5 +1,5 @@ import { TranslateIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { AuthMode, type BlockConfig } from '@/blocks/types' import { getHostedModels, getProviderIcon, providers } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' diff --git a/apps/sim/components/emails/footer.tsx b/apps/sim/components/emails/footer.tsx index 6b45e7e011..f6eb4044d2 100644 --- a/apps/sim/components/emails/footer.tsx +++ b/apps/sim/components/emails/footer.tsx @@ -1,6 +1,6 @@ import { Container, Img, Link, Section, Text } from '@react-email/components' import { getBrandConfig } from '@/lib/branding/branding' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' interface UnsubscribeOptions { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 1e366c4014..449dd82441 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' -import { isHosted } from '@/lib/core/config/environment' import { getAllBlocks } from '@/blocks' import { BlockType } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' @@ -11,11 +10,11 @@ import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' -vi.mock('@/lib/core/config/environment', () => ({ - isHosted: vi.fn().mockReturnValue(false), - isProd: vi.fn().mockReturnValue(false), - isDev: vi.fn().mockReturnValue(true), - isTest: vi.fn().mockReturnValue(false), +vi.mock('@/lib/core/config/feature-flags', () => ({ + isHosted: false, + isProd: false, + isDev: true, + isTest: false, getCostMultiplier: vi.fn().mockReturnValue(1), isEmailVerificationEnabled: false, isBillingEnabled: false, @@ -65,7 +64,6 @@ global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch const mockGetAllBlocks = getAllBlocks as Mock const mockExecuteTool = executeTool as Mock -const mockIsHosted = isHosted as unknown as Mock const mockGetProviderFromModel = getProviderFromModel as Mock const mockTransformBlockTool = transformBlockTool as Mock const mockFetch = global.fetch as unknown as Mock @@ -120,7 +118,6 @@ describe('AgentBlockHandler', () => { loops: {}, } as SerializedWorkflow, } - mockIsHosted.mockReturnValue(false) mockGetProviderFromModel.mockReturnValue('mock-provider') mockFetch.mockImplementation(() => { @@ -552,8 +549,6 @@ describe('AgentBlockHandler', () => { }) it('should not require API key for gpt-4o on hosted version', async () => { - mockIsHosted.mockReturnValue(true) - const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index ada3c84801..3354a0f70e 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -1,5 +1,5 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('CopilotKeysQuery') diff --git a/apps/sim/lib/auth/anonymous.ts b/apps/sim/lib/auth/anonymous.ts new file mode 100644 index 0000000000..30ee4e94a4 --- /dev/null +++ b/apps/sim/lib/auth/anonymous.ts @@ -0,0 +1,104 @@ +import { db } from '@sim/db' +import * as schema from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console/logger' +import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants' + +const logger = createLogger('AnonymousAuth') + +let anonymousUserEnsured = false + +/** + * Ensures the anonymous user and their stats record exist in the database. + * Called when DISABLE_AUTH is enabled to ensure DB operations work. + */ +export async function ensureAnonymousUserExists(): Promise { + if (anonymousUserEnsured) return + + try { + const existingUser = await db.query.user.findFirst({ + where: eq(schema.user.id, ANONYMOUS_USER_ID), + }) + + if (!existingUser) { + const now = new Date() + await db.insert(schema.user).values({ + ...ANONYMOUS_USER, + createdAt: now, + updatedAt: now, + }) + logger.info('Created anonymous user for DISABLE_AUTH mode') + } + + const existingStats = await db.query.userStats.findFirst({ + where: eq(schema.userStats.userId, ANONYMOUS_USER_ID), + }) + + if (!existingStats) { + await db.insert(schema.userStats).values({ + id: crypto.randomUUID(), + userId: ANONYMOUS_USER_ID, + currentUsageLimit: '10000000000', + }) + logger.info('Created anonymous user stats for DISABLE_AUTH mode') + } + + anonymousUserEnsured = true + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('unique') || error.message.includes('duplicate')) + ) { + anonymousUserEnsured = true + return + } + logger.error('Failed to ensure anonymous user exists', { error }) + throw error + } +} + +export interface AnonymousSession { + user: { + id: string + name: string + email: string + emailVerified: boolean + image: null + createdAt: Date + updatedAt: Date + } + session: { + id: string + userId: string + expiresAt: Date + createdAt: Date + updatedAt: Date + token: string + ipAddress: null + userAgent: null + } +} + +/** + * Creates an anonymous session for when auth is disabled. + */ +export function createAnonymousSession(): AnonymousSession { + const now = new Date() + return { + user: { + ...ANONYMOUS_USER, + createdAt: now, + updatedAt: now, + }, + session: { + id: 'anonymous-session', + userId: ANONYMOUS_USER_ID, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year + createdAt: now, + updatedAt: now, + token: 'anonymous-token', + ipAddress: null, + userAgent: null, + }, + } +} diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index 817902a0b4..5c61e5bff4 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -10,7 +10,7 @@ import { import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' import { env } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider' diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index eeec34fcc8..fb9287e908 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -38,13 +38,19 @@ import { handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' -import { env, isTruthy } from '@/lib/core/config/env' -import { isBillingEnabled, isEmailVerificationEnabled } from '@/lib/core/config/environment' +import { env } from '@/lib/core/config/env' +import { + isAuthDisabled, + isBillingEnabled, + isEmailVerificationEnabled, + isRegistrationDisabled, +} from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { SSO_TRUSTED_PROVIDERS } from './sso/constants' const logger = createLogger('Auth') @@ -270,7 +276,7 @@ export const auth = betterAuth({ }, hooks: { before: createAuthMiddleware(async (ctx) => { - if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION)) + if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled) throw new Error('Registration is disabled, please contact your admin.') if ( @@ -2185,6 +2191,11 @@ export const auth = betterAuth({ }) export async function getSession() { + if (isAuthDisabled) { + await ensureAnonymousUserExists() + return createAnonymousSession() + } + const hdrs = await headers() return await auth.api.getSession({ headers: hdrs, diff --git a/apps/sim/lib/auth/constants.ts b/apps/sim/lib/auth/constants.ts new file mode 100644 index 0000000000..46eca038cf --- /dev/null +++ b/apps/sim/lib/auth/constants.ts @@ -0,0 +1,10 @@ +/** Anonymous user ID used when DISABLE_AUTH is enabled */ +export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000' + +export const ANONYMOUS_USER = { + id: ANONYMOUS_USER_ID, + name: 'Anonymous', + email: 'anonymous@localhost', + emailVerified: true, + image: null, +} as const diff --git a/apps/sim/lib/auth/index.ts b/apps/sim/lib/auth/index.ts index 84c42e215b..d997017e19 100644 --- a/apps/sim/lib/auth/index.ts +++ b/apps/sim/lib/auth/index.ts @@ -1 +1,4 @@ +export type { AnonymousSession } from './anonymous' +export { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' export { auth, getSession, signIn, signUp } from './auth' +export { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants' diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 66cf8fbe44..219f9e2f30 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { member, organization, userStats } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { getUserUsageLimit } from '@/lib/billing/core/usage' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('UsageMonitor') diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 4aa977abe9..74a2fa9ee3 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -9,7 +9,7 @@ import { getPerUserMinimumLimit, } from '@/lib/billing/subscriptions/utils' import type { UserSubscriptionState } from '@/lib/billing/types' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index f784151cf5..f32ac38bff 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,7 +14,7 @@ import { getPlanPricing, } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 8f15ec2237..9e7dd5efc7 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -13,7 +13,7 @@ import { import { organization, subscription, userStats } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { getEnv } from '@/lib/core/config/env' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('StorageLimits') diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index b094769e70..704a4ae6ab 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -7,7 +7,7 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { eq, sql } from 'drizzle-orm' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('StorageTracking') diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ab289468e3..2fc2ad1a09 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -20,6 +20,7 @@ export const env = createEnv({ BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration + DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session) ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data diff --git a/apps/sim/lib/core/config/environment.ts b/apps/sim/lib/core/config/feature-flags.ts similarity index 63% rename from apps/sim/lib/core/config/environment.ts rename to apps/sim/lib/core/config/feature-flags.ts index 835f54c8bc..cac10d296d 100644 --- a/apps/sim/lib/core/config/environment.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -35,6 +35,31 @@ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) */ export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED) +/** + * Is authentication disabled (for self-hosted deployments behind private networks) + */ +export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) + +/** + * Is user registration disabled + */ +export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION) + +/** + * Is Trigger.dev enabled for async job processing + */ +export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) + +/** + * Is SSO enabled for enterprise authentication + */ +export const isSsoEnabled = isTruthy(env.SSO_ENABLED) + +/** + * Is E2B enabled for remote code execution + */ +export const isE2bEnabled = isTruthy(env.E2B_ENABLED) + /** * Get cost multiplier based on environment */ diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index e740ac52fc..22e164cf1d 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,5 +1,5 @@ import { getEnv } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' /** * Returns the base URL of the application from NEXT_PUBLIC_APP_URL diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index a1b0cc2023..14e2ceee8b 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -6,7 +6,7 @@ import { } from '@sim/db/schema' import { and, eq, or, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowExecutionLog } from '@/lib/logs/types' import { @@ -140,9 +140,7 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): alertConfig: alertConfig || undefined, } - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { await workspaceNotificationDeliveryTask.trigger(payload) logger.info( `Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev` diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 3bf8496313..390a604567 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -15,7 +15,7 @@ import { maybeSendUsageThresholdEmail, } from '@/lib/billing/core/usage' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' -import { isBillingEnabled } from '@/lib/core/config/environment' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { filterForDisplay } from '@/lib/core/utils/display-filters' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index cc04f2e6de..8d1483f522 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -5,7 +5,7 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import { isTest } from '@/lib/core/config/environment' +import { isTest } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/lib/notifications/inactivity-polling.ts b/apps/sim/lib/notifications/inactivity-polling.ts index f5558088fc..4d4395faa4 100644 --- a/apps/sim/lib/notifications/inactivity-polling.ts +++ b/apps/sim/lib/notifications/inactivity-polling.ts @@ -7,7 +7,7 @@ import { } from '@sim/db/schema' import { and, eq, gte, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import { executeNotificationDelivery, @@ -118,9 +118,7 @@ async function checkWorkflowInactivity( alertConfig, } - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { await workspaceNotificationDeliveryTask.trigger(payload) } else { void executeNotificationDelivery(payload).catch((error) => { diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 8f0fd8cad8..d7cb0f65f6 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { env, isTruthy } from '@/lib/core/config/env' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { preprocessExecution } from '@/lib/execution/preprocessing' import { createLogger } from '@/lib/logs/console/logger' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' @@ -707,9 +707,7 @@ export async function queueWebhookExecution( ...(credentialId ? { credentialId } : {}), } - const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - - if (useTrigger) { + if (isTriggerDevEnabled) { const handle = await tasks.trigger('webhook-execution', payload) logger.info( `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 3e3e22bc89..a007db01b0 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,6 +1,6 @@ import type { NextConfig } from 'next' import { env, getEnv, isTruthy } from './lib/core/config/env' -import { isDev, isHosted } from './lib/core/config/environment' +import { isDev, isHosted } from './lib/core/config/feature-flags' import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/core/security/csp' const nextConfig: NextConfig = { diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 0b6d7b1ac7..72d1423e15 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,4 +1,4 @@ -import { getCostMultiplier } from '@/lib/core/config/environment' +import { getCostMultiplier } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import type { StreamingExecution } from '@/executor/types' import type { ProviderRequest, ProviderResponse } from '@/providers/types' diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 13977f37d9..003119c86c 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as environmentModule from '@/lib/core/config/environment' +import * as environmentModule from '@/lib/core/config/feature-flags' import { calculateCost, extractAndParseJSON, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ca0b6db77f..972cdf8156 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1,5 +1,5 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/environment' +import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' import { anthropicProvider } from '@/providers/anthropic' import { azureOpenAIProvider } from '@/providers/azure-openai' diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 0d0c2cb647..bb1b23bfad 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -1,6 +1,6 @@ import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' -import { isHosted } from './lib/core/config/environment' +import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags' import { generateRuntimeCSP } from './lib/core/security/csp' import { createLogger } from './lib/logs/console/logger' @@ -135,7 +135,7 @@ export async function proxy(request: NextRequest) { const url = request.nextUrl const sessionCookie = getSessionCookie(request) - const hasActiveSession = !!sessionCookie + const hasActiveSession = isAuthDisabled || !!sessionCookie const redirect = handleRootPathRedirects(request, hasActiveSession) if (redirect) return redirect diff --git a/apps/sim/scripts/process-docs.ts b/apps/sim/scripts/process-docs.ts index 2a53e78fc5..8657e3b6fc 100644 --- a/apps/sim/scripts/process-docs.ts +++ b/apps/sim/scripts/process-docs.ts @@ -5,7 +5,7 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { sql } from 'drizzle-orm' import { type DocChunk, DocsChunker } from '@/lib/chunkers' -import { isDev } from '@/lib/core/config/environment' +import { isDev } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ProcessDocs') diff --git a/apps/sim/socket-server/config/socket.ts b/apps/sim/socket-server/config/socket.ts index 2eab72f588..6015dc971f 100644 --- a/apps/sim/socket-server/config/socket.ts +++ b/apps/sim/socket-server/config/socket.ts @@ -1,7 +1,7 @@ import type { Server as HttpServer } from 'http' import { Server } from 'socket.io' import { env } from '@/lib/core/config/env' -import { isProd } from '@/lib/core/config/environment' +import { isProd } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' diff --git a/apps/sim/socket-server/middleware/auth.ts b/apps/sim/socket-server/middleware/auth.ts index 56de4676ca..3b3a6cc548 100644 --- a/apps/sim/socket-server/middleware/auth.ts +++ b/apps/sim/socket-server/middleware/auth.ts @@ -1,10 +1,14 @@ import type { Socket } from 'socket.io' import { auth } from '@/lib/auth' +import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { isAuthDisabled } from '@/lib/core/config/feature-flags' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('SocketAuth') -// Extend Socket interface to include user data +/** + * Authenticated socket with user data attached. + */ export interface AuthenticatedSocket extends Socket { userId?: string userName?: string @@ -13,9 +17,21 @@ export interface AuthenticatedSocket extends Socket { userImage?: string | null } -// Enhanced authentication middleware +/** + * Socket.IO authentication middleware. + * Handles both anonymous mode (DISABLE_AUTH=true) and normal token-based auth. + */ export async function authenticateSocket(socket: AuthenticatedSocket, next: any) { try { + if (isAuthDisabled) { + socket.userId = ANONYMOUS_USER_ID + socket.userName = ANONYMOUS_USER.name + socket.userEmail = ANONYMOUS_USER.email + socket.userImage = ANONYMOUS_USER.image + logger.debug(`Socket ${socket.id} authenticated as anonymous`) + return next() + } + // Extract authentication data from socket handshake const token = socket.handshake.auth?.token const origin = socket.handshake.headers.origin diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index 1c99a162db..9e8248d3e3 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,4 +1,4 @@ -import { isTest } from '@/lib/core/config/environment' +import { isTest } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import type { TableRow } from '@/tools/types' From 0fb084b9e402fbc93f4664904f87d69651611bc9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 13 Dec 2025 17:38:59 -0800 Subject: [PATCH 10/14] fix(subflows): prevent cross-boundary connections on autoconnect drop between subflow blocks and regular blocks (#2366) --- .../app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index de70d6b67e..c28ff8dc27 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -579,8 +579,10 @@ const WorkflowContent = React.memo(() => { const node = nodeIndex.get(id) if (!node) return false - // If dropping outside containers, ignore blocks that are inside a container - if (!containerAtPoint && blocks[id]?.data?.parentId) return false + const blockParentId = blocks[id]?.data?.parentId + const dropParentId = containerAtPoint?.loopId + if (dropParentId !== blockParentId) return false + return true }) .map(([id, block]) => { From d5b95cbd338fc29a8d264327ba473421f6cf0c2c Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 13 Dec 2025 19:06:12 -0800 Subject: [PATCH 11/14] fix(organizations): move organization better-auth client to conditionally be included based on FF (#2367) * fix(organizations): move organization better-auth client to conditionally be included based on FF * ack PR comment --- apps/sim/lib/auth/auth-client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index 5c61e5bff4..c5b841afe1 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -25,9 +25,9 @@ export const client = createAuthClient({ stripeClient({ subscription: true, // Enable subscription management }), + organizationClient(), ] : []), - organizationClient(), ...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []), ], }) @@ -42,7 +42,9 @@ export function useSession(): SessionHookResult { return ctx } -export const { useActiveOrganization } = client +export const useActiveOrganization = isBillingEnabled + ? client.useActiveOrganization + : () => ({ data: undefined, isPending: false, error: null }) export const useSubscription = () => { return { From c962e3b3983c3c7d8d8e5878d3dc5ec9bae02acd Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 13 Dec 2025 19:14:33 -0800 Subject: [PATCH 12/14] feat(webflow): added collection, item, & site selectors for webflow (#2368) * feat(webflow): added collection, item, & site selectors for webflow * ack PR comments * ack PR comments --- apps/docs/content/docs/en/tools/webflow.mdx | 5 + .../api/tools/webflow/collections/route.ts | 57 ++++++--- apps/sim/app/api/tools/webflow/items/route.ts | 104 +++++++++++++++++ apps/sim/app/api/tools/webflow/sites/route.ts | 51 ++++++--- .../file-selector/file-selector-input.tsx | 14 +++ .../project-selector-input.tsx | 15 +-- .../file-selector/file-selector-input.tsx | 14 +++ apps/sim/blocks/blocks/webflow.ts | 108 ++++++++++++++++-- apps/sim/hooks/selectors/registry.ts | 93 +++++++++++++++ apps/sim/hooks/selectors/resolution.ts | 14 +++ apps/sim/hooks/selectors/types.ts | 5 + apps/sim/tools/webflow/create_item.ts | 6 + apps/sim/tools/webflow/delete_item.ts | 8 +- apps/sim/tools/webflow/get_item.ts | 8 +- apps/sim/tools/webflow/list_items.ts | 6 + apps/sim/tools/webflow/types.ts | 1 + apps/sim/tools/webflow/update_item.ts | 8 +- 17 files changed, 460 insertions(+), 57 deletions(-) create mode 100644 apps/sim/app/api/tools/webflow/items/route.ts diff --git a/apps/docs/content/docs/en/tools/webflow.mdx b/apps/docs/content/docs/en/tools/webflow.mdx index f23fd172cf..168f3eaf43 100644 --- a/apps/docs/content/docs/en/tools/webflow.mdx +++ b/apps/docs/content/docs/en/tools/webflow.mdx @@ -42,6 +42,7 @@ List all items from a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `offset` | number | No | Offset for pagination \(optional\) | | `limit` | number | No | Maximum number of items to return \(optional, default: 100\) | @@ -61,6 +62,7 @@ Get a single item from a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `itemId` | string | Yes | ID of the item to retrieve | @@ -79,6 +81,7 @@ Create a new item in a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `fieldData` | json | Yes | Field data for the new item as a JSON object. Keys should match collection field names. | @@ -97,6 +100,7 @@ Update an existing item in a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `itemId` | string | Yes | ID of the item to update | | `fieldData` | json | Yes | Field data to update as a JSON object. Only include fields you want to change. | @@ -116,6 +120,7 @@ Delete an item from a Webflow CMS collection | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | ID of the Webflow site | | `collectionId` | string | Yes | ID of the collection | | `itemId` | string | Yes | ID of the item to delete | diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts index 79c1d4605e..2aa17de0da 100644 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ b/apps/sim/app/api/tools/webflow/collections/route.ts @@ -1,32 +1,53 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowCollectionsAPI') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function POST(request: Request) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId, siteId } = body - const { searchParams } = new URL(request.url) - const siteId = searchParams.get('siteId') + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } if (!siteId) { - return NextResponse.json({ error: 'Missing siteId parameter' }, { status: 400 }) + logger.error('Missing siteId in request') + return NextResponse.json({ error: 'Site ID is required' }, { status: 400 }) } - const accessToken = await getOAuthToken(session.user.id, 'webflow') + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) return NextResponse.json( - { error: 'No Webflow access token found. Please connect your Webflow account.' }, - { status: 404 } + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } ) } @@ -58,11 +79,11 @@ export async function GET(request: NextRequest) { name: collection.displayName || collection.slug || collection.id, })) - return NextResponse.json({ collections: formattedCollections }, { status: 200 }) - } catch (error: any) { - logger.error('Error fetching Webflow collections', error) + return NextResponse.json({ collections: formattedCollections }) + } catch (error) { + logger.error('Error processing Webflow collections request:', error) return NextResponse.json( - { error: 'Internal server error', details: error.message }, + { error: 'Failed to retrieve Webflow collections', details: (error as Error).message }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts new file mode 100644 index 0000000000..8639c63e35 --- /dev/null +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebflowItemsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + try { + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId, collectionId, search } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!collectionId) { + logger.error('Missing collectionId in request') + return NextResponse.json({ error: 'Collection ID is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + + const response = await fetch( + `https://api.webflow.com/v2/collections/${collectionId}/items?limit=100`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Webflow items', { + status: response.status, + error: errorData, + collectionId, + }) + return NextResponse.json( + { error: 'Failed to fetch Webflow items', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const items = data.items || [] + + let formattedItems = items.map((item: any) => { + const fieldData = item.fieldData || {} + const name = fieldData.name || fieldData.title || fieldData.slug || item.id + return { + id: item.id, + name, + } + }) + + if (search) { + const searchLower = search.toLowerCase() + formattedItems = formattedItems.filter((item: { id: string; name: string }) => + item.name.toLowerCase().includes(searchLower) + ) + } + + return NextResponse.json({ items: formattedItems }) + } catch (error) { + logger.error('Error processing Webflow items request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Webflow items', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts index f94c3e3406..2cfc4698a3 100644 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ b/apps/sim/app/api/tools/webflow/sites/route.ts @@ -1,25 +1,48 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowSitesAPI') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function POST(request: Request) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const accessToken = await getOAuthToken(session.user.id, 'webflow') + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) return NextResponse.json( - { error: 'No Webflow access token found. Please connect your Webflow account.' }, - { status: 404 } + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } ) } @@ -50,11 +73,11 @@ export async function GET(request: NextRequest) { name: site.displayName || site.shortName || site.id, })) - return NextResponse.json({ sites: formattedSites }, { status: 200 }) - } catch (error: any) { - logger.error('Error fetching Webflow sites', error) + return NextResponse.json({ sites: formattedSites }) + } catch (error) { + logger.error('Error processing Webflow sites request:', error) return NextResponse.json( - { error: 'Internal server error', details: error.message }, + { error: 'Failed to retrieve Webflow sites', details: (error as Error).message }, { status: 500 } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx index b2232202d2..27415b31a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx @@ -47,12 +47,16 @@ export function FileSelectorInput({ const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId') const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId') const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId') + const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId') + const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId') const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore const domainValue = previewContextValues?.domain ?? domainValueFromStore const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore const planIdValue = previewContextValues?.planId ?? planIdValueFromStore const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore + const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore + const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore const normalizedCredentialId = typeof connectedCredential === 'string' @@ -75,6 +79,8 @@ export function FileSelectorInput({ projectId: (projectIdValue as string) || undefined, planId: (planIdValue as string) || undefined, teamId: (teamIdValue as string) || undefined, + siteId: (siteIdValue as string) || undefined, + collectionId: (collectionIdValue as string) || undefined, }) }, [ subBlock, @@ -84,6 +90,8 @@ export function FileSelectorInput({ projectIdValue, planIdValue, teamIdValue, + siteIdValue, + collectionIdValue, ]) const missingCredential = !normalizedCredentialId @@ -97,6 +105,10 @@ export function FileSelectorInput({ !selectorResolution.context.projectId const missingPlan = selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId + const missingSite = + selectorResolution?.key === 'webflow.collections' && !selectorResolution.context.siteId + const missingCollection = + selectorResolution?.key === 'webflow.items' && !selectorResolution.context.collectionId const disabledReason = finalDisabled || @@ -105,6 +117,8 @@ export function FileSelectorInput({ missingDomain || missingProject || missingPlan || + missingSite || + missingCollection || !selectorResolution?.key if (!selectorResolution?.key) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx index 8dccf4a449..d189aa92f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx @@ -43,14 +43,12 @@ export function ProjectSelectorInput({ // Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore - const linearCredential = previewContextValues?.credential ?? connectedCredentialFromStore const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore // Derive provider from serviceId using OAuth config const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - const isLinear = serviceId === 'linear' const { isForeignCredential } = useForeignCredential( effectiveProviderId, @@ -65,7 +63,6 @@ export function ProjectSelectorInput({ }) // Jira/Discord upstream fields - use values from previewContextValues or store - const jiraCredential = connectedCredential const domain = (jiraDomain as string) || '' // Verify Jira credential belongs to current user; if not, treat as absent @@ -84,19 +81,11 @@ export function ProjectSelectorInput({ const selectorResolution = useMemo(() => { return resolveSelectorForSubBlock(subBlock, { workflowId: workflowIdFromUrl || undefined, - credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined, + credentialId: (connectedCredential as string) || undefined, domain, teamId: (linearTeamId as string) || undefined, }) - }, [ - subBlock, - workflowIdFromUrl, - isLinear, - linearCredential, - jiraCredential, - domain, - linearTeamId, - ]) + }, [subBlock, workflowIdFromUrl, connectedCredential, domain, linearTeamId]) const missingCredential = !selectorResolution?.context.credentialId diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/sub-block/components/file-selector/file-selector-input.tsx index 6dfc8044d3..3f4dab8519 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/sub-block/components/file-selector/file-selector-input.tsx @@ -47,12 +47,16 @@ export function FileSelectorInput({ const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId') const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId') const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId') + const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId') + const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId') const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore const domainValue = previewContextValues?.domain ?? domainValueFromStore const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore const planIdValue = previewContextValues?.planId ?? planIdValueFromStore const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore + const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore + const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore const normalizedCredentialId = typeof connectedCredential === 'string' @@ -75,6 +79,8 @@ export function FileSelectorInput({ projectId: (projectIdValue as string) || undefined, planId: (planIdValue as string) || undefined, teamId: (teamIdValue as string) || undefined, + siteId: (siteIdValue as string) || undefined, + collectionId: (collectionIdValue as string) || undefined, }) }, [ subBlock, @@ -84,6 +90,8 @@ export function FileSelectorInput({ projectIdValue, planIdValue, teamIdValue, + siteIdValue, + collectionIdValue, ]) const missingCredential = !normalizedCredentialId @@ -97,6 +105,10 @@ export function FileSelectorInput({ !selectorResolution?.context.projectId const missingPlan = selectorResolution?.key === 'microsoft.planner' && !selectorResolution?.context.planId + const missingSite = + selectorResolution?.key === 'webflow.collections' && !selectorResolution?.context.siteId + const missingCollection = + selectorResolution?.key === 'webflow.items' && !selectorResolution?.context.collectionId const disabledReason = finalDisabled || @@ -105,6 +117,8 @@ export function FileSelectorInput({ missingDomain || missingProject || missingPlan || + missingSite || + missingCollection || !selectorResolution?.key if (!selectorResolution?.key) { diff --git a/apps/sim/blocks/blocks/webflow.ts b/apps/sim/blocks/blocks/webflow.ts index 21b3c6cd2d..cdb55df299 100644 --- a/apps/sim/blocks/blocks/webflow.ts +++ b/apps/sim/blocks/blocks/webflow.ts @@ -39,19 +39,65 @@ export const WebflowBlock: BlockConfig = { placeholder: 'Select Webflow account', required: true, }, + { + id: 'siteId', + title: 'Site', + type: 'project-selector', + canonicalParamId: 'siteId', + serviceId: 'webflow', + placeholder: 'Select Webflow site', + dependsOn: ['credential'], + mode: 'basic', + required: true, + }, + { + id: 'manualSiteId', + title: 'Site ID', + type: 'short-input', + canonicalParamId: 'siteId', + placeholder: 'Enter site ID', + mode: 'advanced', + required: true, + }, { id: 'collectionId', + title: 'Collection', + type: 'file-selector', + canonicalParamId: 'collectionId', + serviceId: 'webflow', + placeholder: 'Select collection', + dependsOn: ['credential', 'siteId'], + mode: 'basic', + required: true, + }, + { + id: 'manualCollectionId', title: 'Collection ID', type: 'short-input', + canonicalParamId: 'collectionId', placeholder: 'Enter collection ID', - dependsOn: ['credential'], + mode: 'advanced', required: true, }, { id: 'itemId', + title: 'Item', + type: 'file-selector', + canonicalParamId: 'itemId', + serviceId: 'webflow', + placeholder: 'Select item', + dependsOn: ['credential', 'collectionId'], + mode: 'basic', + condition: { field: 'operation', value: ['get', 'update', 'delete'] }, + required: true, + }, + { + id: 'manualItemId', title: 'Item ID', type: 'short-input', - placeholder: 'ID of the item', + canonicalParamId: 'itemId', + placeholder: 'Enter item ID', + mode: 'advanced', condition: { field: 'operation', value: ['get', 'update', 'delete'] }, required: true, }, @@ -108,7 +154,17 @@ export const WebflowBlock: BlockConfig = { } }, params: (params) => { - const { credential, fieldData, ...rest } = params + const { + credential, + fieldData, + siteId, + manualSiteId, + collectionId, + manualCollectionId, + itemId, + manualItemId, + ...rest + } = params let parsedFieldData: any | undefined try { @@ -119,15 +175,46 @@ export const WebflowBlock: BlockConfig = { throw new Error(`Invalid JSON input for ${params.operation} operation: ${error.message}`) } + const effectiveSiteId = ((siteId as string) || (manualSiteId as string) || '').trim() + const effectiveCollectionId = ( + (collectionId as string) || + (manualCollectionId as string) || + '' + ).trim() + const effectiveItemId = ((itemId as string) || (manualItemId as string) || '').trim() + + if (!effectiveSiteId) { + throw new Error('Site ID is required') + } + + if (!effectiveCollectionId) { + throw new Error('Collection ID is required') + } + const baseParams = { credential, + siteId: effectiveSiteId, + collectionId: effectiveCollectionId, ...rest, } switch (params.operation) { case 'create': case 'update': - return { ...baseParams, fieldData: parsedFieldData } + if (params.operation === 'update' && !effectiveItemId) { + throw new Error('Item ID is required for update operation') + } + return { + ...baseParams, + itemId: effectiveItemId || undefined, + fieldData: parsedFieldData, + } + case 'get': + case 'delete': + if (!effectiveItemId) { + throw new Error(`Item ID is required for ${params.operation} operation`) + } + return { ...baseParams, itemId: effectiveItemId } default: return baseParams } @@ -137,12 +224,15 @@ export const WebflowBlock: BlockConfig = { inputs: { operation: { type: 'string', description: 'Operation to perform' }, credential: { type: 'string', description: 'Webflow OAuth access token' }, + siteId: { type: 'string', description: 'Webflow site identifier' }, + manualSiteId: { type: 'string', description: 'Manual site identifier' }, collectionId: { type: 'string', description: 'Webflow collection identifier' }, - // Conditional inputs - itemId: { type: 'string', description: 'Item identifier' }, // Required for get/update/delete - offset: { type: 'number', description: 'Pagination offset' }, // Optional for list - limit: { type: 'number', description: 'Maximum items to return' }, // Optional for list - fieldData: { type: 'json', description: 'Item field data' }, // Required for create/update + manualCollectionId: { type: 'string', description: 'Manual collection identifier' }, + itemId: { type: 'string', description: 'Item identifier' }, + manualItemId: { type: 'string', description: 'Manual item identifier' }, + offset: { type: 'number', description: 'Pagination offset' }, + limit: { type: 'number', description: 'Maximum items to return' }, + fieldData: { type: 'json', description: 'Item field data' }, }, outputs: { items: { type: 'json', description: 'Array of items (list operation)' }, diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 87662747a6..4137e3065f 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -673,6 +673,99 @@ const registry: Record = { return { id: doc.id, label: doc.filename } }, }, + 'webflow.sites': { + key: 'webflow.sites', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'webflow.sites', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'webflow.sites') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ sites: { id: string; name: string }[] }>( + '/api/tools/webflow/sites', + { + method: 'POST', + body, + } + ) + return (data.sites || []).map((site) => ({ + id: site.id, + label: site.name, + })) + }, + }, + 'webflow.collections': { + key: 'webflow.collections', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'webflow.collections', + context.credentialId ?? 'none', + context.siteId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.siteId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'webflow.collections') + if (!context.siteId) { + throw new Error('Missing site ID for webflow.collections selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ collections: { id: string; name: string }[] }>( + '/api/tools/webflow/collections', + { + method: 'POST', + body, + } + ) + return (data.collections || []).map((collection) => ({ + id: collection.id, + label: collection.name, + })) + }, + }, + 'webflow.items': { + key: 'webflow.items', + staleTime: 15 * 1000, + getQueryKey: ({ context, search }: SelectorQueryArgs) => [ + 'selectors', + 'webflow.items', + context.credentialId ?? 'none', + context.collectionId ?? 'none', + search ?? '', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.collectionId), + fetchList: async ({ context, search }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'webflow.items') + if (!context.collectionId) { + throw new Error('Missing collection ID for webflow.items selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + collectionId: context.collectionId, + search, + }) + const data = await fetchJson<{ items: { id: string; name: string }[] }>( + '/api/tools/webflow/items', + { + method: 'POST', + body, + } + ) + return (data.items || []).map((item) => ({ + id: item.id, + label: item.name, + })) + }, + }, } export function getSelectorDefinition(key: SelectorKey): SelectorDefinition { diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index fcb6747d2c..76e3f2117b 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -15,6 +15,8 @@ export interface SelectorResolutionArgs { planId?: string teamId?: string knowledgeBaseId?: string + siteId?: string + collectionId?: string } const defaultContext: SelectorContext = {} @@ -52,6 +54,8 @@ function buildBaseContext( planId: args.planId, teamId: args.teamId, knowledgeBaseId: args.knowledgeBaseId, + siteId: args.siteId, + collectionId: args.collectionId, ...extra, } } @@ -106,6 +110,14 @@ function resolveFileSelector( } case 'sharepoint': return { key: 'sharepoint.sites', context, allowSearch: true } + case 'webflow': + if (subBlock.id === 'collectionId') { + return { key: 'webflow.collections', context, allowSearch: false } + } + if (subBlock.id === 'itemId') { + return { key: 'webflow.items', context, allowSearch: true } + } + return { key: null, context, allowSearch: true } default: return { key: null, context, allowSearch: true } } @@ -159,6 +171,8 @@ function resolveProjectSelector( } case 'jira': return { key: 'jira.projects', context, allowSearch: true } + case 'webflow': + return { key: 'webflow.sites', context, allowSearch: false } default: return { key: null, context, allowSearch: true } } diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index d83a7816a7..d186c4d50e 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -23,6 +23,9 @@ export type SelectorKey = | 'microsoft.planner' | 'google.drive' | 'knowledge.documents' + | 'webflow.sites' + | 'webflow.collections' + | 'webflow.items' export interface SelectorOption { id: string @@ -43,6 +46,8 @@ export interface SelectorContext { planId?: string mimeType?: string fileId?: string + siteId?: string + collectionId?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/tools/webflow/create_item.ts b/apps/sim/tools/webflow/create_item.ts index 7dd736b23b..516bab931f 100644 --- a/apps/sim/tools/webflow/create_item.ts +++ b/apps/sim/tools/webflow/create_item.ts @@ -20,6 +20,12 @@ export const webflowCreateItemTool: ToolConfig Date: Sat, 13 Dec 2025 19:26:31 -0800 Subject: [PATCH 13/14] feat(i18n): update translations (#2370) Co-authored-by: waleedlatif1 --- apps/docs/content/docs/de/tools/webflow.mdx | 11 ++++++++--- apps/docs/content/docs/es/tools/webflow.mdx | 5 +++++ apps/docs/content/docs/fr/tools/webflow.mdx | 13 +++++++++---- apps/docs/content/docs/ja/tools/webflow.mdx | 11 ++++++++--- apps/docs/content/docs/zh/tools/webflow.mdx | 15 ++++++++++----- apps/docs/i18n.lock | 10 +++++----- 6 files changed, 45 insertions(+), 20 deletions(-) diff --git a/apps/docs/content/docs/de/tools/webflow.mdx b/apps/docs/content/docs/de/tools/webflow.mdx index 75dd6605b0..4b34e13e99 100644 --- a/apps/docs/content/docs/de/tools/webflow.mdx +++ b/apps/docs/content/docs/de/tools/webflow.mdx @@ -39,9 +39,10 @@ Alle Elemente aus einer Webflow CMS-Sammlung auflisten | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | -| `offset` | number | Nein | Offset für Paginierung \(optional\) | -| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente \(optional, Standard: 100\) | +| `offset` | number | Nein | Offset für Paginierung (optional) | +| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente (optional, Standard: 100) | #### Ausgabe @@ -58,6 +59,7 @@ Ein einzelnes Element aus einer Webflow CMS-Sammlung abrufen | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | | `itemId` | string | Ja | ID des abzurufenden Elements | @@ -76,8 +78,9 @@ Ein neues Element in einer Webflow CMS-Sammlung erstellen | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | -| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Die Schlüssel sollten mit den Feldnamen der Sammlung übereinstimmen. | +| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Schlüssel sollten mit den Sammlungsfeldnamen übereinstimmen. | #### Ausgabe @@ -94,6 +97,7 @@ Ein vorhandenes Element in einer Webflow CMS-Sammlung aktualisieren | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | | `itemId` | string | Ja | ID des zu aktualisierenden Elements | | `fieldData` | json | Ja | Zu aktualisierende Felddaten als JSON-Objekt. Nur Felder einschließen, die geändert werden sollen. | @@ -113,6 +117,7 @@ Ein Element aus einer Webflow CMS-Sammlung löschen | Parameter | Typ | Erforderlich | Beschreibung | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Ja | ID der Webflow-Website | | `collectionId` | string | Ja | ID der Sammlung | | `itemId` | string | Ja | ID des zu löschenden Elements | diff --git a/apps/docs/content/docs/es/tools/webflow.mdx b/apps/docs/content/docs/es/tools/webflow.mdx index 09aec10879..e5c254874a 100644 --- a/apps/docs/content/docs/es/tools/webflow.mdx +++ b/apps/docs/content/docs/es/tools/webflow.mdx @@ -39,6 +39,7 @@ Listar todos los elementos de una colección del CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `offset` | number | No | Desplazamiento para paginación \(opcional\) | | `limit` | number | No | Número máximo de elementos a devolver \(opcional, predeterminado: 100\) | @@ -58,6 +59,7 @@ Obtener un solo elemento de una colección del CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `itemId` | string | Sí | ID del elemento a recuperar | @@ -76,6 +78,7 @@ Crear un nuevo elemento en una colección del CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `fieldData` | json | Sí | Datos de campo para el nuevo elemento como objeto JSON. Las claves deben coincidir con los nombres de campo de la colección. | @@ -94,6 +97,7 @@ Actualizar un elemento existente en una colección CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `itemId` | string | Sí | ID del elemento a actualizar | | `fieldData` | json | Sí | Datos de campo para actualizar como objeto JSON. Solo incluye los campos que quieres cambiar. | @@ -113,6 +117,7 @@ Eliminar un elemento de una colección CMS de Webflow | Parámetro | Tipo | Obligatorio | Descripción | | --------- | ---- | -------- | ----------- | +| `siteId` | string | Sí | ID del sitio de Webflow | | `collectionId` | string | Sí | ID de la colección | | `itemId` | string | Sí | ID del elemento a eliminar | diff --git a/apps/docs/content/docs/fr/tools/webflow.mdx b/apps/docs/content/docs/fr/tools/webflow.mdx index 7765b00526..ef8bf09173 100644 --- a/apps/docs/content/docs/fr/tools/webflow.mdx +++ b/apps/docs/content/docs/fr/tools/webflow.mdx @@ -38,7 +38,8 @@ Lister tous les éléments d'une collection CMS Webflow #### Entrée | Paramètre | Type | Obligatoire | Description | -| --------- | ---- | -------- | ----------- | +| --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `offset` | number | Non | Décalage pour la pagination \(facultatif\) | | `limit` | number | Non | Nombre maximum d'éléments à retourner \(facultatif, par défaut : 100\) | @@ -57,7 +58,8 @@ Obtenir un seul élément d'une collection CMS Webflow #### Entrée | Paramètre | Type | Obligatoire | Description | -| --------- | ---- | -------- | ----------- | +| --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `itemId` | string | Oui | ID de l'élément à récupérer | @@ -76,8 +78,9 @@ Créer un nouvel élément dans une collection CMS Webflow | Paramètre | Type | Obligatoire | Description | | --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | -| `fieldData` | json | Oui | Données de champ pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. | +| `fieldData` | json | Oui | Données des champs pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. | #### Sortie @@ -94,9 +97,10 @@ Mettre à jour un élément existant dans une collection CMS Webflow | Paramètre | Type | Obligatoire | Description | | --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `itemId` | string | Oui | ID de l'élément à mettre à jour | -| `fieldData` | json | Oui | Données de champ à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. | +| `fieldData` | json | Oui | Données des champs à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. | #### Sortie @@ -113,6 +117,7 @@ Supprimer un élément d'une collection CMS Webflow | Paramètre | Type | Obligatoire | Description | | --------- | ---- | ---------- | ----------- | +| `siteId` | string | Oui | ID du site Webflow | | `collectionId` | string | Oui | ID de la collection | | `itemId` | string | Oui | ID de l'élément à supprimer | diff --git a/apps/docs/content/docs/ja/tools/webflow.mdx b/apps/docs/content/docs/ja/tools/webflow.mdx index 83ff2c3849..e44c62a95e 100644 --- a/apps/docs/content/docs/ja/tools/webflow.mdx +++ b/apps/docs/content/docs/ja/tools/webflow.mdx @@ -39,9 +39,10 @@ Webflow CMSコレクションからすべてのアイテムを一覧表示する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `offset` | number | いいえ | ページネーション用のオフセット(オプション) | -| `limit` | number | いいえ | 返すアイテムの最大数(オプション、デフォルト:100) | +| `limit` | number | いいえ | 返す最大アイテム数(オプション、デフォルト:100) | #### 出力 @@ -58,6 +59,7 @@ Webflow CMSコレクションから単一のアイテムを取得する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `itemId` | string | はい | 取得するアイテムのID | @@ -76,8 +78,9 @@ Webflow CMSコレクションに新しいアイテムを作成する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | -| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト形式)。キーはコレクションのフィールド名と一致する必要があります。 | +| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト)。キーはコレクションフィールド名と一致する必要があります。 | #### 出力 @@ -94,9 +97,10 @@ Webflow CMSコレクション内の既存アイテムを更新する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `itemId` | string | はい | 更新するアイテムのID | -| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト形式)。変更したいフィールドのみを含めてください。 | +| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト)。変更したいフィールドのみを含めてください。 | #### 出力 @@ -113,6 +117,7 @@ Webflow CMSコレクションからアイテムを削除する | パラメータ | 型 | 必須 | 説明 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | はい | WebflowサイトのID | | `collectionId` | string | はい | コレクションのID | | `itemId` | string | はい | 削除するアイテムのID | diff --git a/apps/docs/content/docs/zh/tools/webflow.mdx b/apps/docs/content/docs/zh/tools/webflow.mdx index 28f569690a..ac405f6732 100644 --- a/apps/docs/content/docs/zh/tools/webflow.mdx +++ b/apps/docs/content/docs/zh/tools/webflow.mdx @@ -38,9 +38,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | -| `offset` | number | 否 | 分页偏移量(可选) | -| `limit` | number | 否 | 返回的最大项目数(可选,默认值:100) | +| `offset` | number | 否 | 分页的偏移量(可选) | +| `limit` | number | 否 | 要返回的最大项目数(可选,默认值:100) | #### 输出 @@ -57,8 +58,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | -| `itemId` | string | 是 | 要检索的项目 ID | +| `itemId` | string | 是 | 要检索项目的 ID | #### 输出 @@ -75,8 +77,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | -| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名称匹配。 | +| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 | #### 输出 @@ -93,9 +96,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | | `itemId` | string | 是 | 要更新项目的 ID | -| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含您想更改的字段。 | +| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含需要更改的字段。 | #### 输出 @@ -112,6 +116,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必需 | 描述 | | --------- | ---- | -------- | ----------- | +| `siteId` | string | 是 | Webflow 网站的 ID | | `collectionId` | string | 是 | 集合的 ID | | `itemId` | string | 是 | 要删除项目的 ID | diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index 5cf9a4ed6a..dca4eab3fb 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -5973,31 +5973,31 @@ checksums: content/9: 5914baadfaf2ca26d54130a36dd5ed29 content/10: 25507380ac7d9c7f8cf9f5256c6a0dbb content/11: 371d0e46b4bd2c23f559b8bc112f6955 - content/12: e7fb612c3323c1e6b05eacfcea360d34 + content/12: e034523b05e8c7bd1723ef0ba96c5332 content/13: bcadfc362b69078beee0088e5936c98b content/14: e5f830d6049ff79a318110098e5e0130 content/15: 711e90714806b91f93923018e82ad2e9 content/16: 0f3f7d9699d7397cb3a094c3229329ee content/17: 371d0e46b4bd2c23f559b8bc112f6955 - content/18: c53b5b8f901066e63fe159ad2fa5e6e0 + content/18: 4b0c581b30f4449b0bfa3cdd4af69e02 content/19: bcadfc362b69078beee0088e5936c98b content/20: 5f2afdd49c3ac13381401c69d1eca22a content/21: cc4baa9096fafa4c6276f6136412ba66 content/22: 676f76e8a7154a576d7fa20b245cef70 content/23: 371d0e46b4bd2c23f559b8bc112f6955 - content/24: c67c387eb7e274ee7c07b7e1748afce1 + content/24: d26dd24c5398fd036d1f464ba3789002 content/25: bcadfc362b69078beee0088e5936c98b content/26: a6ffebda549ad5b903a66c7d9ac03a20 content/27: 0dadd51cde48d6ea75b29ec3ee4ade56 content/28: cdc74f6483a0b4e9933ecdd92ed7480f content/29: 371d0e46b4bd2c23f559b8bc112f6955 - content/30: 4cda10aa374e1a46d60ad14eeaa79100 + content/30: cec3953ee52d1d3c8b1a495f9684d35b content/31: bcadfc362b69078beee0088e5936c98b content/32: 5f221421953a0e760ead7388cbf66561 content/33: a3c0372590cef72d5d983dbc8dbbc2cb content/34: 1402e53c08bdd8a741f44b2d66fcd003 content/35: 371d0e46b4bd2c23f559b8bc112f6955 - content/36: 028e579a28e55def4fbc59f39f4610b7 + content/36: db921b05a9e5ddceb28a4f3f1af2a377 content/37: bcadfc362b69078beee0088e5936c98b content/38: 4fe4260da2f137679ce2fa42cffcf56a content/39: b3f310d5ef115bea5a8b75bf25d7ea9a From 431f2069302e07d46307fb1ead293c0189ab822e Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 13 Dec 2025 19:40:33 -0800 Subject: [PATCH 14/14] fix(tools): add validation for ids in tool routes (#2371) --- apps/sim/app/api/proxy/tts/unified/route.ts | 5 +++++ apps/sim/app/api/tools/discord/send-message/route.ts | 12 ++++++++++++ apps/sim/app/api/tools/webflow/collections/route.ts | 8 +++++--- apps/sim/app/api/tools/webflow/items/route.ts | 8 +++++--- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/proxy/tts/unified/route.ts b/apps/sim/app/api/proxy/tts/unified/route.ts index 9937a513a3..827dfae61c 100644 --- a/apps/sim/app/api/proxy/tts/unified/route.ts +++ b/apps/sim/app/api/proxy/tts/unified/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { StorageService } from '@/lib/uploads' @@ -147,6 +148,10 @@ export async function POST(request: NextRequest) { { status: 400 } ) } + const voiceIdValidation = validateAlphanumericId(body.voiceId, 'voiceId') + if (!voiceIdValidation.isValid) { + return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 }) + } const result = await synthesizeWithElevenLabs({ text, apiKey, diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 205149a7df..ef6df171dc 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' @@ -41,6 +42,17 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = DiscordSendMessageSchema.parse(body) + const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId') + if (!channelIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid channelId format`, { + error: channelIdValidation.error, + }) + return NextResponse.json( + { success: false, error: channelIdValidation.error }, + { status: 400 } + ) + } + logger.info(`[${requestId}] Sending Discord message`, { channelId: validatedData.channelId, hasFiles: !!(validatedData.files && validatedData.files.length > 0), diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts index 2aa17de0da..31ec540615 100644 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ b/apps/sim/app/api/tools/webflow/collections/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -19,9 +20,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - if (!siteId) { - logger.error('Missing siteId in request') - return NextResponse.json({ error: 'Site ID is required' }, { status: 400 }) + const siteIdValidation = validateAlphanumericId(siteId, 'siteId') + if (!siteIdValidation.isValid) { + logger.error('Invalid siteId', { error: siteIdValidation.error }) + return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) } const authz = await authorizeCredentialUse(request as any, { diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts index 8639c63e35..95acc644d7 100644 --- a/apps/sim/app/api/tools/webflow/items/route.ts +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -19,9 +20,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - if (!collectionId) { - logger.error('Missing collectionId in request') - return NextResponse.json({ error: 'Collection ID is required' }, { status: 400 }) + const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId') + if (!collectionIdValidation.isValid) { + logger.error('Invalid collectionId', { error: collectionIdValidation.error }) + return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 }) } const authz = await authorizeCredentialUse(request as any, {