Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const dynamic = 'force-dynamic'

const logger = createLogger('OAuthTokenAPI')

const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/

const tokenRequestSchema = z.object({
credentialId: z
.string({ required_error: 'Credential ID is required' })
Expand Down Expand Up @@ -78,10 +80,20 @@ export async function POST(request: NextRequest) {
try {
// Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)

let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}

return NextResponse.json(
{
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
},
{ status: 200 }
)
Expand Down Expand Up @@ -147,10 +159,21 @@ export async function GET(request: NextRequest) {

try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)

// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}

return NextResponse.json(
{
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
},
{ status: 200 }
)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/blocks/blocks/pinecone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export const PineconeBlock: BlockConfig<PineconeResponse> = {

outputs: {
matches: { type: 'json', description: 'Search matches' },
upsertedCount: { type: 'number', description: 'Upserted count' },
statusText: { type: 'string', description: 'Status of the upsert operation' },
data: { type: 'json', description: 'Response data' },
model: { type: 'string', description: 'Model information' },
vector_type: { type: 'string', description: 'Vector type' },
Expand Down
17 changes: 12 additions & 5 deletions apps/sim/blocks/blocks/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,13 +596,20 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
},
// Storage Upload fields
{
id: 'path',
title: 'File Path',
id: 'fileName',
title: 'File Name',
type: 'short-input',
placeholder: 'folder/file.jpg',
placeholder: 'myfile.pdf',
condition: { field: 'operation', value: 'storage_upload' },
required: true,
},
{
id: 'path',
title: 'Folder Path (optional)',
type: 'short-input',
placeholder: 'folder/subfolder/',
condition: { field: 'operation', value: 'storage_upload' },
},
{
id: 'fileContent',
title: 'File Content',
Expand Down Expand Up @@ -1065,10 +1072,10 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
countType: { type: 'string', description: 'Count type: exact, planned, or estimated' },
// Storage operation inputs
bucket: { type: 'string', description: 'Storage bucket name' },
path: { type: 'string', description: 'File path in storage' },
path: { type: 'string', description: 'File or folder path in storage' },
fileContent: { type: 'string', description: 'File content (base64 for binary)' },
contentType: { type: 'string', description: 'MIME type of the file' },
fileName: { type: 'string', description: 'Optional filename override for downloaded file' },
fileName: { type: 'string', description: 'File name for upload or download override' },
upsert: { type: 'boolean', description: 'Whether to overwrite existing file' },
download: { type: 'boolean', description: 'Whether to force download' },
paths: { type: 'array', description: 'Array of file paths' },
Expand Down
73 changes: 71 additions & 2 deletions apps/sim/blocks/blocks/typeform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,76 @@ export const TypeformBlock: BlockConfig<TypeformResponse> = {
{
id: 'operations',
title: 'JSON Patch Operations',
type: 'long-input',
placeholder: 'JSON array of patch operations (RFC 6902)',
type: 'code',
language: 'json',
placeholder: '[{"op": "replace", "path": "/title", "value": "New Title"}]',
condition: { field: 'operation', value: 'typeform_update_form' },
required: true,
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert at creating JSON Patch operations (RFC 6902) for Typeform forms.
Generate ONLY the JSON array of patch operations based on the user's request.
The output MUST be a valid JSON array, starting with [ and ending with ].

Current operations: {context}

### JSON PATCH OPERATIONS
Each operation is an object with:
- "op": The operation type ("add", "remove", "replace", "move", "copy", "test")
- "path": JSON pointer to the target location (e.g., "/title", "/fields/0", "/settings/language")
- "value": The new value (required for "add", "replace", "copy", "test")
- "from": Source path (required for "move" and "copy")

### COMMON TYPEFORM PATHS
- /title - Form title
- /settings/language - Form language (e.g., "en", "es", "fr")
- /settings/is_public - Whether form is public (true/false)
- /settings/show_progress_bar - Show progress bar (true/false)
- /fields - Array of form fields
- /fields/- - Add to end of fields array
- /fields/0 - First field
- /welcome_screens - Array of welcome screens
- /thankyou_screens - Array of thank you screens
- /theme/href - Theme URL reference

### FIELD OBJECT STRUCTURE
{
"type": "short_text" | "long_text" | "email" | "number" | "multiple_choice" | "yes_no" | "rating" | "date" | "dropdown" | "file_upload",
"title": "Question text",
"ref": "unique_reference_id",
"properties": { ... },
"validations": { "required": true/false }
}

### EXAMPLES

**Change form title:**
[{"op": "replace", "path": "/title", "value": "My Updated Form"}]

**Add a new text field:**
[{"op": "add", "path": "/fields/-", "value": {"type": "short_text", "title": "What is your name?", "ref": "name_field", "validations": {"required": true}}}]

**Add multiple choice field:**
[{"op": "add", "path": "/fields/-", "value": {"type": "multiple_choice", "title": "Select your favorite color", "ref": "color_field", "properties": {"choices": [{"label": "Red"}, {"label": "Blue"}, {"label": "Green"}]}}}]

**Remove first field:**
[{"op": "remove", "path": "/fields/0"}]

**Update form settings:**
[{"op": "replace", "path": "/settings/language", "value": "es"}, {"op": "replace", "path": "/settings/is_public", "value": false}]

**Multiple operations:**
[
{"op": "replace", "path": "/title", "value": "Customer Feedback Form"},
{"op": "add", "path": "/fields/-", "value": {"type": "rating", "title": "Rate your experience", "ref": "rating_field", "properties": {"steps": 5}}},
{"op": "replace", "path": "/settings/show_progress_bar", "value": true}
]

Do not include any explanations, markdown formatting, or other text outside the JSON array.`,
placeholder: 'Describe how you want to update the form...',
generationType: 'json-object',
},
},
...getTrigger('typeform_webhook').subBlocks,
],
Expand Down Expand Up @@ -322,6 +388,9 @@ export const TypeformBlock: BlockConfig<TypeformResponse> = {
fields: { type: 'json', description: 'Form fields array' },
welcome_screens: { type: 'json', description: 'Welcome screens array' },
thankyou_screens: { type: 'json', description: 'Thank you screens array' },
created_at: { type: 'string', description: 'Form creation timestamp' },
last_updated_at: { type: 'string', description: 'Form last update timestamp' },
published_at: { type: 'string', description: 'Form publication timestamp' },
_links: { type: 'json', description: 'Related resource links' },
// Delete form outputs
deleted: { type: 'boolean', description: 'Whether the form was deleted' },
Expand Down
90 changes: 69 additions & 21 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,35 @@ export const auth = betterAuth({
})

if (existing) {
let scopeToStore = account.scope

if (account.providerId === 'salesforce' && account.accessToken) {
try {
const response = await fetch(
'https://login.salesforce.com/services/oauth2/userinfo',
{
headers: {
Authorization: `Bearer ${account.accessToken}`,
},
}
)

if (response.ok) {
const data = await response.json()

if (data.profile) {
const match = data.profile.match(/^(https:\/\/[^/]+)/)
if (match && match[1] !== 'https://login.salesforce.com') {
const instanceUrl = match[1]
scopeToStore = `__sf_instance__:${instanceUrl} ${account.scope}`
}
}
}
} catch (error) {
logger.error('Failed to fetch Salesforce instance URL', { error })
}
}

await db
.update(schema.account)
.set({
Expand All @@ -129,7 +158,7 @@ export const auth = betterAuth({
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope,
scope: scopeToStore,
updatedAt: new Date(),
})
.where(eq(schema.account.id, existing.id))
Expand All @@ -140,24 +169,45 @@ export const auth = betterAuth({
return { data: account }
},
after: async (account) => {
// Salesforce doesn't return expires_in in its token response (unlike other OAuth providers).
// We set a default 2-hour expiration so token refresh logic works correctly.
if (account.providerId === 'salesforce' && !account.accessTokenExpiresAt) {
const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000)
try {
await db
.update(schema.account)
.set({ accessTokenExpiresAt: twoHoursFromNow })
.where(eq(schema.account.id, account.id))
logger.info(
'[databaseHooks.account.create.after] Set default expiration for Salesforce token',
{ accountId: account.id, expiresAt: twoHoursFromNow }
)
} catch (error) {
logger.error(
'[databaseHooks.account.create.after] Failed to set Salesforce token expiration',
{ accountId: account.id, error }
)
if (account.providerId === 'salesforce') {
const updates: {
accessTokenExpiresAt?: Date
scope?: string
} = {}

if (!account.accessTokenExpiresAt) {
updates.accessTokenExpiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000)
}

if (account.accessToken) {
try {
const response = await fetch(
'https://login.salesforce.com/services/oauth2/userinfo',
{
headers: {
Authorization: `Bearer ${account.accessToken}`,
},
}
)

if (response.ok) {
const data = await response.json()

if (data.profile) {
const match = data.profile.match(/^(https:\/\/[^/]+)/)
if (match && match[1] !== 'https://login.salesforce.com') {
const instanceUrl = match[1]
updates.scope = `__sf_instance__:${instanceUrl} ${account.scope}`
}
}
}
} catch (error) {
logger.error('Failed to fetch Salesforce instance URL', { error })
}
}

if (Object.keys(updates).length > 0) {
await db.update(schema.account).set(updates).where(eq(schema.account.id, account.id))
}
}
},
Expand Down Expand Up @@ -928,8 +978,6 @@ export const auth = betterAuth({
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`,
getUserInfo: async (tokens) => {
try {
logger.info('Fetching Salesforce user profile')

const response = await fetch(
'https://login.salesforce.com/services/oauth2/userinfo',
{
Expand Down
Loading
Loading