From 273d4cda2ec667dba12ae2450e6886d1e2df1642 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Thu, 13 Nov 2025 21:28:49 -0800 Subject: [PATCH 01/12] first push --- apps/sim/app/api/auth/oauth/utils.ts | 31 +- .../app/api/auth/snowflake/authorize/route.ts | 101 ++++ .../app/api/auth/snowflake/callback/route.ts | 204 ++++++++ .../components/oauth-required-modal.tsx | 21 + apps/sim/blocks/blocks/snowflake.ts | 439 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 15 + apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/oauth/oauth.ts | 51 +- apps/sim/lib/oauth/pkce.ts | 30 ++ apps/sim/tools/registry.ts | 20 + apps/sim/tools/snowflake/describe_table.ts | 131 ++++++ apps/sim/tools/snowflake/execute_query.ts | 155 +++++++ apps/sim/tools/snowflake/index.ts | 21 + apps/sim/tools/snowflake/list_databases.ts | 113 +++++ apps/sim/tools/snowflake/list_file_formats.ts | 125 +++++ apps/sim/tools/snowflake/list_schemas.ts | 119 +++++ apps/sim/tools/snowflake/list_stages.ts | 125 +++++ apps/sim/tools/snowflake/list_tables.ts | 125 +++++ apps/sim/tools/snowflake/list_views.ts | 122 +++++ apps/sim/tools/snowflake/list_warehouses.ts | 113 +++++ apps/sim/tools/snowflake/types.ts | 262 +++++++++++ apps/sim/tools/snowflake/utils.ts | 142 ++++++ 23 files changed, 2463 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/api/auth/snowflake/authorize/route.ts create mode 100644 apps/sim/app/api/auth/snowflake/callback/route.ts create mode 100644 apps/sim/blocks/blocks/snowflake.ts create mode 100644 apps/sim/lib/oauth/pkce.ts create mode 100644 apps/sim/tools/snowflake/describe_table.ts create mode 100644 apps/sim/tools/snowflake/execute_query.ts create mode 100644 apps/sim/tools/snowflake/index.ts create mode 100644 apps/sim/tools/snowflake/list_databases.ts create mode 100644 apps/sim/tools/snowflake/list_file_formats.ts create mode 100644 apps/sim/tools/snowflake/list_schemas.ts create mode 100644 apps/sim/tools/snowflake/list_stages.ts create mode 100644 apps/sim/tools/snowflake/list_tables.ts create mode 100644 apps/sim/tools/snowflake/list_views.ts create mode 100644 apps/sim/tools/snowflake/list_warehouses.ts create mode 100644 apps/sim/tools/snowflake/types.ts create mode 100644 apps/sim/tools/snowflake/utils.ts diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 7a09891b57..5713e288f1 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -67,6 +67,8 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessToken: account.accessToken, refreshToken: account.refreshToken, accessTokenExpiresAt: account.accessTokenExpiresAt, + accountId: account.accountId, + providerId: account.providerId, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -93,8 +95,14 @@ export async function getOAuthToken(userId: string, providerId: string): Promise ) try { + // Extract account URL from accountId for Snowflake + let metadata: { accountUrl?: string } | undefined + if (providerId === 'snowflake' && credential.accountId) { + metadata = { accountUrl: credential.accountId } + } + // Use the existing refreshOAuthToken function - const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!) + const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!, metadata) if (!refreshResult) { logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, { @@ -177,9 +185,16 @@ export async function refreshAccessTokenIfNeeded( if (shouldRefresh) { logger.info(`[${requestId}] Token expired, attempting to refresh for credential`) try { + // Extract account URL from accountId for Snowflake + let metadata: { accountUrl?: string } | undefined + if (credential.providerId === 'snowflake' && credential.accountId) { + metadata = { accountUrl: credential.accountId } + } + const refreshedToken = await refreshOAuthToken( credential.providerId, - credential.refreshToken! + credential.refreshToken!, + metadata ) if (!refreshedToken) { @@ -251,7 +266,17 @@ export async function refreshTokenIfNeeded( } try { - const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!) + // Extract account URL from accountId for Snowflake + let metadata: { accountUrl?: string } | undefined + if (credential.providerId === 'snowflake' && credential.accountId) { + metadata = { accountUrl: credential.accountId } + } + + const refreshResult = await refreshOAuthToken( + credential.providerId, + credential.refreshToken!, + metadata + ) if (!refreshResult) { logger.error(`[${requestId}] Failed to refresh token for credential`) diff --git a/apps/sim/app/api/auth/snowflake/authorize/route.ts b/apps/sim/app/api/auth/snowflake/authorize/route.ts new file mode 100644 index 0000000000..b2a0885827 --- /dev/null +++ b/apps/sim/app/api/auth/snowflake/authorize/route.ts @@ -0,0 +1,101 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce' +import { getBaseUrl } from '@/lib/urls/utils' + +const logger = createLogger('SnowflakeAuthorize') + +export const dynamic = 'force-dynamic' + +/** + * Initiates Snowflake OAuth flow + * Requires accountUrl as query parameter + */ +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn('Unauthorized Snowflake OAuth attempt') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const accountUrl = searchParams.get('accountUrl') + + if (!accountUrl) { + logger.error('Missing accountUrl parameter') + return NextResponse.json({ error: 'accountUrl parameter is required' }, { status: 400 }) + } + + const clientId = env.SNOWFLAKE_CLIENT_ID + const clientSecret = env.SNOWFLAKE_CLIENT_SECRET + + if (!clientId || !clientSecret) { + logger.error('Snowflake OAuth credentials not configured') + return NextResponse.json({ error: 'Snowflake OAuth not configured' }, { status: 500 }) + } + + // Parse and clean the account URL + let cleanAccountUrl = accountUrl.replace(/^https?:\/\//, '') + cleanAccountUrl = cleanAccountUrl.replace(/\/$/, '') + if (!cleanAccountUrl.includes('snowflakecomputing.com')) { + cleanAccountUrl = `${cleanAccountUrl}.snowflakecomputing.com` + } + + const baseUrl = getBaseUrl() + const redirectUri = `${baseUrl}/api/auth/snowflake/callback` + + // Generate PKCE values + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + + const state = Buffer.from( + JSON.stringify({ + userId: session.user.id, + accountUrl: cleanAccountUrl, + timestamp: Date.now(), + codeVerifier, + }) + ).toString('base64url') + + // Construct Snowflake-specific authorization URL + const authUrl = new URL(`https://${cleanAccountUrl}/oauth/authorize`) + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('redirect_uri', redirectUri) + // Add scope parameter to specify a safe role (not ACCOUNTADMIN or SECURITYADMIN) + authUrl.searchParams.set('scope', 'refresh_token session:role:PUBLIC') + authUrl.searchParams.set('state', state) + // Add PKCE parameters for security and compatibility with OAUTH_ENFORCE_PKCE + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + + logger.info('Initiating Snowflake OAuth flow (CONFIDENTIAL client with PKCE)', { + userId: session.user.id, + accountUrl: cleanAccountUrl, + authUrl: authUrl.toString(), + redirectUri, + clientId, + hasClientSecret: !!clientSecret, + hasPkce: true, + parametersCount: authUrl.searchParams.toString().length, + }) + + logger.info('Authorization URL parameters:', { + client_id: authUrl.searchParams.get('client_id'), + response_type: authUrl.searchParams.get('response_type'), + redirect_uri: authUrl.searchParams.get('redirect_uri'), + state_length: authUrl.searchParams.get('state')?.length, + scope: authUrl.searchParams.get('scope'), + has_pkce: authUrl.searchParams.has('code_challenge'), + code_challenge_method: authUrl.searchParams.get('code_challenge_method'), + }) + + return NextResponse.redirect(authUrl.toString()) + } catch (error) { + logger.error('Error initiating Snowflake authorization:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/auth/snowflake/callback/route.ts b/apps/sim/app/api/auth/snowflake/callback/route.ts new file mode 100644 index 0000000000..a08425c611 --- /dev/null +++ b/apps/sim/app/api/auth/snowflake/callback/route.ts @@ -0,0 +1,204 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { getBaseUrl } from '@/lib/urls/utils' +import { db } from '@/../../packages/db' +import { account } from '@/../../packages/db/schema' + +const logger = createLogger('SnowflakeCallback') + +export const dynamic = 'force-dynamic' + +/** + * Handles Snowflake OAuth callback + */ +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn('Unauthorized Snowflake OAuth callback') + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=unauthorized`) + } + + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const errorDescription = searchParams.get('error_description') + + // Handle OAuth errors + if (error) { + logger.error('Snowflake OAuth error', { error, errorDescription }) + return NextResponse.redirect( + `${getBaseUrl()}/workspace?error=snowflake_${error}&description=${encodeURIComponent(errorDescription || '')}` + ) + } + + if (!code || !state) { + logger.error('Missing code or state in callback') + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_callback`) + } + + // Decode state to get account URL and code verifier + let stateData: { + userId: string + accountUrl: string + timestamp: number + codeVerifier: string + } + + try { + stateData = JSON.parse(Buffer.from(state, 'base64url').toString()) + logger.info('Decoded state successfully', { + userId: stateData.userId, + accountUrl: stateData.accountUrl, + age: Date.now() - stateData.timestamp, + hasCodeVerifier: !!stateData.codeVerifier, + }) + } catch (e) { + logger.error('Invalid state parameter', { error: e, state }) + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_state`) + } + + // Verify the user matches + if (stateData.userId !== session.user.id) { + logger.error('User ID mismatch in state', { + stateUserId: stateData.userId, + sessionUserId: session.user.id, + }) + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_user_mismatch`) + } + + // Verify state is not too old (15 minutes) + if (Date.now() - stateData.timestamp > 15 * 60 * 1000) { + logger.error('State expired', { + age: Date.now() - stateData.timestamp, + }) + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_state_expired`) + } + + const clientId = env.SNOWFLAKE_CLIENT_ID + const clientSecret = env.SNOWFLAKE_CLIENT_SECRET + + if (!clientId || !clientSecret) { + logger.error('Snowflake OAuth credentials not configured') + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_not_configured`) + } + + // Exchange authorization code for tokens + const tokenUrl = `https://${stateData.accountUrl}/oauth/token-request` + const redirectUri = `${getBaseUrl()}/api/auth/snowflake/callback` + + const tokenParams = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + code_verifier: stateData.codeVerifier, + }) + + logger.info('Exchanging authorization code for tokens (with PKCE)', { + tokenUrl, + redirectUri, + clientId, + hasCode: !!code, + hasClientSecret: !!clientSecret, + hasCodeVerifier: !!stateData.codeVerifier, + paramsLength: tokenParams.toString().length, + }) + + const tokenResponse = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: tokenParams.toString(), + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + logger.error('Failed to exchange code for token', { + status: tokenResponse.status, + statusText: tokenResponse.statusText, + error: errorText, + tokenUrl, + redirectUri, + }) + + // Try to parse error as JSON for better diagnostics + try { + const errorJson = JSON.parse(errorText) + logger.error('Snowflake error details:', errorJson) + } catch (e) { + logger.error('Error text (not JSON):', errorText) + } + + return NextResponse.redirect( + `${getBaseUrl()}/workspace?error=snowflake_token_exchange_failed&details=${encodeURIComponent(errorText)}` + ) + } + + const tokens = await tokenResponse.json() + + logger.info('Token exchange for Snowflake successful', { + hasAccessToken: !!tokens.access_token, + hasRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }) + + if (!tokens.access_token) { + logger.error('No access token in response', { tokens }) + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_no_access_token`) + } + + // Store the account and tokens in the database + const existing = await db.query.account.findFirst({ + where: and(eq(account.userId, session.user.id), eq(account.providerId, 'snowflake')), + }) + + const now = new Date() + const expiresAt = tokens.expires_in + ? new Date(now.getTime() + tokens.expires_in * 1000) + : new Date(now.getTime() + 10 * 60 * 1000) // Default 10 minutes + + const accountData = { + userId: session.user.id, + providerId: 'snowflake', + accountId: stateData.accountUrl, // Store the Snowflake account URL here + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + accessTokenExpiresAt: expiresAt, + scope: tokens.scope || null, + updatedAt: now, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + + logger.info('Updated existing Snowflake account', { + userId: session.user.id, + accountUrl: stateData.accountUrl, + }) + } else { + await db.insert(account).values({ + ...accountData, + id: `snowflake_${session.user.id}_${Date.now()}`, + createdAt: now, + }) + + logger.info('Created new Snowflake account', { + userId: session.user.id, + accountUrl: stateData.accountUrl, + }) + } + + return NextResponse.redirect(`${getBaseUrl()}/workspace?snowflake_connected=true`) + } catch (error) { + logger.error('Error in Snowflake callback:', error) + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_callback_failed`) + } +} 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 9d5eec3f5b..cdcc217182 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 @@ -1,5 +1,6 @@ 'use client' +import { useState } from 'react' import { Check } from 'lucide-react' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' import { client } from '@/lib/auth/auth-client' @@ -271,6 +272,9 @@ export function OAuthRequiredModal({ serviceId, newScopes = [], }: OAuthRequiredModalProps) { + const [snowflakeAccountUrl, setSnowflakeAccountUrl] = useState('') + const [accountUrlError, setAccountUrlError] = useState('') + const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -301,6 +305,23 @@ export function OAuthRequiredModal({ try { const providerId = getProviderIdFromServiceId(effectiveServiceId) + // Special handling for Snowflake - requires account URL + if (providerId === 'snowflake') { + if (!snowflakeAccountUrl.trim()) { + setAccountUrlError('Account URL is required') + return + } + + onClose() + + logger.info('Initiating Snowflake OAuth:', { + accountUrl: snowflakeAccountUrl, + }) + + window.location.href = `/api/auth/snowflake/authorize?accountUrl=${encodeURIComponent(snowflakeAccountUrl)}` + return + } + onClose() logger.info('Linking OAuth2:', { diff --git a/apps/sim/blocks/blocks/snowflake.ts b/apps/sim/blocks/blocks/snowflake.ts new file mode 100644 index 0000000000..3143cd3f51 --- /dev/null +++ b/apps/sim/blocks/blocks/snowflake.ts @@ -0,0 +1,439 @@ +import { SnowflakeIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import type { SnowflakeResponse } from '@/tools/snowflake/types' + +export const SnowflakeBlock: BlockConfig = { + type: 'snowflake', + name: 'Snowflake', + description: 'Execute queries on Snowflake data warehouse', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate Snowflake into your workflow. Execute SQL queries, list databases, schemas, and tables, and describe table structures in your Snowflake data warehouse.', + docsLink: 'https://docs.sim.ai/tools/snowflake', + category: 'tools', + bgColor: '#E0E0E0', + icon: SnowflakeIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Execute Query', id: 'execute_query' }, + { label: 'List Databases', id: 'list_databases' }, + { label: 'List Schemas', id: 'list_schemas' }, + { label: 'List Tables', id: 'list_tables' }, + { label: 'List Views', id: 'list_views' }, + { label: 'List Warehouses', id: 'list_warehouses' }, + { label: 'List File Formats', id: 'list_file_formats' }, + { label: 'List Stages', id: 'list_stages' }, + { label: 'Describe Table', id: 'describe_table' }, + ], + value: () => 'execute_query', + }, + { + id: 'credential', + title: 'Snowflake Account', + type: 'oauth-input', + provider: 'snowflake', + serviceId: 'snowflake', + requiredScopes: [], + placeholder: 'Select Snowflake account', + required: true, + }, + { + id: 'accountUrl', + title: 'Account URL', + type: 'short-input', + placeholder: 'your-account.snowflakecomputing.com', + required: true, + }, + { + id: 'warehouse', + title: 'Warehouse', + type: 'short-input', + placeholder: 'Warehouse name', + }, + { + id: 'role', + title: 'Role', + type: 'short-input', + placeholder: 'Role name', + }, + { + id: 'query', + title: 'SQL Query', + type: 'long-input', + required: true, + placeholder: 'Enter SQL query (e.g., SELECT * FROM database.schema.table LIMIT 10)', + condition: { + field: 'operation', + value: 'execute_query', + }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert Snowflake SQL developer. Generate Snowflake SQL queries based on the user's natural language request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the SQL query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw SQL query that can be executed directly in Snowflake. + +### SNOWFLAKE SQL GUIDELINES +1. **Syntax**: Use standard Snowflake SQL syntax and functions +2. **Fully Qualified Names**: Use database.schema.table format when possible +3. **Case Sensitivity**: Identifiers are case-insensitive unless quoted +4. **Performance**: Consider using LIMIT clauses for large datasets +5. **Data Types**: Use appropriate Snowflake data types (VARIANT for JSON, TIMESTAMP_NTZ, etc.) + +### COMMON SNOWFLAKE SQL PATTERNS + +**Basic SELECT**: +SELECT * FROM database.schema.table LIMIT 100; + +**Filtered Query**: +SELECT column1, column2 +FROM database.schema.table +WHERE status = 'active' + AND created_at > DATEADD(day, -7, CURRENT_DATE()) +LIMIT 100; + +**Aggregate Functions**: +SELECT + category, + COUNT(*) as total_count, + AVG(amount) as avg_amount, + SUM(amount) as total_amount +FROM database.schema.sales +GROUP BY category +ORDER BY total_amount DESC; + +**JOIN Operations**: +SELECT + u.user_id, + u.name, + o.order_id, + o.total +FROM database.schema.users u +INNER JOIN database.schema.orders o + ON u.user_id = o.user_id +WHERE o.created_at > CURRENT_DATE() - 30; + +**Window Functions**: +SELECT + user_id, + order_date, + amount, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) as row_num +FROM database.schema.orders; + +**JSON/VARIANT Queries**: +SELECT + id, + json_data:field::STRING as field_value, + json_data:nested.value::NUMBER as nested_value +FROM database.schema.json_table +WHERE json_data:status::STRING = 'active'; + +**FLATTEN for Arrays**: +SELECT + id, + f.value::STRING as array_item +FROM database.schema.table, +LATERAL FLATTEN(input => array_column) f; + +**CTE (Common Table Expression)**: +WITH active_users AS ( + SELECT user_id, name + FROM database.schema.users + WHERE status = 'active' +) +SELECT + au.name, + COUNT(o.order_id) as order_count +FROM active_users au +LEFT JOIN database.schema.orders o ON au.user_id = o.user_id +GROUP BY au.name; + +**Date/Time Functions**: +SELECT + DATE_TRUNC('month', order_date) as month, + COUNT(*) as orders +FROM database.schema.orders +WHERE order_date >= DATEADD(year, -1, CURRENT_DATE()) +GROUP BY month +ORDER BY month DESC; + +**INSERT Statement**: +INSERT INTO database.schema.table (column1, column2, column3) +VALUES ('value1', 123, CURRENT_TIMESTAMP()); + +**UPDATE Statement**: +UPDATE database.schema.table +SET status = 'processed', updated_at = CURRENT_TIMESTAMP() +WHERE id = 123; + +**DELETE Statement**: +DELETE FROM database.schema.table +WHERE created_at < DATEADD(year, -2, CURRENT_DATE()); + +**MERGE Statement (Upsert)**: +MERGE INTO database.schema.target t +USING database.schema.source s +ON t.id = s.id +WHEN MATCHED THEN + UPDATE SET t.value = s.value, t.updated_at = CURRENT_TIMESTAMP() +WHEN NOT MATCHED THEN + INSERT (id, value, created_at) VALUES (s.id, s.value, CURRENT_TIMESTAMP()); + +### SNOWFLAKE SPECIFIC FEATURES + +**SAMPLE Clause** (for testing with large tables): +SELECT * FROM database.schema.large_table SAMPLE (1000 ROWS); + +**QUALIFY Clause** (filter window functions): +SELECT + user_id, + order_date, + amount +FROM database.schema.orders +QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) = 1; + +**Time Travel**: +SELECT * FROM database.schema.table AT (TIMESTAMP => '2024-01-01 00:00:00'::TIMESTAMP); + +### BEST PRACTICES +1. Always use LIMIT when exploring data +2. Use WHERE clauses to filter data efficiently +3. Index commonly queried columns +4. Use appropriate date functions (DATEADD, DATE_TRUNC, DATEDIFF) +5. For JSON data, use proper casting (::STRING, ::NUMBER, etc.) +6. Use CTEs for complex queries to improve readability + +### REMEMBER +Return ONLY the SQL query - no explanations, no markdown code blocks, no extra text. The query should be ready to execute.`, + placeholder: + 'Describe the SQL query you need (e.g., "Get all orders from the last 7 days with customer names")...', + generationType: 'sql-query', + }, + }, + { + id: 'database', + title: 'Database', + type: 'short-input', + placeholder: 'Database name', + required: true, + condition: { + field: 'operation', + value: [ + 'list_schemas', + 'list_tables', + 'list_views', + 'list_file_formats', + 'list_stages', + 'describe_table', + ], + }, + }, + { + id: 'schema', + title: 'Schema', + type: 'short-input', + placeholder: 'Schema name', + required: true, + condition: { + field: 'operation', + value: ['list_tables', 'list_views', 'list_file_formats', 'list_stages', 'describe_table'], + }, + }, + { + id: 'table', + title: 'Table', + type: 'short-input', + placeholder: 'Table name', + required: true, + condition: { + field: 'operation', + value: 'describe_table', + }, + }, + { + id: 'timeout', + title: 'Timeout (seconds)', + type: 'short-input', + placeholder: '60', + condition: { + field: 'operation', + value: 'execute_query', + }, + }, + ], + tools: { + access: [ + 'snowflake_execute_query', + 'snowflake_list_databases', + 'snowflake_list_schemas', + 'snowflake_list_tables', + 'snowflake_list_views', + 'snowflake_list_warehouses', + 'snowflake_list_file_formats', + 'snowflake_list_stages', + 'snowflake_describe_table', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'execute_query': + return 'snowflake_execute_query' + case 'list_databases': + return 'snowflake_list_databases' + case 'list_schemas': + return 'snowflake_list_schemas' + case 'list_tables': + return 'snowflake_list_tables' + case 'list_views': + return 'snowflake_list_views' + case 'list_warehouses': + return 'snowflake_list_warehouses' + case 'list_file_formats': + return 'snowflake_list_file_formats' + case 'list_stages': + return 'snowflake_list_stages' + case 'describe_table': + return 'snowflake_describe_table' + default: + throw new Error(`Unknown operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, operation, ...rest } = params + + // Build base params + const baseParams: Record = { + credential, + accountUrl: params.accountUrl, + } + + // Add optional warehouse and role if provided + if (params.warehouse) { + baseParams.warehouse = params.warehouse + } + + if (params.role) { + baseParams.role = params.role + } + + // Operation-specific params + switch (operation) { + case 'execute_query': { + if (!params.query) { + throw new Error('Query is required for execute_query operation') + } + baseParams.query = params.query + if (params.database) baseParams.database = params.database + if (params.schema) baseParams.schema = params.schema + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + case 'list_databases': { + // No additional params needed + break + } + + case 'list_schemas': { + if (!params.database) { + throw new Error('Database is required for list_schemas operation') + } + baseParams.database = params.database + break + } + + case 'list_tables': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_tables operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'list_views': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_views operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'list_warehouses': { + // No additional params needed + break + } + + case 'list_file_formats': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_file_formats operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'list_stages': { + if (!params.database || !params.schema) { + throw new Error('Database and Schema are required for list_stages operation') + } + baseParams.database = params.database + baseParams.schema = params.schema + break + } + + case 'describe_table': { + if (!params.database || !params.schema || !params.table) { + throw new Error( + 'Database, Schema, and Table are required for describe_table operation' + ) + } + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + break + } + + default: + throw new Error(`Unknown operation: ${operation}`) + } + + return baseParams + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + credential: { type: 'string', description: 'Snowflake OAuth credential' }, + accountUrl: { + type: 'string', + description: 'Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + warehouse: { type: 'string', description: 'Warehouse name' }, + role: { type: 'string', description: 'Role name' }, + query: { type: 'string', description: 'SQL query to execute' }, + database: { type: 'string', description: 'Database name' }, + schema: { type: 'string', description: 'Schema name' }, + table: { type: 'string', description: 'Table name' }, + timeout: { type: 'string', description: 'Query timeout in seconds' }, + }, + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + output: { + type: 'json', + description: + 'Operation results containing query data, databases, schemas, tables, or column definitions based on the selected operation', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 40b5ad6aad..68abb6be26 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -97,6 +97,7 @@ import { SharepointBlock } from '@/blocks/blocks/sharepoint' import { ShopifyBlock } from '@/blocks/blocks/shopify' import { SlackBlock } from '@/blocks/blocks/slack' import { SmtpBlock } from '@/blocks/blocks/smtp' +import { SnowflakeBlock } from '@/blocks/blocks/snowflake' import { SSHBlock } from '@/blocks/blocks/ssh' import { StagehandBlock } from '@/blocks/blocks/stagehand' import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent' @@ -234,6 +235,7 @@ export const registry: Record = { shopify: ShopifyBlock, slack: SlackBlock, smtp: SmtpBlock, + snowflake: SnowflakeBlock, ssh: SSHBlock, stagehand: StagehandBlock, stagehand_agent: StagehandAgentBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index d6dc0ae4d2..e6ee4433f6 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4089,3 +4089,18 @@ export function PolymarketIcon(props: SVGProps) { ) } + +export function SnowflakeIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ac5ec6ffe0..cc04f59519 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -221,6 +221,8 @@ export const env = createEnv({ REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID WEBFLOW_CLIENT_SECRET: z.string().optional(), // Webflow OAuth client secret + SNOWFLAKE_CLIENT_ID: z.string().optional(), // Snowflake OAuth client ID + SNOWFLAKE_CLIENT_SECRET: z.string().optional(), // Snowflake OAuth client secret TRELLO_API_KEY: z.string().optional(), // Trello API Key LINKEDIN_CLIENT_ID: z.string().optional(), // LinkedIn OAuth client ID LINKEDIN_CLIENT_SECRET: z.string().optional(), // LinkedIn OAuth client secret diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index bfd86227d1..1f1f3b21ed 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -30,7 +30,7 @@ import { SalesforceIcon, ShopifyIcon, SlackIcon, - // SupabaseIcon, + SnowflakeIcon, TrelloIcon, WealthboxIcon, WebflowIcon, @@ -69,6 +69,7 @@ export type OAuthProvider = | 'shopify' | 'zoom' | 'wordpress' + | 'snowflake' | string export type OAuthService = @@ -109,6 +110,7 @@ export type OAuthService = | 'shopify' | 'zoom' | 'wordpress' + | 'snowflake' export interface OAuthProviderConfig { id: OAuthProvider name: string @@ -830,6 +832,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'salesforce', }, + snowflake: { + id: 'snowflake', + name: 'Snowflake', + icon: (props) => SnowflakeIcon(props), + services: { + snowflake: { + id: 'snowflake', + name: 'Snowflake', + description: 'Execute queries and manage data in your Snowflake data warehouse.', + providerId: 'snowflake', + icon: (props) => SnowflakeIcon(props), + baseProviderIcon: (props) => SnowflakeIcon(props), + scopes: [], + }, + }, + defaultService: 'snowflake', + }, zoom: { id: 'zoom', name: 'Zoom', @@ -1415,6 +1434,21 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } + case 'snowflake': { + const { clientId, clientSecret } = getCredentials( + env.SNOWFLAKE_CLIENT_ID, + env.SNOWFLAKE_CLIENT_SECRET + ) + // Note: For Snowflake, the tokenEndpoint is account-specific + // The actual URL will be constructed dynamically in refreshOAuthToken + return { + tokenEndpoint: 'https://placeholder.snowflakecomputing.com/oauth/token-request', + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + } + } case 'shopify': { // Shopify access tokens don't expire and don't support refresh tokens // This configuration is provided for completeness but won't be used for token refresh @@ -1495,11 +1529,13 @@ function buildAuthRequest( * This is a server-side utility function to refresh OAuth tokens * @param providerId The provider ID (e.g., 'google-drive') * @param refreshToken The refresh token to use + * @param metadata Optional metadata (e.g., accountUrl for Snowflake) * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ export async function refreshOAuthToken( providerId: string, - refreshToken: string + refreshToken: string, + metadata?: { accountUrl?: string } ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { // Get the provider from the providerId (e.g., 'google-drive' -> 'google') @@ -1508,11 +1544,20 @@ export async function refreshOAuthToken( // Get provider configuration const config = getProviderAuthConfig(provider) + // For Snowflake, use the account-specific token endpoint + let tokenEndpoint = config.tokenEndpoint + if (provider === 'snowflake' && metadata?.accountUrl) { + tokenEndpoint = `https://${metadata.accountUrl}/oauth/token-request` + logger.info('Using Snowflake account-specific token endpoint', { + accountUrl: metadata.accountUrl, + }) + } + // Build authentication request const { headers, bodyParams } = buildAuthRequest(config, refreshToken) // Refresh the token - const response = await fetch(config.tokenEndpoint, { + const response = await fetch(tokenEndpoint, { method: 'POST', headers, body: new URLSearchParams(bodyParams).toString(), diff --git a/apps/sim/lib/oauth/pkce.ts b/apps/sim/lib/oauth/pkce.ts new file mode 100644 index 0000000000..fdbe60803d --- /dev/null +++ b/apps/sim/lib/oauth/pkce.ts @@ -0,0 +1,30 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('PKCE') + +/** + * Generate a cryptographically secure random string for PKCE code verifier + */ +export function generateCodeVerifier(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return base64URLEncode(array) +} + +/** + * Generate PKCE code challenge from verifier using SHA-256 + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const hash = await crypto.subtle.digest('SHA-256', data) + return base64URLEncode(new Uint8Array(hash)) +} + +/** + * Base64URL encode without padding + */ +function base64URLEncode(buffer: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...buffer)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2433ae86df..cd30397456 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1010,6 +1010,17 @@ import { } from '@/tools/slack' import { smsSendTool } from '@/tools/sms' import { smtpSendMailTool } from '@/tools/smtp' +import { + snowflakeDescribeTableTool, + snowflakeExecuteQueryTool, + snowflakeListDatabasesTool, + snowflakeListFileFormatsTool, + snowflakeListSchemasTool, + snowflakeListStagesTool, + snowflakeListTablesTool, + snowflakeListViewsTool, + snowflakeListWarehousesTool, +} from '@/tools/snowflake' import { checkCommandExistsTool as sshCheckCommandExistsTool, checkFileExistsTool as sshCheckFileExistsTool, @@ -2425,4 +2436,13 @@ export const tools: Record = { zoom_get_meeting_recordings: zoomGetMeetingRecordingsTool, zoom_delete_recording: zoomDeleteRecordingTool, zoom_list_past_participants: zoomListPastParticipantsTool, + snowflake_execute_query: snowflakeExecuteQueryTool, + snowflake_list_databases: snowflakeListDatabasesTool, + snowflake_list_schemas: snowflakeListSchemasTool, + snowflake_list_tables: snowflakeListTablesTool, + snowflake_list_views: snowflakeListViewsTool, + snowflake_list_warehouses: snowflakeListWarehousesTool, + snowflake_list_file_formats: snowflakeListFileFormatsTool, + snowflake_list_stages: snowflakeListStagesTool, + snowflake_describe_table: snowflakeDescribeTableTool, } diff --git a/apps/sim/tools/snowflake/describe_table.ts b/apps/sim/tools/snowflake/describe_table.ts new file mode 100644 index 0000000000..65dc40c036 --- /dev/null +++ b/apps/sim/tools/snowflake/describe_table.ts @@ -0,0 +1,131 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeDescribeTableParams, + SnowflakeDescribeTableResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeDescribeTableTool') + +export const snowflakeDescribeTableTool: ToolConfig< + SnowflakeDescribeTableParams, + SnowflakeDescribeTableResponse +> = { + id: 'snowflake_describe_table', + name: 'Snowflake Describe Table', + description: 'Get the schema and structure of a Snowflake table', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name to describe', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeDescribeTableParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeDescribeTableParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeDescribeTableParams) => { + const requestBody: any = { + statement: `DESCRIBE TABLE ${params.database}.${params.schema}.${params.table}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return JSON.stringify(requestBody) + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeDescribeTableParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to describe Snowflake table', { + status: response.status, + errorText, + }) + throw new Error(`Failed to describe table: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + columns: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Table column definitions and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/execute_query.ts b/apps/sim/tools/snowflake/execute_query.ts new file mode 100644 index 0000000000..7b32261e4b --- /dev/null +++ b/apps/sim/tools/snowflake/execute_query.ts @@ -0,0 +1,155 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeExecuteQueryParams, + SnowflakeExecuteQueryResponse, +} from '@/tools/snowflake/types' +import { + extractColumnMetadata, + extractResponseData, + parseAccountUrl, +} from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeExecuteQueryTool') + +export const snowflakeExecuteQueryTool: ToolConfig< + SnowflakeExecuteQueryParams, + SnowflakeExecuteQueryResponse +> = { + id: 'snowflake_execute_query', + name: 'Snowflake Execute Query', + description: 'Execute a SQL query on your Snowflake data warehouse', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SQL query to execute (SELECT, INSERT, UPDATE, DELETE, etc.)', + }, + database: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Database to use for the query (optional)', + }, + schema: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Schema to use for the query (optional)', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use for query execution (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use for query execution (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeExecuteQueryParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeExecuteQueryParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeExecuteQueryParams) => { + const requestBody: any = { + statement: params.query, + timeout: params.timeout || 60, + } + + if (params.database) { + requestBody.database = params.database + } + + if (params.schema) { + requestBody.schema = params.schema + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return JSON.stringify(requestBody) + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeExecuteQueryParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to execute Snowflake query', { + status: response.status, + errorText, + }) + throw new Error(`Failed to execute query: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + const extractedData = extractResponseData(data) + const columns = extractColumnMetadata(data) + + return { + success: true, + output: { + statementHandle: data.statementHandle, + data: extractedData, + rowCount: extractedData.length, + columns, + message: data.message || 'Query executed successfully', + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Query execution results and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/index.ts b/apps/sim/tools/snowflake/index.ts new file mode 100644 index 0000000000..0e03d22044 --- /dev/null +++ b/apps/sim/tools/snowflake/index.ts @@ -0,0 +1,21 @@ +import { snowflakeDescribeTableTool } from '@/tools/snowflake/describe_table' +import { snowflakeExecuteQueryTool } from '@/tools/snowflake/execute_query' +import { snowflakeListDatabasesTool } from '@/tools/snowflake/list_databases' +import { snowflakeListFileFormatsTool } from '@/tools/snowflake/list_file_formats' +import { snowflakeListSchemasTool } from '@/tools/snowflake/list_schemas' +import { snowflakeListStagesTool } from '@/tools/snowflake/list_stages' +import { snowflakeListTablesTool } from '@/tools/snowflake/list_tables' +import { snowflakeListViewsTool } from '@/tools/snowflake/list_views' +import { snowflakeListWarehousesTool } from '@/tools/snowflake/list_warehouses' + +export { + snowflakeExecuteQueryTool, + snowflakeListDatabasesTool, + snowflakeListSchemasTool, + snowflakeListTablesTool, + snowflakeDescribeTableTool, + snowflakeListViewsTool, + snowflakeListWarehousesTool, + snowflakeListFileFormatsTool, + snowflakeListStagesTool, +} diff --git a/apps/sim/tools/snowflake/list_databases.ts b/apps/sim/tools/snowflake/list_databases.ts new file mode 100644 index 0000000000..ccdb9e121e --- /dev/null +++ b/apps/sim/tools/snowflake/list_databases.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListDatabasesParams, + SnowflakeListDatabasesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListDatabasesTool') + +export const snowflakeListDatabasesTool: ToolConfig< + SnowflakeListDatabasesParams, + SnowflakeListDatabasesResponse +> = { + id: 'snowflake_list_databases', + name: 'Snowflake List Databases', + description: 'List all databases in your Snowflake account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListDatabasesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListDatabasesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListDatabasesParams) => { + const requestBody: any = { + statement: 'SHOW DATABASES', + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return JSON.stringify(requestBody) + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListDatabasesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake databases', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list databases: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + databases: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of databases and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_file_formats.ts b/apps/sim/tools/snowflake/list_file_formats.ts new file mode 100644 index 0000000000..7171c342ee --- /dev/null +++ b/apps/sim/tools/snowflake/list_file_formats.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListFileFormatsParams, + SnowflakeListFileFormatsResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListFileFormatsTool') + +export const snowflakeListFileFormatsTool: ToolConfig< + SnowflakeListFileFormatsParams, + SnowflakeListFileFormatsResponse +> = { + id: 'snowflake_list_file_formats', + name: 'Snowflake List File Formats', + description: 'List all file formats in a Snowflake schema', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list file formats from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListFileFormatsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListFileFormatsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListFileFormatsParams) => { + const requestBody: Record = { + statement: `SHOW FILE FORMATS IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListFileFormatsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake file formats', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list file formats: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + fileFormats: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of file formats and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_schemas.ts b/apps/sim/tools/snowflake/list_schemas.ts new file mode 100644 index 0000000000..6549abdb72 --- /dev/null +++ b/apps/sim/tools/snowflake/list_schemas.ts @@ -0,0 +1,119 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListSchemasParams, + SnowflakeListSchemasResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListSchemasTool') + +export const snowflakeListSchemasTool: ToolConfig< + SnowflakeListSchemasParams, + SnowflakeListSchemasResponse +> = { + id: 'snowflake_list_schemas', + name: 'Snowflake List Schemas', + description: 'List all schemas in a Snowflake database', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to list schemas from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListSchemasParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListSchemasParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListSchemasParams) => { + const requestBody: any = { + statement: `SHOW SCHEMAS IN DATABASE ${params.database}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return JSON.stringify(requestBody) + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListSchemasParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake schemas', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list schemas: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + schemas: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of schemas and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_stages.ts b/apps/sim/tools/snowflake/list_stages.ts new file mode 100644 index 0000000000..b834216fdf --- /dev/null +++ b/apps/sim/tools/snowflake/list_stages.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListStagesParams, + SnowflakeListStagesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListStagesTool') + +export const snowflakeListStagesTool: ToolConfig< + SnowflakeListStagesParams, + SnowflakeListStagesResponse +> = { + id: 'snowflake_list_stages', + name: 'Snowflake List Stages', + description: 'List all stages in a Snowflake schema', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list stages from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListStagesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListStagesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListStagesParams) => { + const requestBody: Record = { + statement: `SHOW STAGES IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListStagesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake stages', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list stages: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + stages: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of stages and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_tables.ts b/apps/sim/tools/snowflake/list_tables.ts new file mode 100644 index 0000000000..6781bf000d --- /dev/null +++ b/apps/sim/tools/snowflake/list_tables.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListTablesParams, + SnowflakeListTablesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListTablesTool') + +export const snowflakeListTablesTool: ToolConfig< + SnowflakeListTablesParams, + SnowflakeListTablesResponse +> = { + id: 'snowflake_list_tables', + name: 'Snowflake List Tables', + description: 'List all tables in a Snowflake schema', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list tables from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListTablesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListTablesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListTablesParams) => { + const requestBody: any = { + statement: `SHOW TABLES IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return JSON.stringify(requestBody) + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListTablesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake tables', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list tables: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + tables: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of tables and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_views.ts b/apps/sim/tools/snowflake/list_views.ts new file mode 100644 index 0000000000..0665237465 --- /dev/null +++ b/apps/sim/tools/snowflake/list_views.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { SnowflakeListViewsParams, SnowflakeListViewsResponse } from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListViewsTool') + +export const snowflakeListViewsTool: ToolConfig< + SnowflakeListViewsParams, + SnowflakeListViewsResponse +> = { + id: 'snowflake_list_views', + name: 'Snowflake List Views', + description: 'List all views in a Snowflake schema', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name to list views from', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListViewsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListViewsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListViewsParams) => { + const requestBody: Record = { + statement: `SHOW VIEWS IN ${params.database}.${params.schema}`, + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListViewsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake views', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list views: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + views: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of views and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/list_warehouses.ts b/apps/sim/tools/snowflake/list_warehouses.ts new file mode 100644 index 0000000000..36a0c0c171 --- /dev/null +++ b/apps/sim/tools/snowflake/list_warehouses.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeListWarehousesParams, + SnowflakeListWarehousesResponse, +} from '@/tools/snowflake/types' +import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeListWarehousesTool') + +export const snowflakeListWarehousesTool: ToolConfig< + SnowflakeListWarehousesParams, + SnowflakeListWarehousesResponse +> = { + id: 'snowflake_list_warehouses', + name: 'Snowflake List Warehouses', + description: 'List all warehouses in the Snowflake account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + }, + + request: { + url: (params: SnowflakeListWarehousesParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeListWarehousesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeListWarehousesParams) => { + const requestBody: Record = { + statement: 'SHOW WAREHOUSES', + timeout: 60, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeListWarehousesParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list Snowflake warehouses', { + status: response.status, + errorText, + }) + throw new Error(`Failed to list warehouses: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const extractedData = extractResponseData(data) + + return { + success: true, + output: { + warehouses: extractedData, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'List of warehouses and metadata', + }, + }, +} diff --git a/apps/sim/tools/snowflake/types.ts b/apps/sim/tools/snowflake/types.ts new file mode 100644 index 0000000000..383a0426a8 --- /dev/null +++ b/apps/sim/tools/snowflake/types.ts @@ -0,0 +1,262 @@ +import type { ToolResponse } from '@/tools/types' + +/** + * Snowflake tool types and interfaces + */ + +/** + * Base parameters for Snowflake operations + */ +export interface SnowflakeBaseParams { + accessToken: string + accountUrl: string +} + +/** + * Parameters for executing a SQL query + */ +export interface SnowflakeExecuteQueryParams extends SnowflakeBaseParams { + query: string + database?: string + schema?: string + warehouse?: string + role?: string + timeout?: number +} + +/** + * Parameters for listing databases + */ +export interface SnowflakeListDatabasesParams extends SnowflakeBaseParams { + warehouse?: string + role?: string +} + +/** + * Parameters for listing schemas + */ +export interface SnowflakeListSchemasParams extends SnowflakeBaseParams { + database: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing tables + */ +export interface SnowflakeListTablesParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Parameters for describing a table + */ +export interface SnowflakeDescribeTableParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing views + */ +export interface SnowflakeListViewsParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing warehouses + */ +export interface SnowflakeListWarehousesParams extends SnowflakeBaseParams { + warehouse?: string + role?: string +} + +/** + * Parameters for listing file formats + */ +export interface SnowflakeListFileFormatsParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Parameters for listing stages + */ +export interface SnowflakeListStagesParams extends SnowflakeBaseParams { + database: string + schema: string + warehouse?: string + role?: string +} + +/** + * Response for execute query operations + */ +export interface SnowflakeExecuteQueryResponse extends ToolResponse { + output: { + statementHandle?: string + message?: string + data?: any[] + rowCount?: number + columns?: Array<{ + name: string + type: string + }> + ts: string + } +} + +/** + * Response for list databases operation + */ +export interface SnowflakeListDatabasesResponse extends ToolResponse { + output: { + databases?: Array<{ + name: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list schemas operation + */ +export interface SnowflakeListSchemasResponse extends ToolResponse { + output: { + schemas?: Array<{ + name: string + database_name: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list tables operation + */ +export interface SnowflakeListTablesResponse extends ToolResponse { + output: { + tables?: Array<{ + name: string + database_name: string + schema_name: string + kind: string + created_on: string + row_count: number + }> + ts: string + } +} + +/** + * Response for describe table operation + */ +export interface SnowflakeDescribeTableResponse extends ToolResponse { + output: { + columns?: Array<{ + name: string + type: string + kind: string + null: string + default: string | null + primary_key: string + unique_key: string + check: string | null + expression: string | null + comment: string | null + }> + ts: string + } +} + +/** + * Response for list views operation + */ +export interface SnowflakeListViewsResponse extends ToolResponse { + output: { + views?: Array<{ + name: string + database_name: string + schema_name: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list warehouses operation + */ +export interface SnowflakeListWarehousesResponse extends ToolResponse { + output: { + warehouses?: Array<{ + name: string + state: string + size: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Response for list file formats operation + */ +export interface SnowflakeListFileFormatsResponse extends ToolResponse { + output: { + fileFormats?: Array<{ + name: string + type: string + owner: string + created_on: string + }> + ts: string + } +} + +/** + * Response for list stages operation + */ +export interface SnowflakeListStagesResponse extends ToolResponse { + output: { + stages?: Array<{ + name: string + type: string + url: string + created_on: string + owner: string + }> + ts: string + } +} + +/** + * Generic Snowflake response type for the block + */ +export type SnowflakeResponse = + | SnowflakeExecuteQueryResponse + | SnowflakeListDatabasesResponse + | SnowflakeListSchemasResponse + | SnowflakeListTablesResponse + | SnowflakeDescribeTableResponse + | SnowflakeListViewsResponse + | SnowflakeListWarehousesResponse + | SnowflakeListFileFormatsResponse + | SnowflakeListStagesResponse diff --git a/apps/sim/tools/snowflake/utils.ts b/apps/sim/tools/snowflake/utils.ts new file mode 100644 index 0000000000..1a38829da2 --- /dev/null +++ b/apps/sim/tools/snowflake/utils.ts @@ -0,0 +1,142 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('Snowflake Utils') + +/** + * Build the base Snowflake SQL API URL + */ +export function buildSnowflakeSQLAPIUrl(accountUrl: string): string { + // Remove https:// if present + const cleanUrl = accountUrl.replace(/^https?:\/\//, '') + return `https://${cleanUrl}/api/v2/statements` +} + +/** + * Execute a Snowflake SQL statement + */ +export async function executeSnowflakeStatement( + accountUrl: string, + accessToken: string, + query: string, + options?: { + database?: string + schema?: string + warehouse?: string + role?: string + timeout?: number + async?: boolean + } +): Promise { + const apiUrl = buildSnowflakeSQLAPIUrl(accountUrl) + + const requestBody: any = { + statement: query, + timeout: options?.timeout || 60, + } + + if (options?.database) { + requestBody.database = options.database + } + + if (options?.schema) { + requestBody.schema = options.schema + } + + if (options?.warehouse) { + requestBody.warehouse = options.warehouse + } + + if (options?.role) { + requestBody.role = options.role + } + + if (options?.async) { + requestBody.async = true + } + + logger.info('Executing Snowflake statement', { + accountUrl, + hasAccessToken: !!accessToken, + database: options?.database, + schema: options?.schema, + }) + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Snowflake API error', { + status: response.status, + statusText: response.statusText, + errorText, + }) + throw new Error(`Snowflake API error: ${response.status} - ${errorText}`) + } + + const data = await response.json() + logger.info('Snowflake statement executed successfully') + + return data +} + +/** + * Parse Snowflake account URL to ensure proper format + */ +export function parseAccountUrl(accountUrl: string): string { + // Remove protocol if present + let cleanUrl = accountUrl.replace(/^https?:\/\//, '') + + // Remove trailing slash if present + cleanUrl = cleanUrl.replace(/\/$/, '') + + // If it doesn't contain snowflakecomputing.com, append it + if (!cleanUrl.includes('snowflakecomputing.com')) { + cleanUrl = `${cleanUrl}.snowflakecomputing.com` + } + + return cleanUrl +} + +/** + * Extract data from Snowflake API response + */ +export function extractResponseData(response: any): any[] { + if (!response.data || response.data.length === 0) { + return [] + } + + const rows: any[] = [] + + for (const row of response.data) { + const rowData: any = {} + for (let i = 0; i < row.length; i++) { + const columnName = response.resultSetMetaData?.rowType?.[i]?.name || `column_${i}` + rowData[columnName] = row[i] + } + rows.push(rowData) + } + + return rows +} + +/** + * Extract column metadata from Snowflake API response + */ +export function extractColumnMetadata(response: any): Array<{ name: string; type: string }> { + if (!response.resultSetMetaData?.rowType) { + return [] + } + + return response.resultSetMetaData.rowType.map((col: any) => ({ + name: col.name, + type: col.type, + })) +} From e3dca6635a3fb77b96b4907c682eaf7900524599 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Thu, 13 Nov 2025 22:37:01 -0800 Subject: [PATCH 02/12] working, but certain create, delete update queries are not --- apps/sim/blocks/blocks/snowflake.ts | 159 +++++++++++++++- apps/sim/tools/registry.ts | 6 + apps/sim/tools/snowflake/delete_rows.ts | 194 ++++++++++++++++++++ apps/sim/tools/snowflake/index.ts | 6 + apps/sim/tools/snowflake/insert_rows.ts | 231 +++++++++++++++++++++++ apps/sim/tools/snowflake/types.ts | 80 ++++++++ apps/sim/tools/snowflake/update_rows.ts | 233 ++++++++++++++++++++++++ 7 files changed, 905 insertions(+), 4 deletions(-) create mode 100644 apps/sim/tools/snowflake/delete_rows.ts create mode 100644 apps/sim/tools/snowflake/insert_rows.ts create mode 100644 apps/sim/tools/snowflake/update_rows.ts diff --git a/apps/sim/blocks/blocks/snowflake.ts b/apps/sim/blocks/blocks/snowflake.ts index 3143cd3f51..7ded1bc84b 100644 --- a/apps/sim/blocks/blocks/snowflake.ts +++ b/apps/sim/blocks/blocks/snowflake.ts @@ -9,7 +9,7 @@ export const SnowflakeBlock: BlockConfig = { description: 'Execute queries on Snowflake data warehouse', authMode: AuthMode.OAuth, longDescription: - 'Integrate Snowflake into your workflow. Execute SQL queries, list databases, schemas, and tables, and describe table structures in your Snowflake data warehouse.', + 'Integrate Snowflake into your workflow. Execute SQL queries, insert, update, and delete rows, list databases, schemas, and tables, and describe table structures in your Snowflake data warehouse.', docsLink: 'https://docs.sim.ai/tools/snowflake', category: 'tools', bgColor: '#E0E0E0', @@ -21,6 +21,9 @@ export const SnowflakeBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'Execute Query', id: 'execute_query' }, + { label: 'Insert Rows', id: 'insert_rows' }, + { label: 'Update Rows', id: 'update_rows' }, + { label: 'Delete Rows', id: 'delete_rows' }, { label: 'List Databases', id: 'list_databases' }, { label: 'List Schemas', id: 'list_schemas' }, { label: 'List Tables', id: 'list_tables' }, @@ -36,7 +39,6 @@ export const SnowflakeBlock: BlockConfig = { id: 'credential', title: 'Snowflake Account', type: 'oauth-input', - provider: 'snowflake', serviceId: 'snowflake', requiredScopes: [], placeholder: 'Select Snowflake account', @@ -235,6 +237,9 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t 'list_file_formats', 'list_stages', 'describe_table', + 'insert_rows', + 'update_rows', + 'delete_rows', ], }, }, @@ -246,7 +251,16 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t required: true, condition: { field: 'operation', - value: ['list_tables', 'list_views', 'list_file_formats', 'list_stages', 'describe_table'], + value: [ + 'list_tables', + 'list_views', + 'list_file_formats', + 'list_stages', + 'describe_table', + 'insert_rows', + 'update_rows', + 'delete_rows', + ], }, }, { @@ -257,7 +271,51 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t required: true, condition: { field: 'operation', - value: 'describe_table', + value: ['describe_table', 'insert_rows', 'update_rows', 'delete_rows'], + }, + }, + { + id: 'columns', + title: 'Columns', + type: 'long-input', + placeholder: '["column1", "column2", "column3"]', + required: true, + condition: { + field: 'operation', + value: 'insert_rows', + }, + }, + { + id: 'values', + title: 'Values', + type: 'long-input', + placeholder: '[["value1", "value2", "value3"], ["value4", "value5", "value6"]]', + required: true, + condition: { + field: 'operation', + value: 'insert_rows', + }, + }, + { + id: 'updates', + title: 'Updates', + type: 'long-input', + placeholder: '{"column1": "new_value", "column2": 123, "updated_at": "2024-01-01"}', + required: true, + condition: { + field: 'operation', + value: 'update_rows', + }, + }, + { + id: 'whereClause', + title: 'WHERE Clause', + type: 'long-input', + placeholder: 'id = 123 (leave empty to update/delete ALL rows)', + required: false, + condition: { + field: 'operation', + value: ['update_rows', 'delete_rows'], }, }, { @@ -274,6 +332,9 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t tools: { access: [ 'snowflake_execute_query', + 'snowflake_insert_rows', + 'snowflake_update_rows', + 'snowflake_delete_rows', 'snowflake_list_databases', 'snowflake_list_schemas', 'snowflake_list_tables', @@ -288,6 +349,12 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t switch (params.operation) { case 'execute_query': return 'snowflake_execute_query' + case 'insert_rows': + return 'snowflake_insert_rows' + case 'update_rows': + return 'snowflake_update_rows' + case 'delete_rows': + return 'snowflake_delete_rows' case 'list_databases': return 'snowflake_list_databases' case 'list_schemas': @@ -405,6 +472,83 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t break } + case 'insert_rows': { + if (!params.database || !params.schema || !params.table) { + throw new Error('Database, Schema, and Table are required for insert_rows operation') + } + if (!params.columns || !params.values) { + throw new Error('Columns and Values are required for insert_rows operation') + } + + // Parse columns and values if they are strings + let columns = params.columns + let values = params.values + + if (typeof columns === 'string') { + try { + columns = JSON.parse(columns) + } catch (e) { + throw new Error('Columns must be a valid JSON array') + } + } + + if (typeof values === 'string') { + try { + values = JSON.parse(values) + } catch (e) { + throw new Error('Values must be a valid JSON array of arrays') + } + } + + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + baseParams.columns = columns + baseParams.values = values + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + case 'update_rows': { + if (!params.database || !params.schema || !params.table) { + throw new Error('Database, Schema, and Table are required for update_rows operation') + } + if (!params.updates) { + throw new Error('Updates object is required for update_rows operation') + } + + // Parse updates if it's a string + let updates = params.updates + if (typeof updates === 'string') { + try { + updates = JSON.parse(updates) + } catch (e) { + throw new Error('Updates must be a valid JSON object') + } + } + + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + baseParams.updates = updates + if (params.whereClause) baseParams.whereClause = params.whereClause + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + + case 'delete_rows': { + if (!params.database || !params.schema || !params.table) { + throw new Error('Database, Schema, and Table are required for delete_rows operation') + } + + baseParams.database = params.database + baseParams.schema = params.schema + baseParams.table = params.table + if (params.whereClause) baseParams.whereClause = params.whereClause + if (params.timeout) baseParams.timeout = Number.parseInt(params.timeout) + break + } + default: throw new Error(`Unknown operation: ${operation}`) } @@ -426,6 +570,13 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t database: { type: 'string', description: 'Database name' }, schema: { type: 'string', description: 'Schema name' }, table: { type: 'string', description: 'Table name' }, + columns: { type: 'json', description: 'Array of column names for insert operation' }, + values: { type: 'json', description: 'Array of arrays containing values for insert operation' }, + updates: { + type: 'json', + description: 'Object containing column-value pairs for update operation', + }, + whereClause: { type: 'string', description: 'WHERE clause for update/delete operations' }, timeout: { type: 'string', description: 'Query timeout in seconds' }, }, outputs: { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index cd30397456..0f73c4bbf2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1011,8 +1011,10 @@ import { import { smsSendTool } from '@/tools/sms' import { smtpSendMailTool } from '@/tools/smtp' import { + snowflakeDeleteRowsTool, snowflakeDescribeTableTool, snowflakeExecuteQueryTool, + snowflakeInsertRowsTool, snowflakeListDatabasesTool, snowflakeListFileFormatsTool, snowflakeListSchemasTool, @@ -1020,6 +1022,7 @@ import { snowflakeListTablesTool, snowflakeListViewsTool, snowflakeListWarehousesTool, + snowflakeUpdateRowsTool, } from '@/tools/snowflake' import { checkCommandExistsTool as sshCheckCommandExistsTool, @@ -2437,6 +2440,9 @@ export const tools: Record = { zoom_delete_recording: zoomDeleteRecordingTool, zoom_list_past_participants: zoomListPastParticipantsTool, snowflake_execute_query: snowflakeExecuteQueryTool, + snowflake_insert_rows: snowflakeInsertRowsTool, + snowflake_update_rows: snowflakeUpdateRowsTool, + snowflake_delete_rows: snowflakeDeleteRowsTool, snowflake_list_databases: snowflakeListDatabasesTool, snowflake_list_schemas: snowflakeListSchemasTool, snowflake_list_tables: snowflakeListTablesTool, diff --git a/apps/sim/tools/snowflake/delete_rows.ts b/apps/sim/tools/snowflake/delete_rows.ts new file mode 100644 index 0000000000..d7216165bd --- /dev/null +++ b/apps/sim/tools/snowflake/delete_rows.ts @@ -0,0 +1,194 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeDeleteRowsParams, + SnowflakeDeleteRowsResponse, +} from '@/tools/snowflake/types' +import { parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeDeleteRowsTool') + +/** + * Build DELETE SQL statement from parameters + */ +function buildDeleteSQL( + database: string, + schema: string, + table: string, + whereClause?: string +): string { + const fullTableName = `${database}.${schema}.${table}` + + let sql = `DELETE FROM ${fullTableName}` + + // Add WHERE clause if provided + if (whereClause?.trim()) { + sql += ` WHERE ${whereClause}` + } + + return sql +} + +export const snowflakeDeleteRowsTool: ToolConfig< + SnowflakeDeleteRowsParams, + SnowflakeDeleteRowsResponse +> = { + id: 'snowflake_delete_rows', + name: 'Snowflake Delete Rows', + description: 'Delete rows from a Snowflake table', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name', + }, + whereClause: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'WHERE clause to filter rows to delete (e.g., "id = 123" or "status = \'inactive\' AND created_at < \'2024-01-01\'"). WARNING: If not provided, ALL rows will be deleted.', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeDeleteRowsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeDeleteRowsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeDeleteRowsParams) => { + // Build DELETE SQL + const deleteSQL = buildDeleteSQL( + params.database, + params.schema, + params.table, + params.whereClause + ) + + logger.info('Building DELETE statement', { + database: params.database, + schema: params.schema, + table: params.table, + hasWhereClause: !!params.whereClause, + }) + + // Log warning if no WHERE clause provided + if (!params.whereClause) { + logger.warn('DELETE statement has no WHERE clause - ALL rows will be deleted', { + table: `${params.database}.${params.schema}.${params.table}`, + }) + } + + const requestBody: Record = { + statement: deleteSQL, + timeout: params.timeout || 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeDeleteRowsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to delete rows from Snowflake table', { + status: response.status, + errorText, + table: params ? `${params.database}.${params.schema}.${params.table}` : 'unknown', + }) + throw new Error(`Failed to delete rows: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // Extract number of rows deleted from response + const rowsDeleted = data.statementStatusUrl ? 'unknown' : 0 + + return { + success: true, + output: { + statementHandle: data.statementHandle, + rowsDeleted, + message: `Successfully deleted rows from ${params?.database}.${params?.schema}.${params?.table}`, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Delete operation result', + }, + }, +} diff --git a/apps/sim/tools/snowflake/index.ts b/apps/sim/tools/snowflake/index.ts index 0e03d22044..63c37c1dca 100644 --- a/apps/sim/tools/snowflake/index.ts +++ b/apps/sim/tools/snowflake/index.ts @@ -1,5 +1,7 @@ +import { snowflakeDeleteRowsTool } from '@/tools/snowflake/delete_rows' import { snowflakeDescribeTableTool } from '@/tools/snowflake/describe_table' import { snowflakeExecuteQueryTool } from '@/tools/snowflake/execute_query' +import { snowflakeInsertRowsTool } from '@/tools/snowflake/insert_rows' import { snowflakeListDatabasesTool } from '@/tools/snowflake/list_databases' import { snowflakeListFileFormatsTool } from '@/tools/snowflake/list_file_formats' import { snowflakeListSchemasTool } from '@/tools/snowflake/list_schemas' @@ -7,6 +9,7 @@ import { snowflakeListStagesTool } from '@/tools/snowflake/list_stages' import { snowflakeListTablesTool } from '@/tools/snowflake/list_tables' import { snowflakeListViewsTool } from '@/tools/snowflake/list_views' import { snowflakeListWarehousesTool } from '@/tools/snowflake/list_warehouses' +import { snowflakeUpdateRowsTool } from '@/tools/snowflake/update_rows' export { snowflakeExecuteQueryTool, @@ -18,4 +21,7 @@ export { snowflakeListWarehousesTool, snowflakeListFileFormatsTool, snowflakeListStagesTool, + snowflakeInsertRowsTool, + snowflakeUpdateRowsTool, + snowflakeDeleteRowsTool, } diff --git a/apps/sim/tools/snowflake/insert_rows.ts b/apps/sim/tools/snowflake/insert_rows.ts new file mode 100644 index 0000000000..8c79be6eca --- /dev/null +++ b/apps/sim/tools/snowflake/insert_rows.ts @@ -0,0 +1,231 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeInsertRowsParams, + SnowflakeInsertRowsResponse, +} from '@/tools/snowflake/types' +import { parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeInsertRowsTool') + +/** + * Build INSERT SQL statement from parameters + */ +function buildInsertSQL( + database: string, + schema: string, + table: string, + columns: string[], + values: any[][] +): string { + const fullTableName = `${database}.${schema}.${table}` + const columnList = columns.join(', ') + + // Build values clause for multiple rows + const valuesClause = values + .map((rowValues) => { + const formattedValues = rowValues.map((val) => { + if (val === null || val === undefined) { + return 'NULL' + } + if (typeof val === 'string') { + // Escape single quotes by doubling them + return `'${val.replace(/'/g, "''")}'` + } + if (typeof val === 'boolean') { + return val ? 'TRUE' : 'FALSE' + } + return String(val) + }) + return `(${formattedValues.join(', ')})` + }) + .join(', ') + + return `INSERT INTO ${fullTableName} (${columnList}) VALUES ${valuesClause}` +} + +export const snowflakeInsertRowsTool: ToolConfig< + SnowflakeInsertRowsParams, + SnowflakeInsertRowsResponse +> = { + id: 'snowflake_insert_rows', + name: 'Snowflake Insert Rows', + description: 'Insert rows into a Snowflake table', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name', + }, + columns: { + type: 'array', + required: true, + visibility: 'user-only', + description: 'Array of column names to insert data into', + }, + values: { + type: 'array', + required: true, + visibility: 'user-only', + description: + 'Array of arrays containing values to insert. Each inner array represents one row and must match the order of columns.', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeInsertRowsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeInsertRowsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeInsertRowsParams) => { + // Validate inputs + if (!Array.isArray(params.columns) || params.columns.length === 0) { + throw new Error('Columns must be a non-empty array') + } + + if (!Array.isArray(params.values) || params.values.length === 0) { + throw new Error('Values must be a non-empty array') + } + + // Validate each row has correct number of values + for (let i = 0; i < params.values.length; i++) { + if (!Array.isArray(params.values[i])) { + throw new Error(`Values row ${i} must be an array`) + } + if (params.values[i].length !== params.columns.length) { + throw new Error( + `Values row ${i} has ${params.values[i].length} values but ${params.columns.length} columns were specified` + ) + } + } + + // Build INSERT SQL + const insertSQL = buildInsertSQL( + params.database, + params.schema, + params.table, + params.columns, + params.values + ) + + logger.info('Building INSERT statement', { + database: params.database, + schema: params.schema, + table: params.table, + columnCount: params.columns.length, + rowCount: params.values.length, + }) + + const requestBody: Record = { + statement: insertSQL, + timeout: params.timeout || 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeInsertRowsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to insert rows into Snowflake table', { + status: response.status, + errorText, + table: params ? `${params.database}.${params.schema}.${params.table}` : 'unknown', + }) + throw new Error(`Failed to insert rows: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // Get number of rows inserted from response + const rowsInserted = data.statementStatusUrl ? params?.values.length || 0 : 0 + + return { + success: true, + output: { + statementHandle: data.statementHandle, + rowsInserted, + message: `Successfully inserted ${rowsInserted} row(s) into ${params?.database}.${params?.schema}.${params?.table}`, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Insert operation result with row count', + }, + }, +} diff --git a/apps/sim/tools/snowflake/types.ts b/apps/sim/tools/snowflake/types.ts index 383a0426a8..1ceda4ff4b 100644 --- a/apps/sim/tools/snowflake/types.ts +++ b/apps/sim/tools/snowflake/types.ts @@ -247,6 +247,83 @@ export interface SnowflakeListStagesResponse extends ToolResponse { } } +/** + * Parameters for inserting rows + */ +export interface SnowflakeInsertRowsParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + columns: string[] + values: any[][] + warehouse?: string + role?: string + timeout?: number +} + +/** + * Parameters for updating rows + */ +export interface SnowflakeUpdateRowsParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + updates: Record + whereClause?: string + warehouse?: string + role?: string + timeout?: number +} + +/** + * Parameters for deleting rows + */ +export interface SnowflakeDeleteRowsParams extends SnowflakeBaseParams { + database: string + schema: string + table: string + whereClause?: string + warehouse?: string + role?: string + timeout?: number +} + +/** + * Response for insert rows operation + */ +export interface SnowflakeInsertRowsResponse extends ToolResponse { + output: { + statementHandle?: string + rowsInserted?: number + message?: string + ts: string + } +} + +/** + * Response for update rows operation + */ +export interface SnowflakeUpdateRowsResponse extends ToolResponse { + output: { + statementHandle?: string + rowsUpdated?: number | string + message?: string + ts: string + } +} + +/** + * Response for delete rows operation + */ +export interface SnowflakeDeleteRowsResponse extends ToolResponse { + output: { + statementHandle?: string + rowsDeleted?: number | string + message?: string + ts: string + } +} + /** * Generic Snowflake response type for the block */ @@ -260,3 +337,6 @@ export type SnowflakeResponse = | SnowflakeListWarehousesResponse | SnowflakeListFileFormatsResponse | SnowflakeListStagesResponse + | SnowflakeInsertRowsResponse + | SnowflakeUpdateRowsResponse + | SnowflakeDeleteRowsResponse diff --git a/apps/sim/tools/snowflake/update_rows.ts b/apps/sim/tools/snowflake/update_rows.ts new file mode 100644 index 0000000000..bd7c22eb7e --- /dev/null +++ b/apps/sim/tools/snowflake/update_rows.ts @@ -0,0 +1,233 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SnowflakeUpdateRowsParams, + SnowflakeUpdateRowsResponse, +} from '@/tools/snowflake/types' +import { parseAccountUrl } from '@/tools/snowflake/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SnowflakeUpdateRowsTool') + +/** + * Build UPDATE SQL statement from parameters + */ +function buildUpdateSQL( + database: string, + schema: string, + table: string, + updates: Record, + whereClause?: string +): string { + const fullTableName = `${database}.${schema}.${table}` + + // Build SET clause + const setClause = Object.entries(updates) + .map(([column, value]) => { + let formattedValue: string + + if (value === null || value === undefined) { + formattedValue = 'NULL' + } else if (typeof value === 'string') { + // Escape single quotes by doubling them + formattedValue = `'${value.replace(/'/g, "''")}'` + } else if (typeof value === 'boolean') { + formattedValue = value ? 'TRUE' : 'FALSE' + } else { + formattedValue = String(value) + } + + return `${column} = ${formattedValue}` + }) + .join(', ') + + let sql = `UPDATE ${fullTableName} SET ${setClause}` + + // Add WHERE clause if provided + if (whereClause?.trim()) { + sql += ` WHERE ${whereClause}` + } + + return sql +} + +export const snowflakeUpdateRowsTool: ToolConfig< + SnowflakeUpdateRowsParams, + SnowflakeUpdateRowsResponse +> = { + id: 'snowflake_update_rows', + name: 'Snowflake Update Rows', + description: 'Update rows in a Snowflake table', + version: '1.0.0', + + oauth: { + required: true, + provider: 'snowflake', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Snowflake', + }, + accountUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name', + }, + schema: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Schema name', + }, + table: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Table name', + }, + updates: { + type: 'object', + required: true, + visibility: 'user-only', + description: + 'Object containing column-value pairs to update (e.g., {"status": "active", "updated_at": "2024-01-01"})', + }, + whereClause: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'WHERE clause to filter rows to update (e.g., "id = 123" or "status = \'pending\' AND created_at < \'2024-01-01\'"). If not provided, all rows will be updated.', + }, + warehouse: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Warehouse to use (optional)', + }, + role: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Role to use (optional)', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Query timeout in seconds (default: 60)', + }, + }, + + request: { + url: (params: SnowflakeUpdateRowsParams) => { + const cleanUrl = parseAccountUrl(params.accountUrl) + return `https://${cleanUrl}/api/v2/statements` + }, + method: 'POST', + headers: (params: SnowflakeUpdateRowsParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + }), + body: (params: SnowflakeUpdateRowsParams) => { + // Validate inputs + if ( + !params.updates || + typeof params.updates !== 'object' || + Object.keys(params.updates).length === 0 + ) { + throw new Error('Updates must be a non-empty object with column-value pairs') + } + + // Build UPDATE SQL + const updateSQL = buildUpdateSQL( + params.database, + params.schema, + params.table, + params.updates, + params.whereClause + ) + + logger.info('Building UPDATE statement', { + database: params.database, + schema: params.schema, + table: params.table, + updateColumnCount: Object.keys(params.updates).length, + hasWhereClause: !!params.whereClause, + }) + + // Log warning if no WHERE clause provided + if (!params.whereClause) { + logger.warn('UPDATE statement has no WHERE clause - all rows will be updated', { + table: `${params.database}.${params.schema}.${params.table}`, + }) + } + + const requestBody: Record = { + statement: updateSQL, + timeout: params.timeout || 60, + database: params.database, + schema: params.schema, + } + + if (params.warehouse) { + requestBody.warehouse = params.warehouse + } + + if (params.role) { + requestBody.role = params.role + } + + return requestBody + }, + }, + + transformResponse: async (response: Response, params?: SnowflakeUpdateRowsParams) => { + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to update rows in Snowflake table', { + status: response.status, + errorText, + table: params ? `${params.database}.${params.schema}.${params.table}` : 'unknown', + }) + throw new Error(`Failed to update rows: ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // Extract number of rows updated from response + const rowsUpdated = data.statementStatusUrl ? 'unknown' : 0 + + return { + success: true, + output: { + statementHandle: data.statementHandle, + rowsUpdated, + message: `Successfully updated rows in ${params?.database}.${params?.schema}.${params?.table}`, + ts: new Date().toISOString(), + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Operation success status', + }, + output: { + type: 'object', + description: 'Update operation result', + }, + }, +} From 17a164508f0b32a51041f6911ecf95fee5541742 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Wed, 19 Nov 2025 13:04:25 -0800 Subject: [PATCH 03/12] client id and secret for individual projects, no more single client and secret --- apps/sim/app/api/auth/oauth/utils.ts | 46 +++++++++++++-- .../app/api/auth/snowflake/authorize/route.ts | 53 +++++++---------- .../app/api/auth/snowflake/callback/route.ts | 26 +++++++-- .../components/oauth-required-modal.tsx | 58 +++++++++++++++++-- apps/sim/lib/oauth/oauth.ts | 33 ++++++++--- 5 files changed, 159 insertions(+), 57 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 5713e288f1..f5b039dfd0 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -69,6 +69,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessTokenExpiresAt: account.accessTokenExpiresAt, accountId: account.accountId, providerId: account.providerId, + password: account.password, // Include password field for Snowflake OAuth credentials }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -95,10 +96,21 @@ export async function getOAuthToken(userId: string, providerId: string): Promise ) try { - // Extract account URL from accountId for Snowflake - let metadata: { accountUrl?: string } | undefined + // Extract account URL and OAuth credentials for Snowflake + let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined if (providerId === 'snowflake' && credential.accountId) { metadata = { accountUrl: credential.accountId } + + // Extract clientId and clientSecret from the password field (stored as JSON) + if (credential.password) { + try { + const oauthCredentials = JSON.parse(credential.password) + metadata.clientId = oauthCredentials.clientId + metadata.clientSecret = oauthCredentials.clientSecret + } catch (e) { + logger.error('Failed to parse Snowflake OAuth credentials', { error: e }) + } + } } // Use the existing refreshOAuthToken function @@ -185,10 +197,21 @@ export async function refreshAccessTokenIfNeeded( if (shouldRefresh) { logger.info(`[${requestId}] Token expired, attempting to refresh for credential`) try { - // Extract account URL from accountId for Snowflake - let metadata: { accountUrl?: string } | undefined + // Extract account URL and OAuth credentials for Snowflake + let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined if (credential.providerId === 'snowflake' && credential.accountId) { metadata = { accountUrl: credential.accountId } + + // Extract clientId and clientSecret from the password field (stored as JSON) + if (credential.password) { + try { + const oauthCredentials = JSON.parse(credential.password) + metadata.clientId = oauthCredentials.clientId + metadata.clientSecret = oauthCredentials.clientSecret + } catch (e) { + logger.error('Failed to parse Snowflake OAuth credentials', { error: e }) + } + } } const refreshedToken = await refreshOAuthToken( @@ -266,10 +289,21 @@ export async function refreshTokenIfNeeded( } try { - // Extract account URL from accountId for Snowflake - let metadata: { accountUrl?: string } | undefined + // Extract account URL and OAuth credentials for Snowflake + let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined if (credential.providerId === 'snowflake' && credential.accountId) { metadata = { accountUrl: credential.accountId } + + // Extract clientId and clientSecret from the password field (stored as JSON) + if (credential.password) { + try { + const oauthCredentials = JSON.parse(credential.password) + metadata.clientId = oauthCredentials.clientId + metadata.clientSecret = oauthCredentials.clientSecret + } catch (e) { + logger.error('Failed to parse Snowflake OAuth credentials', { error: e }) + } + } } const refreshResult = await refreshOAuthToken( diff --git a/apps/sim/app/api/auth/snowflake/authorize/route.ts b/apps/sim/app/api/auth/snowflake/authorize/route.ts index b2a0885827..ba47fcc25a 100644 --- a/apps/sim/app/api/auth/snowflake/authorize/route.ts +++ b/apps/sim/app/api/auth/snowflake/authorize/route.ts @@ -1,6 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce' import { getBaseUrl } from '@/lib/urls/utils' @@ -11,9 +10,9 @@ export const dynamic = 'force-dynamic' /** * Initiates Snowflake OAuth flow - * Requires accountUrl as query parameter + * Expects credentials to be posted in the request body (accountUrl, clientId, clientSecret) */ -export async function GET(request: NextRequest) { +export async function POST(request: NextRequest) { try { const session = await getSession() if (!session?.user?.id) { @@ -21,20 +20,19 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const accountUrl = searchParams.get('accountUrl') + const body = await request.json() + const { accountUrl, clientId, clientSecret } = body - if (!accountUrl) { - logger.error('Missing accountUrl parameter') - return NextResponse.json({ error: 'accountUrl parameter is required' }, { status: 400 }) - } - - const clientId = env.SNOWFLAKE_CLIENT_ID - const clientSecret = env.SNOWFLAKE_CLIENT_SECRET - - if (!clientId || !clientSecret) { - logger.error('Snowflake OAuth credentials not configured') - return NextResponse.json({ error: 'Snowflake OAuth not configured' }, { status: 500 }) + if (!accountUrl || !clientId || !clientSecret) { + logger.error('Missing required Snowflake OAuth parameters', { + hasAccountUrl: !!accountUrl, + hasClientId: !!clientId, + hasClientSecret: !!clientSecret, + }) + return NextResponse.json( + { error: 'accountUrl, clientId, and clientSecret are required' }, + { status: 400 } + ) } // Parse and clean the account URL @@ -51,10 +49,13 @@ export async function GET(request: NextRequest) { const codeVerifier = generateCodeVerifier() const codeChallenge = await generateCodeChallenge(codeVerifier) + // Store user-provided credentials in the state (will be used in callback) const state = Buffer.from( JSON.stringify({ userId: session.user.id, accountUrl: cleanAccountUrl, + clientId, + clientSecret, timestamp: Date.now(), codeVerifier, }) @@ -72,28 +73,18 @@ export async function GET(request: NextRequest) { authUrl.searchParams.set('code_challenge', codeChallenge) authUrl.searchParams.set('code_challenge_method', 'S256') - logger.info('Initiating Snowflake OAuth flow (CONFIDENTIAL client with PKCE)', { + logger.info('Initiating Snowflake OAuth flow with user-provided credentials (PKCE)', { userId: session.user.id, accountUrl: cleanAccountUrl, - authUrl: authUrl.toString(), - redirectUri, - clientId, + hasClientId: !!clientId, hasClientSecret: !!clientSecret, + redirectUri, hasPkce: true, - parametersCount: authUrl.searchParams.toString().length, }) - logger.info('Authorization URL parameters:', { - client_id: authUrl.searchParams.get('client_id'), - response_type: authUrl.searchParams.get('response_type'), - redirect_uri: authUrl.searchParams.get('redirect_uri'), - state_length: authUrl.searchParams.get('state')?.length, - scope: authUrl.searchParams.get('scope'), - has_pkce: authUrl.searchParams.has('code_challenge'), - code_challenge_method: authUrl.searchParams.get('code_challenge_method'), + return NextResponse.json({ + authUrl: authUrl.toString(), }) - - return NextResponse.redirect(authUrl.toString()) } catch (error) { logger.error('Error initiating Snowflake authorization:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/auth/snowflake/callback/route.ts b/apps/sim/app/api/auth/snowflake/callback/route.ts index a08425c611..206fc0e8cd 100644 --- a/apps/sim/app/api/auth/snowflake/callback/route.ts +++ b/apps/sim/app/api/auth/snowflake/callback/route.ts @@ -1,7 +1,6 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' import { db } from '@/../../packages/db' @@ -41,10 +40,12 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_callback`) } - // Decode state to get account URL and code verifier + // Decode state to get account URL, credentials, and code verifier let stateData: { userId: string accountUrl: string + clientId: string + clientSecret: string timestamp: number codeVerifier: string } @@ -54,6 +55,8 @@ export async function GET(request: NextRequest) { logger.info('Decoded state successfully', { userId: stateData.userId, accountUrl: stateData.accountUrl, + hasClientId: !!stateData.clientId, + hasClientSecret: !!stateData.clientSecret, age: Date.now() - stateData.timestamp, hasCodeVerifier: !!stateData.codeVerifier, }) @@ -79,12 +82,13 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_state_expired`) } - const clientId = env.SNOWFLAKE_CLIENT_ID - const clientSecret = env.SNOWFLAKE_CLIENT_SECRET + // Use user-provided credentials from state + const clientId = stateData.clientId + const clientSecret = stateData.clientSecret if (!clientId || !clientSecret) { - logger.error('Snowflake OAuth credentials not configured') - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_not_configured`) + logger.error('Missing client credentials in state') + return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_missing_credentials`) } // Exchange authorization code for tokens @@ -165,12 +169,22 @@ export async function GET(request: NextRequest) { ? new Date(now.getTime() + tokens.expires_in * 1000) : new Date(now.getTime() + 10 * 60 * 1000) // Default 10 minutes + // Store user-provided OAuth credentials securely + // We use the password field to store a JSON object with clientId and clientSecret + // and idToken to store the accountUrl for easier retrieval + const oauthCredentials = JSON.stringify({ + clientId: stateData.clientId, + clientSecret: stateData.clientSecret, + }) + const accountData = { userId: session.user.id, providerId: 'snowflake', accountId: stateData.accountUrl, // Store the Snowflake account URL here accessToken: tokens.access_token, refreshToken: tokens.refresh_token || null, + idToken: stateData.accountUrl, // Store accountUrl for easier access + password: oauthCredentials, // Store clientId and clientSecret as JSON accessTokenExpiresAt: expiresAt, scope: tokens.scope || null, updatedAt: now, 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 cdcc217182..b96c89db0d 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 @@ -273,7 +273,11 @@ export function OAuthRequiredModal({ newScopes = [], }: OAuthRequiredModalProps) { const [snowflakeAccountUrl, setSnowflakeAccountUrl] = useState('') + const [snowflakeClientId, setSnowflakeClientId] = useState('') + const [snowflakeClientSecret, setSnowflakeClientSecret] = useState('') const [accountUrlError, setAccountUrlError] = useState('') + const [clientIdError, setClientIdError] = useState('') + const [clientSecretError, setClientSecretError] = useState('') const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) @@ -305,20 +309,64 @@ export function OAuthRequiredModal({ try { const providerId = getProviderIdFromServiceId(effectiveServiceId) - // Special handling for Snowflake - requires account URL + // Special handling for Snowflake - requires account URL, client ID, and client secret if (providerId === 'snowflake') { + let hasError = false + if (!snowflakeAccountUrl.trim()) { setAccountUrlError('Account URL is required') + hasError = true + } + + if (!snowflakeClientId.trim()) { + setClientIdError('Client ID is required') + hasError = true + } + + if (!snowflakeClientSecret.trim()) { + setClientSecretError('Client Secret is required') + hasError = true + } + + if (hasError) { return } onClose() - logger.info('Initiating Snowflake OAuth:', { + logger.info('Initiating Snowflake OAuth with user credentials:', { accountUrl: snowflakeAccountUrl, + hasClientId: !!snowflakeClientId, + hasClientSecret: !!snowflakeClientSecret, }) - window.location.href = `/api/auth/snowflake/authorize?accountUrl=${encodeURIComponent(snowflakeAccountUrl)}` + // Call the authorize endpoint with user credentials + try { + const response = await fetch('/api/auth/snowflake/authorize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + accountUrl: snowflakeAccountUrl, + clientId: snowflakeClientId, + clientSecret: snowflakeClientSecret, + }), + }) + + if (!response.ok) { + throw new Error('Failed to initiate Snowflake OAuth') + } + + const data = await response.json() + + // Redirect to Snowflake authorization page + window.location.href = data.authUrl + } catch (error) { + logger.error('Error initiating Snowflake OAuth:', error) + // TODO: Show error to user + } + return } @@ -352,7 +400,7 @@ export function OAuthRequiredModal({ return ( !open && onClose()}> - + Connect {providerName}
@@ -414,4 +462,4 @@ export function OAuthRequiredModal({ ) -} +} \ No newline at end of file diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 1f1f3b21ed..e07399002c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1529,28 +1529,43 @@ function buildAuthRequest( * This is a server-side utility function to refresh OAuth tokens * @param providerId The provider ID (e.g., 'google-drive') * @param refreshToken The refresh token to use - * @param metadata Optional metadata (e.g., accountUrl for Snowflake) + * @param metadata Optional metadata (e.g., accountUrl, clientId, clientSecret for Snowflake) * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ export async function refreshOAuthToken( providerId: string, refreshToken: string, - metadata?: { accountUrl?: string } + metadata?: { accountUrl?: string; clientId?: string; clientSecret?: string } ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { // Get the provider from the providerId (e.g., 'google-drive' -> 'google') const provider = providerId.split('-')[0] - // Get provider configuration - const config = getProviderAuthConfig(provider) + let config: ProviderAuthConfig - // For Snowflake, use the account-specific token endpoint - let tokenEndpoint = config.tokenEndpoint - if (provider === 'snowflake' && metadata?.accountUrl) { - tokenEndpoint = `https://${metadata.accountUrl}/oauth/token-request` - logger.info('Using Snowflake account-specific token endpoint', { + if (provider === 'snowflake' && metadata?.clientId && metadata?.clientSecret) { + config = { + tokenEndpoint: `https://${metadata.accountUrl}/oauth/token-request`, + clientId: metadata.clientId, + clientSecret: metadata.clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + } + logger.info('Using user-provided Snowflake OAuth credentials for token refresh', { accountUrl: metadata.accountUrl, + hasClientId: !!metadata.clientId, + hasClientSecret: !!metadata.clientSecret, }) + } else { + config = getProviderAuthConfig(provider) + + // For Snowflake without user credentials, use the account-specific token endpoint + if (provider === 'snowflake' && metadata?.accountUrl) { + config.tokenEndpoint = `https://${metadata.accountUrl}/oauth/token-request` + logger.info('Using Snowflake account-specific token endpoint', { + accountUrl: metadata.accountUrl, + }) + } } // Build authentication request From f093f97cc82e9cf4c56fa151ffb2022d3088dec9 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Wed, 19 Nov 2025 13:10:17 -0800 Subject: [PATCH 04/12] component fix --- .../credential-selector/components/oauth-required-modal.tsx | 3 ++- apps/sim/lib/oauth/oauth.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 b96c89db0d..af42931875 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 @@ -1,7 +1,7 @@ 'use client' -import { useState } from 'react' import { Check } from 'lucide-react' +import { useState } from 'react' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' import { client } from '@/lib/auth/auth-client' import { createLogger } from '@/lib/logs/console/logger' @@ -13,6 +13,7 @@ import { parseProvider, } from '@/lib/oauth' + const logger = createLogger('OAuthRequiredModal') export interface OAuthRequiredModalProps { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index e07399002c..f409ee41ec 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1572,7 +1572,7 @@ export async function refreshOAuthToken( const { headers, bodyParams } = buildAuthRequest(config, refreshToken) // Refresh the token - const response = await fetch(tokenEndpoint, { + const response = await fetch(config.tokenEndpoint, { method: 'POST', headers, body: new URLSearchParams(bodyParams).toString(), From 883c70140a3006a0af26c6fc0cd7a33a0493b1c6 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Sat, 29 Nov 2025 14:27:49 -0800 Subject: [PATCH 05/12] fixed vulnerability and lint --- apps/sim/tools/snowflake/delete_rows.ts | 9 ++++-- apps/sim/tools/snowflake/insert_rows.ts | 13 +++++--- apps/sim/tools/snowflake/update_rows.ts | 16 ++++++---- apps/sim/tools/snowflake/utils.ts | 42 +++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/apps/sim/tools/snowflake/delete_rows.ts b/apps/sim/tools/snowflake/delete_rows.ts index d7216165bd..0b7cc75571 100644 --- a/apps/sim/tools/snowflake/delete_rows.ts +++ b/apps/sim/tools/snowflake/delete_rows.ts @@ -3,7 +3,7 @@ import type { SnowflakeDeleteRowsParams, SnowflakeDeleteRowsResponse, } from '@/tools/snowflake/types' -import { parseAccountUrl } from '@/tools/snowflake/utils' +import { parseAccountUrl, sanitizeIdentifier, validateWhereClause } from '@/tools/snowflake/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SnowflakeDeleteRowsTool') @@ -17,12 +17,15 @@ function buildDeleteSQL( table: string, whereClause?: string ): string { - const fullTableName = `${database}.${schema}.${table}` + const sanitizedDatabase = sanitizeIdentifier(database) + const sanitizedSchema = sanitizeIdentifier(schema) + const sanitizedTable = sanitizeIdentifier(table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` let sql = `DELETE FROM ${fullTableName}` - // Add WHERE clause if provided if (whereClause?.trim()) { + validateWhereClause(whereClause) sql += ` WHERE ${whereClause}` } diff --git a/apps/sim/tools/snowflake/insert_rows.ts b/apps/sim/tools/snowflake/insert_rows.ts index 8c79be6eca..5e9d905eeb 100644 --- a/apps/sim/tools/snowflake/insert_rows.ts +++ b/apps/sim/tools/snowflake/insert_rows.ts @@ -3,13 +3,13 @@ import type { SnowflakeInsertRowsParams, SnowflakeInsertRowsResponse, } from '@/tools/snowflake/types' -import { parseAccountUrl } from '@/tools/snowflake/utils' +import { parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SnowflakeInsertRowsTool') /** - * Build INSERT SQL statement from parameters + * Build INSERT SQL statement from parameters with proper identifier quoting */ function buildInsertSQL( database: string, @@ -18,10 +18,13 @@ function buildInsertSQL( columns: string[], values: any[][] ): string { - const fullTableName = `${database}.${schema}.${table}` - const columnList = columns.join(', ') + const sanitizedDatabase = sanitizeIdentifier(database) + const sanitizedSchema = sanitizeIdentifier(schema) + const sanitizedTable = sanitizeIdentifier(table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` + + const columnList = columns.map((col) => sanitizeIdentifier(col)).join(', ') - // Build values clause for multiple rows const valuesClause = values .map((rowValues) => { const formattedValues = rowValues.map((val) => { diff --git a/apps/sim/tools/snowflake/update_rows.ts b/apps/sim/tools/snowflake/update_rows.ts index bd7c22eb7e..37c859d403 100644 --- a/apps/sim/tools/snowflake/update_rows.ts +++ b/apps/sim/tools/snowflake/update_rows.ts @@ -3,13 +3,13 @@ import type { SnowflakeUpdateRowsParams, SnowflakeUpdateRowsResponse, } from '@/tools/snowflake/types' -import { parseAccountUrl } from '@/tools/snowflake/utils' +import { parseAccountUrl, sanitizeIdentifier, validateWhereClause } from '@/tools/snowflake/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SnowflakeUpdateRowsTool') /** - * Build UPDATE SQL statement from parameters + * Build UPDATE SQL statement from parameters with proper identifier quoting */ function buildUpdateSQL( database: string, @@ -18,11 +18,15 @@ function buildUpdateSQL( updates: Record, whereClause?: string ): string { - const fullTableName = `${database}.${schema}.${table}` + const sanitizedDatabase = sanitizeIdentifier(database) + const sanitizedSchema = sanitizeIdentifier(schema) + const sanitizedTable = sanitizeIdentifier(table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` - // Build SET clause const setClause = Object.entries(updates) .map(([column, value]) => { + const sanitizedColumn = sanitizeIdentifier(column) + let formattedValue: string if (value === null || value === undefined) { @@ -36,14 +40,14 @@ function buildUpdateSQL( formattedValue = String(value) } - return `${column} = ${formattedValue}` + return `${sanitizedColumn} = ${formattedValue}` }) .join(', ') let sql = `UPDATE ${fullTableName} SET ${setClause}` - // Add WHERE clause if provided if (whereClause?.trim()) { + validateWhereClause(whereClause) sql += ` WHERE ${whereClause}` } diff --git a/apps/sim/tools/snowflake/utils.ts b/apps/sim/tools/snowflake/utils.ts index 1a38829da2..b2c32de2f6 100644 --- a/apps/sim/tools/snowflake/utils.ts +++ b/apps/sim/tools/snowflake/utils.ts @@ -140,3 +140,45 @@ export function extractColumnMetadata(response: any): Array<{ name: string; type type: col.type, })) } + +export function sanitizeIdentifier(identifier: string): string { + if (identifier.includes('.')) { + const parts = identifier.split('.') + return parts.map((part) => sanitizeSingleIdentifier(part)).join('.') + } + + return sanitizeSingleIdentifier(identifier) +} + +export function validateWhereClause(where: string): void { + const dangerousPatterns = [ + /;\s*(drop|delete|insert|update|create|alter|grant|revoke|truncate)/i, + /union\s+select/i, + /into\s+outfile/i, + /load_file/i, + /--/, + /\/\*/, + /\*\//, + /xp_cmdshell/i, + /exec\s*\(/i, + /execute\s+immediate/i, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(where)) { + throw new Error('WHERE clause contains potentially dangerous operation') + } + } +} + +function sanitizeSingleIdentifier(identifier: string): string { + const cleaned = identifier.replace(/"/g, '') + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error( + `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.` + ) + } + + return `"${cleaned}"` +} From b3f6bffc5533efcb2f87403624f66e65443719b8 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Sat, 29 Nov 2025 14:58:11 -0800 Subject: [PATCH 06/12] added new test routes to fix build --- apps/sim/app/api/auth/oauth/utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index c76ed05bff..297c1b5966 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -163,7 +163,7 @@ describe('OAuth Utils', () => { const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) @@ -251,7 +251,7 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(token).toBe('new-token') From 342c4a2081ca2c619ea66e372da5f55350bf34c7 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Sat, 29 Nov 2025 15:06:27 -0800 Subject: [PATCH 07/12] fixed build --- apps/sim/tools/snowflake/describe_table.ts | 15 +++++++++++---- apps/sim/tools/snowflake/execute_query.ts | 4 ++-- apps/sim/tools/snowflake/list_databases.ts | 4 ++-- apps/sim/tools/snowflake/list_schemas.ts | 11 +++++++---- apps/sim/tools/snowflake/list_tables.ts | 13 +++++++++---- 5 files changed, 31 insertions(+), 16 deletions(-) diff --git a/apps/sim/tools/snowflake/describe_table.ts b/apps/sim/tools/snowflake/describe_table.ts index 65dc40c036..c6328d2174 100644 --- a/apps/sim/tools/snowflake/describe_table.ts +++ b/apps/sim/tools/snowflake/describe_table.ts @@ -3,7 +3,7 @@ import type { SnowflakeDescribeTableParams, SnowflakeDescribeTableResponse, } from '@/tools/snowflake/types' -import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import { extractResponseData, parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SnowflakeDescribeTableTool') @@ -79,9 +79,16 @@ export const snowflakeDescribeTableTool: ToolConfig< 'X-Snowflake-Authorization-Token-Type': 'OAUTH', }), body: (params: SnowflakeDescribeTableParams) => { - const requestBody: any = { - statement: `DESCRIBE TABLE ${params.database}.${params.schema}.${params.table}`, + const sanitizedDatabase = sanitizeIdentifier(params.database) + const sanitizedSchema = sanitizeIdentifier(params.schema) + const sanitizedTable = sanitizeIdentifier(params.table) + const fullTableName = `${sanitizedDatabase}.${sanitizedSchema}.${sanitizedTable}` + + const requestBody: Record = { + statement: `DESCRIBE TABLE ${fullTableName}`, timeout: 60, + database: params.database, + schema: params.schema, } if (params.warehouse) { @@ -92,7 +99,7 @@ export const snowflakeDescribeTableTool: ToolConfig< requestBody.role = params.role } - return JSON.stringify(requestBody) + return requestBody }, }, diff --git a/apps/sim/tools/snowflake/execute_query.ts b/apps/sim/tools/snowflake/execute_query.ts index 7b32261e4b..82218e4a02 100644 --- a/apps/sim/tools/snowflake/execute_query.ts +++ b/apps/sim/tools/snowflake/execute_query.ts @@ -89,7 +89,7 @@ export const snowflakeExecuteQueryTool: ToolConfig< 'X-Snowflake-Authorization-Token-Type': 'OAUTH', }), body: (params: SnowflakeExecuteQueryParams) => { - const requestBody: any = { + const requestBody: Record = { statement: params.query, timeout: params.timeout || 60, } @@ -110,7 +110,7 @@ export const snowflakeExecuteQueryTool: ToolConfig< requestBody.role = params.role } - return JSON.stringify(requestBody) + return requestBody }, }, diff --git a/apps/sim/tools/snowflake/list_databases.ts b/apps/sim/tools/snowflake/list_databases.ts index ccdb9e121e..abe60571a1 100644 --- a/apps/sim/tools/snowflake/list_databases.ts +++ b/apps/sim/tools/snowflake/list_databases.ts @@ -61,7 +61,7 @@ export const snowflakeListDatabasesTool: ToolConfig< 'X-Snowflake-Authorization-Token-Type': 'OAUTH', }), body: (params: SnowflakeListDatabasesParams) => { - const requestBody: any = { + const requestBody: Record = { statement: 'SHOW DATABASES', timeout: 60, } @@ -74,7 +74,7 @@ export const snowflakeListDatabasesTool: ToolConfig< requestBody.role = params.role } - return JSON.stringify(requestBody) + return requestBody }, }, diff --git a/apps/sim/tools/snowflake/list_schemas.ts b/apps/sim/tools/snowflake/list_schemas.ts index 6549abdb72..c30cdfa06e 100644 --- a/apps/sim/tools/snowflake/list_schemas.ts +++ b/apps/sim/tools/snowflake/list_schemas.ts @@ -3,7 +3,7 @@ import type { SnowflakeListSchemasParams, SnowflakeListSchemasResponse, } from '@/tools/snowflake/types' -import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import { extractResponseData, parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SnowflakeListSchemasTool') @@ -67,9 +67,12 @@ export const snowflakeListSchemasTool: ToolConfig< 'X-Snowflake-Authorization-Token-Type': 'OAUTH', }), body: (params: SnowflakeListSchemasParams) => { - const requestBody: any = { - statement: `SHOW SCHEMAS IN DATABASE ${params.database}`, + const sanitizedDatabase = sanitizeIdentifier(params.database) + + const requestBody: Record = { + statement: `SHOW SCHEMAS IN DATABASE ${sanitizedDatabase}`, timeout: 60, + database: params.database, } if (params.warehouse) { @@ -80,7 +83,7 @@ export const snowflakeListSchemasTool: ToolConfig< requestBody.role = params.role } - return JSON.stringify(requestBody) + return requestBody }, }, diff --git a/apps/sim/tools/snowflake/list_tables.ts b/apps/sim/tools/snowflake/list_tables.ts index 6781bf000d..82d1e533cf 100644 --- a/apps/sim/tools/snowflake/list_tables.ts +++ b/apps/sim/tools/snowflake/list_tables.ts @@ -3,7 +3,7 @@ import type { SnowflakeListTablesParams, SnowflakeListTablesResponse, } from '@/tools/snowflake/types' -import { extractResponseData, parseAccountUrl } from '@/tools/snowflake/utils' +import { extractResponseData, parseAccountUrl, sanitizeIdentifier } from '@/tools/snowflake/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SnowflakeListTablesTool') @@ -73,9 +73,14 @@ export const snowflakeListTablesTool: ToolConfig< 'X-Snowflake-Authorization-Token-Type': 'OAUTH', }), body: (params: SnowflakeListTablesParams) => { - const requestBody: any = { - statement: `SHOW TABLES IN ${params.database}.${params.schema}`, + const sanitizedDatabase = sanitizeIdentifier(params.database) + const sanitizedSchema = sanitizeIdentifier(params.schema) + + const requestBody: Record = { + statement: `SHOW TABLES IN ${sanitizedDatabase}.${sanitizedSchema}`, timeout: 60, + database: params.database, + schema: params.schema, } if (params.warehouse) { @@ -86,7 +91,7 @@ export const snowflakeListTablesTool: ToolConfig< requestBody.role = params.role } - return JSON.stringify(requestBody) + return requestBody }, }, From 89ba330846a8211e66bac00a6942cf50090a1f1e Mon Sep 17 00:00:00 2001 From: aadamgough Date: Wed, 3 Dec 2025 23:46:55 -0800 Subject: [PATCH 08/12] reformatted to PAT from oauth --- apps/sim/app/api/auth/oauth/utils.ts | 63 +---- .../app/api/auth/snowflake/authorize/route.ts | 92 -------- .../app/api/auth/snowflake/callback/route.ts | 218 ------------------ .../components/oauth-required-modal.tsx | 73 +----- apps/sim/blocks/blocks/snowflake.ts | 26 ++- apps/sim/lib/core/config/env.ts | 2 - apps/sim/lib/oauth/oauth.ts | 33 +-- apps/sim/tools/snowflake/delete_rows.ts | 9 +- apps/sim/tools/snowflake/describe_table.ts | 9 +- apps/sim/tools/snowflake/execute_query.ts | 9 +- apps/sim/tools/snowflake/insert_rows.ts | 9 +- apps/sim/tools/snowflake/list_databases.ts | 9 +- apps/sim/tools/snowflake/list_file_formats.ts | 9 +- apps/sim/tools/snowflake/list_schemas.ts | 9 +- apps/sim/tools/snowflake/list_stages.ts | 9 +- apps/sim/tools/snowflake/list_tables.ts | 9 +- apps/sim/tools/snowflake/list_views.ts | 9 +- apps/sim/tools/snowflake/list_warehouses.ts | 9 +- apps/sim/tools/snowflake/update_rows.ts | 9 +- apps/sim/tools/snowflake/utils.ts | 2 +- 20 files changed, 53 insertions(+), 564 deletions(-) delete mode 100644 apps/sim/app/api/auth/snowflake/authorize/route.ts delete mode 100644 apps/sim/app/api/auth/snowflake/callback/route.ts diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index f5b039dfd0..dad58eb610 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -69,7 +69,6 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessTokenExpiresAt: account.accessTokenExpiresAt, accountId: account.accountId, providerId: account.providerId, - password: account.password, // Include password field for Snowflake OAuth credentials }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -96,25 +95,8 @@ export async function getOAuthToken(userId: string, providerId: string): Promise ) try { - // Extract account URL and OAuth credentials for Snowflake - let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined - if (providerId === 'snowflake' && credential.accountId) { - metadata = { accountUrl: credential.accountId } - - // Extract clientId and clientSecret from the password field (stored as JSON) - if (credential.password) { - try { - const oauthCredentials = JSON.parse(credential.password) - metadata.clientId = oauthCredentials.clientId - metadata.clientSecret = oauthCredentials.clientSecret - } catch (e) { - logger.error('Failed to parse Snowflake OAuth credentials', { error: e }) - } - } - } - // Use the existing refreshOAuthToken function - const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!, metadata) + const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!) if (!refreshResult) { logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, { @@ -197,27 +179,9 @@ export async function refreshAccessTokenIfNeeded( if (shouldRefresh) { logger.info(`[${requestId}] Token expired, attempting to refresh for credential`) try { - // Extract account URL and OAuth credentials for Snowflake - let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined - if (credential.providerId === 'snowflake' && credential.accountId) { - metadata = { accountUrl: credential.accountId } - - // Extract clientId and clientSecret from the password field (stored as JSON) - if (credential.password) { - try { - const oauthCredentials = JSON.parse(credential.password) - metadata.clientId = oauthCredentials.clientId - metadata.clientSecret = oauthCredentials.clientSecret - } catch (e) { - logger.error('Failed to parse Snowflake OAuth credentials', { error: e }) - } - } - } - const refreshedToken = await refreshOAuthToken( credential.providerId, - credential.refreshToken!, - metadata + credential.refreshToken! ) if (!refreshedToken) { @@ -289,28 +253,7 @@ export async function refreshTokenIfNeeded( } try { - // Extract account URL and OAuth credentials for Snowflake - let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined - if (credential.providerId === 'snowflake' && credential.accountId) { - metadata = { accountUrl: credential.accountId } - - // Extract clientId and clientSecret from the password field (stored as JSON) - if (credential.password) { - try { - const oauthCredentials = JSON.parse(credential.password) - metadata.clientId = oauthCredentials.clientId - metadata.clientSecret = oauthCredentials.clientSecret - } catch (e) { - logger.error('Failed to parse Snowflake OAuth credentials', { error: e }) - } - } - } - - const refreshResult = await refreshOAuthToken( - credential.providerId, - credential.refreshToken!, - metadata - ) + const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!) if (!refreshResult) { logger.error(`[${requestId}] Failed to refresh token for credential`) diff --git a/apps/sim/app/api/auth/snowflake/authorize/route.ts b/apps/sim/app/api/auth/snowflake/authorize/route.ts deleted file mode 100644 index ba47fcc25a..0000000000 --- a/apps/sim/app/api/auth/snowflake/authorize/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce' -import { getBaseUrl } from '@/lib/urls/utils' - -const logger = createLogger('SnowflakeAuthorize') - -export const dynamic = 'force-dynamic' - -/** - * Initiates Snowflake OAuth flow - * Expects credentials to be posted in the request body (accountUrl, clientId, clientSecret) - */ -export async function POST(request: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn('Unauthorized Snowflake OAuth attempt') - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const { accountUrl, clientId, clientSecret } = body - - if (!accountUrl || !clientId || !clientSecret) { - logger.error('Missing required Snowflake OAuth parameters', { - hasAccountUrl: !!accountUrl, - hasClientId: !!clientId, - hasClientSecret: !!clientSecret, - }) - return NextResponse.json( - { error: 'accountUrl, clientId, and clientSecret are required' }, - { status: 400 } - ) - } - - // Parse and clean the account URL - let cleanAccountUrl = accountUrl.replace(/^https?:\/\//, '') - cleanAccountUrl = cleanAccountUrl.replace(/\/$/, '') - if (!cleanAccountUrl.includes('snowflakecomputing.com')) { - cleanAccountUrl = `${cleanAccountUrl}.snowflakecomputing.com` - } - - const baseUrl = getBaseUrl() - const redirectUri = `${baseUrl}/api/auth/snowflake/callback` - - // Generate PKCE values - const codeVerifier = generateCodeVerifier() - const codeChallenge = await generateCodeChallenge(codeVerifier) - - // Store user-provided credentials in the state (will be used in callback) - const state = Buffer.from( - JSON.stringify({ - userId: session.user.id, - accountUrl: cleanAccountUrl, - clientId, - clientSecret, - timestamp: Date.now(), - codeVerifier, - }) - ).toString('base64url') - - // Construct Snowflake-specific authorization URL - const authUrl = new URL(`https://${cleanAccountUrl}/oauth/authorize`) - authUrl.searchParams.set('client_id', clientId) - authUrl.searchParams.set('response_type', 'code') - authUrl.searchParams.set('redirect_uri', redirectUri) - // Add scope parameter to specify a safe role (not ACCOUNTADMIN or SECURITYADMIN) - authUrl.searchParams.set('scope', 'refresh_token session:role:PUBLIC') - authUrl.searchParams.set('state', state) - // Add PKCE parameters for security and compatibility with OAUTH_ENFORCE_PKCE - authUrl.searchParams.set('code_challenge', codeChallenge) - authUrl.searchParams.set('code_challenge_method', 'S256') - - logger.info('Initiating Snowflake OAuth flow with user-provided credentials (PKCE)', { - userId: session.user.id, - accountUrl: cleanAccountUrl, - hasClientId: !!clientId, - hasClientSecret: !!clientSecret, - redirectUri, - hasPkce: true, - }) - - return NextResponse.json({ - authUrl: authUrl.toString(), - }) - } catch (error) { - logger.error('Error initiating Snowflake authorization:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/auth/snowflake/callback/route.ts b/apps/sim/app/api/auth/snowflake/callback/route.ts deleted file mode 100644 index 206fc0e8cd..0000000000 --- a/apps/sim/app/api/auth/snowflake/callback/route.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { getBaseUrl } from '@/lib/urls/utils' -import { db } from '@/../../packages/db' -import { account } from '@/../../packages/db/schema' - -const logger = createLogger('SnowflakeCallback') - -export const dynamic = 'force-dynamic' - -/** - * Handles Snowflake OAuth callback - */ -export async function GET(request: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn('Unauthorized Snowflake OAuth callback') - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=unauthorized`) - } - - const { searchParams } = new URL(request.url) - const code = searchParams.get('code') - const state = searchParams.get('state') - const error = searchParams.get('error') - const errorDescription = searchParams.get('error_description') - - // Handle OAuth errors - if (error) { - logger.error('Snowflake OAuth error', { error, errorDescription }) - return NextResponse.redirect( - `${getBaseUrl()}/workspace?error=snowflake_${error}&description=${encodeURIComponent(errorDescription || '')}` - ) - } - - if (!code || !state) { - logger.error('Missing code or state in callback') - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_callback`) - } - - // Decode state to get account URL, credentials, and code verifier - let stateData: { - userId: string - accountUrl: string - clientId: string - clientSecret: string - timestamp: number - codeVerifier: string - } - - try { - stateData = JSON.parse(Buffer.from(state, 'base64url').toString()) - logger.info('Decoded state successfully', { - userId: stateData.userId, - accountUrl: stateData.accountUrl, - hasClientId: !!stateData.clientId, - hasClientSecret: !!stateData.clientSecret, - age: Date.now() - stateData.timestamp, - hasCodeVerifier: !!stateData.codeVerifier, - }) - } catch (e) { - logger.error('Invalid state parameter', { error: e, state }) - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_state`) - } - - // Verify the user matches - if (stateData.userId !== session.user.id) { - logger.error('User ID mismatch in state', { - stateUserId: stateData.userId, - sessionUserId: session.user.id, - }) - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_user_mismatch`) - } - - // Verify state is not too old (15 minutes) - if (Date.now() - stateData.timestamp > 15 * 60 * 1000) { - logger.error('State expired', { - age: Date.now() - stateData.timestamp, - }) - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_state_expired`) - } - - // Use user-provided credentials from state - const clientId = stateData.clientId - const clientSecret = stateData.clientSecret - - if (!clientId || !clientSecret) { - logger.error('Missing client credentials in state') - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_missing_credentials`) - } - - // Exchange authorization code for tokens - const tokenUrl = `https://${stateData.accountUrl}/oauth/token-request` - const redirectUri = `${getBaseUrl()}/api/auth/snowflake/callback` - - const tokenParams = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: clientId, - client_secret: clientSecret, - code_verifier: stateData.codeVerifier, - }) - - logger.info('Exchanging authorization code for tokens (with PKCE)', { - tokenUrl, - redirectUri, - clientId, - hasCode: !!code, - hasClientSecret: !!clientSecret, - hasCodeVerifier: !!stateData.codeVerifier, - paramsLength: tokenParams.toString().length, - }) - - const tokenResponse = await fetch(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: tokenParams.toString(), - }) - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text() - logger.error('Failed to exchange code for token', { - status: tokenResponse.status, - statusText: tokenResponse.statusText, - error: errorText, - tokenUrl, - redirectUri, - }) - - // Try to parse error as JSON for better diagnostics - try { - const errorJson = JSON.parse(errorText) - logger.error('Snowflake error details:', errorJson) - } catch (e) { - logger.error('Error text (not JSON):', errorText) - } - - return NextResponse.redirect( - `${getBaseUrl()}/workspace?error=snowflake_token_exchange_failed&details=${encodeURIComponent(errorText)}` - ) - } - - const tokens = await tokenResponse.json() - - logger.info('Token exchange for Snowflake successful', { - hasAccessToken: !!tokens.access_token, - hasRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }) - - if (!tokens.access_token) { - logger.error('No access token in response', { tokens }) - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_no_access_token`) - } - - // Store the account and tokens in the database - const existing = await db.query.account.findFirst({ - where: and(eq(account.userId, session.user.id), eq(account.providerId, 'snowflake')), - }) - - const now = new Date() - const expiresAt = tokens.expires_in - ? new Date(now.getTime() + tokens.expires_in * 1000) - : new Date(now.getTime() + 10 * 60 * 1000) // Default 10 minutes - - // Store user-provided OAuth credentials securely - // We use the password field to store a JSON object with clientId and clientSecret - // and idToken to store the accountUrl for easier retrieval - const oauthCredentials = JSON.stringify({ - clientId: stateData.clientId, - clientSecret: stateData.clientSecret, - }) - - const accountData = { - userId: session.user.id, - providerId: 'snowflake', - accountId: stateData.accountUrl, // Store the Snowflake account URL here - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - idToken: stateData.accountUrl, // Store accountUrl for easier access - password: oauthCredentials, // Store clientId and clientSecret as JSON - accessTokenExpiresAt: expiresAt, - scope: tokens.scope || null, - updatedAt: now, - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - - logger.info('Updated existing Snowflake account', { - userId: session.user.id, - accountUrl: stateData.accountUrl, - }) - } else { - await db.insert(account).values({ - ...accountData, - id: `snowflake_${session.user.id}_${Date.now()}`, - createdAt: now, - }) - - logger.info('Created new Snowflake account', { - userId: session.user.id, - accountUrl: stateData.accountUrl, - }) - } - - return NextResponse.redirect(`${getBaseUrl()}/workspace?snowflake_connected=true`) - } catch (error) { - logger.error('Error in Snowflake callback:', error) - return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_callback_failed`) - } -} 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 af42931875..45c3c39dc7 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 @@ -226,9 +226,6 @@ const SCOPE_DESCRIPTIONS: Record = { 'webhooks:read': 'Read your Pipedrive webhooks', 'webhooks:full': 'Full access to manage your Pipedrive webhooks', w_member_social: 'Access your LinkedIn profile', - // Box scopes - root_readwrite: 'Read and write all files and folders in your Box account', - root_readonly: 'Read all files and folders in your Box account', // Shopify scopes (write_* implicitly includes read access) write_products: 'Read and manage your Shopify products', write_orders: 'Read and manage your Shopify orders', @@ -273,13 +270,6 @@ export function OAuthRequiredModal({ serviceId, newScopes = [], }: OAuthRequiredModalProps) { - const [snowflakeAccountUrl, setSnowflakeAccountUrl] = useState('') - const [snowflakeClientId, setSnowflakeClientId] = useState('') - const [snowflakeClientSecret, setSnowflakeClientSecret] = useState('') - const [accountUrlError, setAccountUrlError] = useState('') - const [clientIdError, setClientIdError] = useState('') - const [clientSecretError, setClientSecretError] = useState('') - const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -310,67 +300,6 @@ export function OAuthRequiredModal({ try { const providerId = getProviderIdFromServiceId(effectiveServiceId) - // Special handling for Snowflake - requires account URL, client ID, and client secret - if (providerId === 'snowflake') { - let hasError = false - - if (!snowflakeAccountUrl.trim()) { - setAccountUrlError('Account URL is required') - hasError = true - } - - if (!snowflakeClientId.trim()) { - setClientIdError('Client ID is required') - hasError = true - } - - if (!snowflakeClientSecret.trim()) { - setClientSecretError('Client Secret is required') - hasError = true - } - - if (hasError) { - return - } - - onClose() - - logger.info('Initiating Snowflake OAuth with user credentials:', { - accountUrl: snowflakeAccountUrl, - hasClientId: !!snowflakeClientId, - hasClientSecret: !!snowflakeClientSecret, - }) - - // Call the authorize endpoint with user credentials - try { - const response = await fetch('/api/auth/snowflake/authorize', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - accountUrl: snowflakeAccountUrl, - clientId: snowflakeClientId, - clientSecret: snowflakeClientSecret, - }), - }) - - if (!response.ok) { - throw new Error('Failed to initiate Snowflake OAuth') - } - - const data = await response.json() - - // Redirect to Snowflake authorization page - window.location.href = data.authUrl - } catch (error) { - logger.error('Error initiating Snowflake OAuth:', error) - // TODO: Show error to user - } - - return - } - onClose() logger.info('Linking OAuth2:', { @@ -401,7 +330,7 @@ export function OAuthRequiredModal({ return ( !open && onClose()}> - + Connect {providerName}
diff --git a/apps/sim/blocks/blocks/snowflake.ts b/apps/sim/blocks/blocks/snowflake.ts index 7ded1bc84b..520e1d2960 100644 --- a/apps/sim/blocks/blocks/snowflake.ts +++ b/apps/sim/blocks/blocks/snowflake.ts @@ -7,7 +7,7 @@ export const SnowflakeBlock: BlockConfig = { type: 'snowflake', name: 'Snowflake', description: 'Execute queries on Snowflake data warehouse', - authMode: AuthMode.OAuth, + authMode: AuthMode.ApiKey, longDescription: 'Integrate Snowflake into your workflow. Execute SQL queries, insert, update, and delete rows, list databases, schemas, and tables, and describe table structures in your Snowflake data warehouse.', docsLink: 'https://docs.sim.ai/tools/snowflake', @@ -36,6 +36,7 @@ export const SnowflakeBlock: BlockConfig = { value: () => 'execute_query', }, { +<<<<<<< HEAD id: 'credential', title: 'Snowflake Account', type: 'oauth-input', @@ -45,10 +46,22 @@ export const SnowflakeBlock: BlockConfig = { required: true, }, { +======= +>>>>>>> 8de761181 (reformatted to PAT from oauth) id: 'accountUrl', title: 'Account URL', type: 'short-input', placeholder: 'your-account.snowflakecomputing.com', + description: 'Your Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', + required: true, + }, + { + id: 'accessToken', + title: 'Personal Access Token', + type: 'short-input', + placeholder: 'Enter your Snowflake PAT', + description: 'Generate a PAT in Snowflake Snowsight', + password: true, required: true, }, { @@ -376,11 +389,11 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t } }, params: (params) => { - const { credential, operation, ...rest } = params + const { operation, ...rest } = params - // Build base params + // Build base params - use PAT directly as accessToken const baseParams: Record = { - credential, + accessToken: params.accessToken, accountUrl: params.accountUrl, } @@ -559,11 +572,14 @@ Return ONLY the SQL query - no explanations, no markdown code blocks, no extra t }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, - credential: { type: 'string', description: 'Snowflake OAuth credential' }, accountUrl: { type: 'string', description: 'Snowflake account URL (e.g., xy12345.us-east-1.snowflakecomputing.com)', }, + accessToken: { + type: 'string', + description: 'Snowflake Personal Access Token (PAT)', + }, warehouse: { type: 'string', description: 'Warehouse name' }, role: { type: 'string', description: 'Role name' }, query: { type: 'string', description: 'SQL query to execute' }, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index cc04f59519..ac5ec6ffe0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -221,8 +221,6 @@ export const env = createEnv({ REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID WEBFLOW_CLIENT_SECRET: z.string().optional(), // Webflow OAuth client secret - SNOWFLAKE_CLIENT_ID: z.string().optional(), // Snowflake OAuth client ID - SNOWFLAKE_CLIENT_SECRET: z.string().optional(), // Snowflake OAuth client secret TRELLO_API_KEY: z.string().optional(), // Trello API Key LINKEDIN_CLIENT_ID: z.string().optional(), // LinkedIn OAuth client ID LINKEDIN_CLIENT_SECRET: z.string().optional(), // LinkedIn OAuth client secret diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index f409ee41ec..e6a2213aa8 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -30,7 +30,6 @@ import { SalesforceIcon, ShopifyIcon, SlackIcon, - SnowflakeIcon, TrelloIcon, WealthboxIcon, WebflowIcon, @@ -111,6 +110,7 @@ export type OAuthService = | 'zoom' | 'wordpress' | 'snowflake' + export interface OAuthProviderConfig { id: OAuthProvider name: string @@ -1529,44 +1529,17 @@ function buildAuthRequest( * This is a server-side utility function to refresh OAuth tokens * @param providerId The provider ID (e.g., 'google-drive') * @param refreshToken The refresh token to use - * @param metadata Optional metadata (e.g., accountUrl, clientId, clientSecret for Snowflake) * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ export async function refreshOAuthToken( providerId: string, - refreshToken: string, - metadata?: { accountUrl?: string; clientId?: string; clientSecret?: string } + refreshToken: string ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { // Get the provider from the providerId (e.g., 'google-drive' -> 'google') const provider = providerId.split('-')[0] - let config: ProviderAuthConfig - - if (provider === 'snowflake' && metadata?.clientId && metadata?.clientSecret) { - config = { - tokenEndpoint: `https://${metadata.accountUrl}/oauth/token-request`, - clientId: metadata.clientId, - clientSecret: metadata.clientSecret, - useBasicAuth: false, - supportsRefreshTokenRotation: true, - } - logger.info('Using user-provided Snowflake OAuth credentials for token refresh', { - accountUrl: metadata.accountUrl, - hasClientId: !!metadata.clientId, - hasClientSecret: !!metadata.clientSecret, - }) - } else { - config = getProviderAuthConfig(provider) - - // For Snowflake without user credentials, use the account-specific token endpoint - if (provider === 'snowflake' && metadata?.accountUrl) { - config.tokenEndpoint = `https://${metadata.accountUrl}/oauth/token-request` - logger.info('Using Snowflake account-specific token endpoint', { - accountUrl: metadata.accountUrl, - }) - } - } + const config = getProviderAuthConfig(provider) // Build authentication request const { headers, bodyParams } = buildAuthRequest(config, refreshToken) diff --git a/apps/sim/tools/snowflake/delete_rows.ts b/apps/sim/tools/snowflake/delete_rows.ts index 0b7cc75571..ff3038fb4b 100644 --- a/apps/sim/tools/snowflake/delete_rows.ts +++ b/apps/sim/tools/snowflake/delete_rows.ts @@ -41,17 +41,12 @@ export const snowflakeDeleteRowsTool: ToolConfig< description: 'Delete rows from a Snowflake table', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -113,7 +108,7 @@ export const snowflakeDeleteRowsTool: ToolConfig< headers: (params: SnowflakeDeleteRowsParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeDeleteRowsParams) => { // Build DELETE SQL diff --git a/apps/sim/tools/snowflake/describe_table.ts b/apps/sim/tools/snowflake/describe_table.ts index c6328d2174..5e84c7ffe5 100644 --- a/apps/sim/tools/snowflake/describe_table.ts +++ b/apps/sim/tools/snowflake/describe_table.ts @@ -17,17 +17,12 @@ export const snowflakeDescribeTableTool: ToolConfig< description: 'Get the schema and structure of a Snowflake table', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -76,7 +71,7 @@ export const snowflakeDescribeTableTool: ToolConfig< headers: (params: SnowflakeDescribeTableParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeDescribeTableParams) => { const sanitizedDatabase = sanitizeIdentifier(params.database) diff --git a/apps/sim/tools/snowflake/execute_query.ts b/apps/sim/tools/snowflake/execute_query.ts index 82218e4a02..340b73b5f5 100644 --- a/apps/sim/tools/snowflake/execute_query.ts +++ b/apps/sim/tools/snowflake/execute_query.ts @@ -21,17 +21,12 @@ export const snowflakeExecuteQueryTool: ToolConfig< description: 'Execute a SQL query on your Snowflake data warehouse', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -86,7 +81,7 @@ export const snowflakeExecuteQueryTool: ToolConfig< headers: (params: SnowflakeExecuteQueryParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeExecuteQueryParams) => { const requestBody: Record = { diff --git a/apps/sim/tools/snowflake/insert_rows.ts b/apps/sim/tools/snowflake/insert_rows.ts index 5e9d905eeb..2d0c12d5e2 100644 --- a/apps/sim/tools/snowflake/insert_rows.ts +++ b/apps/sim/tools/snowflake/insert_rows.ts @@ -56,17 +56,12 @@ export const snowflakeInsertRowsTool: ToolConfig< description: 'Insert rows into a Snowflake table', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -134,7 +129,7 @@ export const snowflakeInsertRowsTool: ToolConfig< headers: (params: SnowflakeInsertRowsParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeInsertRowsParams) => { // Validate inputs diff --git a/apps/sim/tools/snowflake/list_databases.ts b/apps/sim/tools/snowflake/list_databases.ts index abe60571a1..0ebfb55ff2 100644 --- a/apps/sim/tools/snowflake/list_databases.ts +++ b/apps/sim/tools/snowflake/list_databases.ts @@ -17,17 +17,12 @@ export const snowflakeListDatabasesTool: ToolConfig< description: 'List all databases in your Snowflake account', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -58,7 +53,7 @@ export const snowflakeListDatabasesTool: ToolConfig< headers: (params: SnowflakeListDatabasesParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListDatabasesParams) => { const requestBody: Record = { diff --git a/apps/sim/tools/snowflake/list_file_formats.ts b/apps/sim/tools/snowflake/list_file_formats.ts index 7171c342ee..265f36bf97 100644 --- a/apps/sim/tools/snowflake/list_file_formats.ts +++ b/apps/sim/tools/snowflake/list_file_formats.ts @@ -17,17 +17,12 @@ export const snowflakeListFileFormatsTool: ToolConfig< description: 'List all file formats in a Snowflake schema', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -70,7 +65,7 @@ export const snowflakeListFileFormatsTool: ToolConfig< headers: (params: SnowflakeListFileFormatsParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListFileFormatsParams) => { const requestBody: Record = { diff --git a/apps/sim/tools/snowflake/list_schemas.ts b/apps/sim/tools/snowflake/list_schemas.ts index c30cdfa06e..7514f99022 100644 --- a/apps/sim/tools/snowflake/list_schemas.ts +++ b/apps/sim/tools/snowflake/list_schemas.ts @@ -17,17 +17,12 @@ export const snowflakeListSchemasTool: ToolConfig< description: 'List all schemas in a Snowflake database', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -64,7 +59,7 @@ export const snowflakeListSchemasTool: ToolConfig< headers: (params: SnowflakeListSchemasParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListSchemasParams) => { const sanitizedDatabase = sanitizeIdentifier(params.database) diff --git a/apps/sim/tools/snowflake/list_stages.ts b/apps/sim/tools/snowflake/list_stages.ts index b834216fdf..3d70e79793 100644 --- a/apps/sim/tools/snowflake/list_stages.ts +++ b/apps/sim/tools/snowflake/list_stages.ts @@ -17,17 +17,12 @@ export const snowflakeListStagesTool: ToolConfig< description: 'List all stages in a Snowflake schema', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -70,7 +65,7 @@ export const snowflakeListStagesTool: ToolConfig< headers: (params: SnowflakeListStagesParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListStagesParams) => { const requestBody: Record = { diff --git a/apps/sim/tools/snowflake/list_tables.ts b/apps/sim/tools/snowflake/list_tables.ts index 82d1e533cf..4df9a9d674 100644 --- a/apps/sim/tools/snowflake/list_tables.ts +++ b/apps/sim/tools/snowflake/list_tables.ts @@ -17,17 +17,12 @@ export const snowflakeListTablesTool: ToolConfig< description: 'List all tables in a Snowflake schema', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -70,7 +65,7 @@ export const snowflakeListTablesTool: ToolConfig< headers: (params: SnowflakeListTablesParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListTablesParams) => { const sanitizedDatabase = sanitizeIdentifier(params.database) diff --git a/apps/sim/tools/snowflake/list_views.ts b/apps/sim/tools/snowflake/list_views.ts index 0665237465..f9973273bd 100644 --- a/apps/sim/tools/snowflake/list_views.ts +++ b/apps/sim/tools/snowflake/list_views.ts @@ -14,17 +14,12 @@ export const snowflakeListViewsTool: ToolConfig< description: 'List all views in a Snowflake schema', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -67,7 +62,7 @@ export const snowflakeListViewsTool: ToolConfig< headers: (params: SnowflakeListViewsParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListViewsParams) => { const requestBody: Record = { diff --git a/apps/sim/tools/snowflake/list_warehouses.ts b/apps/sim/tools/snowflake/list_warehouses.ts index 36a0c0c171..79e2d1eba3 100644 --- a/apps/sim/tools/snowflake/list_warehouses.ts +++ b/apps/sim/tools/snowflake/list_warehouses.ts @@ -17,17 +17,12 @@ export const snowflakeListWarehousesTool: ToolConfig< description: 'List all warehouses in the Snowflake account', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -58,7 +53,7 @@ export const snowflakeListWarehousesTool: ToolConfig< headers: (params: SnowflakeListWarehousesParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeListWarehousesParams) => { const requestBody: Record = { diff --git a/apps/sim/tools/snowflake/update_rows.ts b/apps/sim/tools/snowflake/update_rows.ts index 37c859d403..ce807f005b 100644 --- a/apps/sim/tools/snowflake/update_rows.ts +++ b/apps/sim/tools/snowflake/update_rows.ts @@ -63,17 +63,12 @@ export const snowflakeUpdateRowsTool: ToolConfig< description: 'Update rows in a Snowflake table', version: '1.0.0', - oauth: { - required: true, - provider: 'snowflake', - }, - params: { accessToken: { type: 'string', required: true, visibility: 'hidden', - description: 'OAuth access token for Snowflake', + description: 'Snowflake Personal Access Token (PAT)', }, accountUrl: { type: 'string', @@ -142,7 +137,7 @@ export const snowflakeUpdateRowsTool: ToolConfig< headers: (params: SnowflakeUpdateRowsParams) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${params.accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }), body: (params: SnowflakeUpdateRowsParams) => { // Validate inputs diff --git a/apps/sim/tools/snowflake/utils.ts b/apps/sim/tools/snowflake/utils.ts index b2c32de2f6..daf77d4ca3 100644 --- a/apps/sim/tools/snowflake/utils.ts +++ b/apps/sim/tools/snowflake/utils.ts @@ -66,7 +66,7 @@ export async function executeSnowflakeStatement( headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, - 'X-Snowflake-Authorization-Token-Type': 'OAUTH', + 'X-Snowflake-Authorization-Token-Type': 'PROGRAMMATIC_ACCESS_TOKEN', }, body: JSON.stringify(requestBody), }) From 248ebac78b93dfb19f792e37af3dc7446603439e Mon Sep 17 00:00:00 2001 From: aadamgough Date: Fri, 5 Dec 2025 12:48:47 -0800 Subject: [PATCH 09/12] removed unnecessary file and reverted oauth.ts --- apps/sim/lib/oauth/oauth.ts | 2 ++ apps/sim/lib/oauth/pkce.ts | 30 ------------------------------ 2 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 apps/sim/lib/oauth/pkce.ts diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index e6a2213aa8..3a62e147f6 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -30,6 +30,7 @@ import { SalesforceIcon, ShopifyIcon, SlackIcon, + // SupabaseIcon, TrelloIcon, WealthboxIcon, WebflowIcon, @@ -1539,6 +1540,7 @@ export async function refreshOAuthToken( // Get the provider from the providerId (e.g., 'google-drive' -> 'google') const provider = providerId.split('-')[0] + // Get provider configuration const config = getProviderAuthConfig(provider) // Build authentication request diff --git a/apps/sim/lib/oauth/pkce.ts b/apps/sim/lib/oauth/pkce.ts deleted file mode 100644 index fdbe60803d..0000000000 --- a/apps/sim/lib/oauth/pkce.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('PKCE') - -/** - * Generate a cryptographically secure random string for PKCE code verifier - */ -export function generateCodeVerifier(): string { - const array = new Uint8Array(32) - crypto.getRandomValues(array) - return base64URLEncode(array) -} - -/** - * Generate PKCE code challenge from verifier using SHA-256 - */ -export async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(verifier) - const hash = await crypto.subtle.digest('SHA-256', data) - return base64URLEncode(new Uint8Array(hash)) -} - -/** - * Base64URL encode without padding - */ -function base64URLEncode(buffer: Uint8Array): string { - const base64 = btoa(String.fromCharCode(...buffer)) - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') -} From addd05bb8ebd2978369a70fa76a4ffbac5ff4caf Mon Sep 17 00:00:00 2001 From: aadamgough Date: Sat, 6 Dec 2025 14:47:24 -0800 Subject: [PATCH 10/12] added env to env.ts --- apps/sim/lib/core/config/env.ts | 2 ++ apps/sim/lib/oauth/oauth.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ac5ec6ffe0..490df1830f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -230,6 +230,8 @@ export const env = createEnv({ ZOOM_CLIENT_SECRET: z.string().optional(), // Zoom OAuth client secret WORDPRESS_CLIENT_ID: z.string().optional(), // WordPress.com OAuth client ID WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret + SNOWFLAKE_CLIENT_ID: z.string().optional(), // Snowflake OAuth client ID + SNOWFLAKE_CLIENT_SECRET: z.string().optional(), // Snowflake OAuth client secret // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 3a62e147f6..76e05b6267 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -30,6 +30,7 @@ import { SalesforceIcon, ShopifyIcon, SlackIcon, + SnowflakeIcon, // SupabaseIcon, TrelloIcon, WealthboxIcon, From 7671ec35e8fbf69845471adfdf32d77f0ed87a72 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Sat, 6 Dec 2025 15:26:54 -0800 Subject: [PATCH 11/12] fix --- .../components/oauth-required-modal.tsx | 7 ++-- apps/sim/blocks/blocks/snowflake.ts | 12 ------- apps/sim/lib/core/config/env.ts | 2 -- apps/sim/lib/oauth/oauth.ts | 35 ------------------- 4 files changed, 4 insertions(+), 52 deletions(-) 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 45c3c39dc7..9d5eec3f5b 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 @@ -1,7 +1,6 @@ 'use client' import { Check } from 'lucide-react' -import { useState } from 'react' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' import { client } from '@/lib/auth/auth-client' import { createLogger } from '@/lib/logs/console/logger' @@ -13,7 +12,6 @@ import { parseProvider, } from '@/lib/oauth' - const logger = createLogger('OAuthRequiredModal') export interface OAuthRequiredModalProps { @@ -226,6 +224,9 @@ const SCOPE_DESCRIPTIONS: Record = { 'webhooks:read': 'Read your Pipedrive webhooks', 'webhooks:full': 'Full access to manage your Pipedrive webhooks', w_member_social: 'Access your LinkedIn profile', + // Box scopes + root_readwrite: 'Read and write all files and folders in your Box account', + root_readonly: 'Read all files and folders in your Box account', // Shopify scopes (write_* implicitly includes read access) write_products: 'Read and manage your Shopify products', write_orders: 'Read and manage your Shopify orders', @@ -392,4 +393,4 @@ export function OAuthRequiredModal({ ) -} \ No newline at end of file +} diff --git a/apps/sim/blocks/blocks/snowflake.ts b/apps/sim/blocks/blocks/snowflake.ts index 520e1d2960..24d5e047af 100644 --- a/apps/sim/blocks/blocks/snowflake.ts +++ b/apps/sim/blocks/blocks/snowflake.ts @@ -36,18 +36,6 @@ export const SnowflakeBlock: BlockConfig = { value: () => 'execute_query', }, { -<<<<<<< HEAD - id: 'credential', - title: 'Snowflake Account', - type: 'oauth-input', - serviceId: 'snowflake', - requiredScopes: [], - placeholder: 'Select Snowflake account', - required: true, - }, - { -======= ->>>>>>> 8de761181 (reformatted to PAT from oauth) id: 'accountUrl', title: 'Account URL', type: 'short-input', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 490df1830f..ac5ec6ffe0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -230,8 +230,6 @@ export const env = createEnv({ ZOOM_CLIENT_SECRET: z.string().optional(), // Zoom OAuth client secret WORDPRESS_CLIENT_ID: z.string().optional(), // WordPress.com OAuth client ID WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret - SNOWFLAKE_CLIENT_ID: z.string().optional(), // Snowflake OAuth client ID - SNOWFLAKE_CLIENT_SECRET: z.string().optional(), // Snowflake OAuth client secret // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 76e05b6267..f82e744f50 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -30,7 +30,6 @@ import { SalesforceIcon, ShopifyIcon, SlackIcon, - SnowflakeIcon, // SupabaseIcon, TrelloIcon, WealthboxIcon, @@ -70,7 +69,6 @@ export type OAuthProvider = | 'shopify' | 'zoom' | 'wordpress' - | 'snowflake' | string export type OAuthService = @@ -111,7 +109,6 @@ export type OAuthService = | 'shopify' | 'zoom' | 'wordpress' - | 'snowflake' export interface OAuthProviderConfig { id: OAuthProvider @@ -834,23 +831,6 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'salesforce', }, - snowflake: { - id: 'snowflake', - name: 'Snowflake', - icon: (props) => SnowflakeIcon(props), - services: { - snowflake: { - id: 'snowflake', - name: 'Snowflake', - description: 'Execute queries and manage data in your Snowflake data warehouse.', - providerId: 'snowflake', - icon: (props) => SnowflakeIcon(props), - baseProviderIcon: (props) => SnowflakeIcon(props), - scopes: [], - }, - }, - defaultService: 'snowflake', - }, zoom: { id: 'zoom', name: 'Zoom', @@ -1436,21 +1416,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } - case 'snowflake': { - const { clientId, clientSecret } = getCredentials( - env.SNOWFLAKE_CLIENT_ID, - env.SNOWFLAKE_CLIENT_SECRET - ) - // Note: For Snowflake, the tokenEndpoint is account-specific - // The actual URL will be constructed dynamically in refreshOAuthToken - return { - tokenEndpoint: 'https://placeholder.snowflakecomputing.com/oauth/token-request', - clientId, - clientSecret, - useBasicAuth: false, - supportsRefreshTokenRotation: true, - } - } case 'shopify': { // Shopify access tokens don't expire and don't support refresh tokens // This configuration is provided for completeness but won't be used for token refresh From e7936f7e1d0cdb38e0949eddd5aea164242763de Mon Sep 17 00:00:00 2001 From: aadamgough Date: Sat, 6 Dec 2025 15:35:08 -0800 Subject: [PATCH 12/12] removed comments and utils --- apps/sim/app/api/auth/oauth/utils.ts | 2 -- apps/sim/tools/snowflake/insert_rows.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index dad58eb610..7a09891b57 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -67,8 +67,6 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessToken: account.accessToken, refreshToken: account.refreshToken, accessTokenExpiresAt: account.accessTokenExpiresAt, - accountId: account.accountId, - providerId: account.providerId, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) diff --git a/apps/sim/tools/snowflake/insert_rows.ts b/apps/sim/tools/snowflake/insert_rows.ts index 2d0c12d5e2..60d381f21c 100644 --- a/apps/sim/tools/snowflake/insert_rows.ts +++ b/apps/sim/tools/snowflake/insert_rows.ts @@ -32,7 +32,6 @@ function buildInsertSQL( return 'NULL' } if (typeof val === 'string') { - // Escape single quotes by doubling them return `'${val.replace(/'/g, "''")}'` } if (typeof val === 'boolean') { @@ -153,7 +152,6 @@ export const snowflakeInsertRowsTool: ToolConfig< } } - // Build INSERT SQL const insertSQL = buildInsertSQL( params.database, params.schema, @@ -202,7 +200,6 @@ export const snowflakeInsertRowsTool: ToolConfig< const data = await response.json() - // Get number of rows inserted from response const rowsInserted = data.statementStatusUrl ? params?.values.length || 0 : 0 return {