Skip to content

Commit d9dec98

Browse files
committed
fix(tools): manage lifecycle for attio tools
1 parent 5cbb712 commit d9dec98

26 files changed

+371
-269
lines changed

apps/sim/blocks/blocks/attio.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,7 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text.
425425
id: 'taskAssignees',
426426
title: 'Assignees',
427427
type: 'code',
428-
placeholder:
429-
'[{"referenced_actor_type":"workspace-member","referenced_actor_id":"..."}]',
428+
placeholder: '[{"referenced_actor_type":"workspace-member","referenced_actor_id":"..."}]',
430429
condition: { field: 'operation', value: ['create_task', 'update_task'] },
431430
wandConfig: {
432431
enabled: true,
@@ -869,15 +868,15 @@ YYYY-MM-DDTHH:mm:ss.SSSZ
869868
type: 'short-input',
870869
placeholder: 'https://example.com/webhook',
871870
condition: { field: 'operation', value: ['create_webhook', 'update_webhook'] },
872-
required: { field: 'operation', value: 'create_webhook' },
871+
required: { field: 'operation', value: ['create_webhook', 'update_webhook'] },
873872
},
874873
{
875874
id: 'webhookSubscriptions',
876875
title: 'Subscriptions',
877876
type: 'code',
878877
placeholder: '[{"event_type":"record.created","filter":{"object_id":"..."}}]',
879878
condition: { field: 'operation', value: ['create_webhook', 'update_webhook'] },
880-
required: { field: 'operation', value: 'create_webhook' },
879+
required: { field: 'operation', value: ['create_webhook', 'update_webhook'] },
881880
wandConfig: {
882881
enabled: true,
883882
maintainHistory: true,

apps/sim/lib/webhooks/processor.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
1313
import {
1414
handleSlackChallenge,
1515
handleWhatsAppVerification,
16+
validateAttioSignature,
1617
validateCalcomSignature,
1718
validateCirclebackSignature,
1819
validateFirefliesSignature,
@@ -597,6 +598,29 @@ export async function verifyProviderAuth(
597598
}
598599
}
599600

601+
if (foundWebhook.provider === 'attio') {
602+
const secret = providerConfig.webhookSecret as string | undefined
603+
604+
if (secret) {
605+
const signature = request.headers.get('Attio-Signature')
606+
607+
if (!signature) {
608+
logger.warn(`[${requestId}] Attio webhook missing signature header`)
609+
return new NextResponse('Unauthorized - Missing Attio signature', { status: 401 })
610+
}
611+
612+
const isValidSignature = validateAttioSignature(secret, signature, rawBody)
613+
614+
if (!isValidSignature) {
615+
logger.warn(`[${requestId}] Attio signature verification failed`, {
616+
signatureLength: signature.length,
617+
secretLength: secret.length,
618+
})
619+
return new NextResponse('Unauthorized - Invalid Attio signature', { status: 401 })
620+
}
621+
}
622+
}
623+
600624
if (foundWebhook.provider === 'linear') {
601625
const secret = providerConfig.webhookSecret as string | undefined
602626

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const calendlyLogger = createLogger('CalendlyWebhook')
1919
const grainLogger = createLogger('GrainWebhook')
2020
const lemlistLogger = createLogger('LemlistWebhook')
2121
const webflowLogger = createLogger('WebflowWebhook')
22+
const attioLogger = createLogger('AttioWebhook')
2223
const providerSubscriptionsLogger = createLogger('WebhookProviderSubscriptions')
2324

2425
function getProviderConfig(webhook: any): Record<string, any> {
@@ -976,6 +977,196 @@ export async function deleteWebflowWebhook(
976977
}
977978
}
978979

980+
export async function createAttioWebhookSubscription(
981+
userId: string,
982+
webhookData: any,
983+
requestId: string
984+
): Promise<{ externalId: string; webhookSecret: string } | undefined> {
985+
try {
986+
const { path, providerConfig } = webhookData
987+
const { triggerId, credentialId } = providerConfig || {}
988+
989+
if (!credentialId) {
990+
attioLogger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, {
991+
webhookId: webhookData.id,
992+
})
993+
throw new Error(
994+
'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.'
995+
)
996+
}
997+
998+
const credentialOwner = await getCredentialOwner(credentialId, requestId)
999+
const accessToken = credentialOwner
1000+
? await refreshAccessTokenIfNeeded(
1001+
credentialOwner.accountId,
1002+
credentialOwner.userId,
1003+
requestId
1004+
)
1005+
: null
1006+
1007+
if (!accessToken) {
1008+
attioLogger.warn(
1009+
`[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.`
1010+
)
1011+
throw new Error(
1012+
'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.'
1013+
)
1014+
}
1015+
1016+
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
1017+
1018+
const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils')
1019+
1020+
let subscriptions: Array<{ event_type: string }> = []
1021+
if (triggerId === 'attio_webhook') {
1022+
const allEvents = new Set<string>()
1023+
for (const events of Object.values(TRIGGER_EVENT_MAP)) {
1024+
for (const event of events) {
1025+
allEvents.add(event)
1026+
}
1027+
}
1028+
subscriptions = Array.from(allEvents).map((event_type) => ({ event_type }))
1029+
} else {
1030+
const events = TRIGGER_EVENT_MAP[triggerId]
1031+
if (!events || events.length === 0) {
1032+
attioLogger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, {
1033+
webhookId: webhookData.id,
1034+
})
1035+
throw new Error(`Unknown Attio trigger type: ${triggerId}`)
1036+
}
1037+
subscriptions = events.map((event_type) => ({ event_type }))
1038+
}
1039+
1040+
const requestBody = {
1041+
target_url: notificationUrl,
1042+
subscriptions,
1043+
}
1044+
1045+
const attioResponse = await fetch('https://api.attio.com/v2/webhooks', {
1046+
method: 'POST',
1047+
headers: {
1048+
Authorization: `Bearer ${accessToken}`,
1049+
'Content-Type': 'application/json',
1050+
},
1051+
body: JSON.stringify(requestBody),
1052+
})
1053+
1054+
if (!attioResponse.ok) {
1055+
const errorBody = await attioResponse.json().catch(() => ({}))
1056+
attioLogger.error(
1057+
`[${requestId}] Failed to create webhook in Attio for webhook ${webhookData.id}. Status: ${attioResponse.status}`,
1058+
{ response: errorBody }
1059+
)
1060+
1061+
let userFriendlyMessage = 'Failed to create webhook subscription in Attio'
1062+
if (attioResponse.status === 401) {
1063+
userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.'
1064+
} else if (attioResponse.status === 403) {
1065+
userFriendlyMessage =
1066+
'Attio access denied. Please ensure your integration has webhook permissions.'
1067+
}
1068+
1069+
throw new Error(userFriendlyMessage)
1070+
}
1071+
1072+
const responseBody = await attioResponse.json()
1073+
const data = responseBody.data || responseBody
1074+
const webhookId = data.id?.webhook_id || data.webhook_id || data.id
1075+
const secret = data.secret
1076+
1077+
if (!webhookId) {
1078+
attioLogger.error(
1079+
`[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookData.id}`,
1080+
{ response: responseBody }
1081+
)
1082+
throw new Error('Attio webhook creation succeeded but no webhook ID was returned')
1083+
}
1084+
1085+
if (!secret) {
1086+
attioLogger.warn(
1087+
`[${requestId}] Attio webhook created but no secret returned for webhook ${webhookData.id}. Signature verification will be skipped.`,
1088+
{ response: responseBody }
1089+
)
1090+
}
1091+
1092+
attioLogger.info(
1093+
`[${requestId}] Successfully created webhook in Attio for webhook ${webhookData.id}.`,
1094+
{ attioWebhookId: webhookId }
1095+
)
1096+
1097+
return { externalId: webhookId, webhookSecret: secret || '' }
1098+
} catch (error: unknown) {
1099+
const message = error instanceof Error ? error.message : String(error)
1100+
attioLogger.error(
1101+
`[${requestId}] Exception during Attio webhook creation for webhook ${webhookData.id}.`,
1102+
{ message }
1103+
)
1104+
throw error
1105+
}
1106+
}
1107+
1108+
export async function deleteAttioWebhook(
1109+
webhook: any,
1110+
_workflow: any,
1111+
requestId: string
1112+
): Promise<void> {
1113+
try {
1114+
const config = getProviderConfig(webhook)
1115+
const externalId = config.externalId as string | undefined
1116+
const credentialId = config.credentialId as string | undefined
1117+
1118+
if (!externalId) {
1119+
attioLogger.warn(
1120+
`[${requestId}] Missing externalId for Attio webhook deletion ${webhook.id}, skipping cleanup`
1121+
)
1122+
return
1123+
}
1124+
1125+
if (!credentialId) {
1126+
attioLogger.warn(
1127+
`[${requestId}] Missing credentialId for Attio webhook deletion ${webhook.id}, skipping cleanup`
1128+
)
1129+
return
1130+
}
1131+
1132+
const credentialOwner = await getCredentialOwner(credentialId, requestId)
1133+
const accessToken = credentialOwner
1134+
? await refreshAccessTokenIfNeeded(
1135+
credentialOwner.accountId,
1136+
credentialOwner.userId,
1137+
requestId
1138+
)
1139+
: null
1140+
1141+
if (!accessToken) {
1142+
attioLogger.warn(
1143+
`[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`,
1144+
{ webhookId: webhook.id }
1145+
)
1146+
return
1147+
}
1148+
1149+
const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, {
1150+
method: 'DELETE',
1151+
headers: {
1152+
Authorization: `Bearer ${accessToken}`,
1153+
},
1154+
})
1155+
1156+
if (!attioResponse.ok && attioResponse.status !== 404) {
1157+
const responseBody = await attioResponse.json().catch(() => ({}))
1158+
attioLogger.warn(
1159+
`[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`,
1160+
{ response: responseBody }
1161+
)
1162+
} else {
1163+
attioLogger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`)
1164+
}
1165+
} catch (error) {
1166+
attioLogger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error)
1167+
}
1168+
}
1169+
9791170
export async function createGrainWebhookSubscription(
9801171
_request: NextRequest,
9811172
webhookData: any,
@@ -1611,6 +1802,7 @@ type RecreateCheckInput = {
16111802
/** Providers that create external webhook subscriptions */
16121803
const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([
16131804
'airtable',
1805+
'attio',
16141806
'calendly',
16151807
'webflow',
16161808
'typeform',
@@ -1626,6 +1818,7 @@ const SYSTEM_MANAGED_FIELDS = new Set([
16261818
'externalSubscriptionId',
16271819
'eventTypes',
16281820
'webhookTag',
1821+
'webhookSecret',
16291822
'historyId',
16301823
'lastCheckedTimestamp',
16311824
'setupCompleted',
@@ -1686,6 +1879,16 @@ export async function createExternalWebhookSubscription(
16861879
updatedProviderConfig = { ...updatedProviderConfig, externalId }
16871880
externalSubscriptionCreated = true
16881881
}
1882+
} else if (provider === 'attio') {
1883+
const result = await createAttioWebhookSubscription(userId, webhookData, requestId)
1884+
if (result) {
1885+
updatedProviderConfig = {
1886+
...updatedProviderConfig,
1887+
externalId: result.externalId,
1888+
webhookSecret: result.webhookSecret,
1889+
}
1890+
externalSubscriptionCreated = true
1891+
}
16891892
} else if (provider === 'calendly') {
16901893
const externalId = await createCalendlyWebhookSubscription(webhookData, requestId)
16911894
if (externalId) {
@@ -1736,7 +1939,7 @@ export async function createExternalWebhookSubscription(
17361939

17371940
/**
17381941
* Clean up external webhook subscriptions for a webhook
1739-
* Handles Airtable, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup
1942+
* Handles Airtable, Attio, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup
17401943
* Don't fail deletion if cleanup fails
17411944
*/
17421945
export async function cleanupExternalWebhook(
@@ -1746,6 +1949,8 @@ export async function cleanupExternalWebhook(
17461949
): Promise<void> {
17471950
if (webhook.provider === 'airtable') {
17481951
await deleteAirtableWebhook(webhook, workflow, requestId)
1952+
} else if (webhook.provider === 'attio') {
1953+
await deleteAttioWebhook(webhook, workflow, requestId)
17491954
} else if (webhook.provider === 'microsoft-teams') {
17501955
await deleteTeamsSubscription(webhook, workflow, requestId)
17511956
} else if (webhook.provider === 'telegram') {

apps/sim/lib/webhooks/utils.server.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,41 @@ export function validateLinearSignature(secret: string, signature: string, body:
13951395
}
13961396
}
13971397

1398+
/**
1399+
* Validates an Attio webhook request signature using HMAC SHA-256
1400+
* @param secret - Attio webhook signing secret (plain text)
1401+
* @param signature - Attio-Signature header value (hex-encoded HMAC SHA-256 signature)
1402+
* @param body - Raw request body string
1403+
* @returns Whether the signature is valid
1404+
*/
1405+
export function validateAttioSignature(secret: string, signature: string, body: string): boolean {
1406+
try {
1407+
if (!secret || !signature || !body) {
1408+
logger.warn('Attio signature validation missing required fields', {
1409+
hasSecret: !!secret,
1410+
hasSignature: !!signature,
1411+
hasBody: !!body,
1412+
})
1413+
return false
1414+
}
1415+
1416+
const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
1417+
1418+
logger.debug('Attio signature comparison', {
1419+
computedSignature: `${computedHash.substring(0, 10)}...`,
1420+
providedSignature: `${signature.substring(0, 10)}...`,
1421+
computedLength: computedHash.length,
1422+
providedLength: signature.length,
1423+
match: computedHash === signature,
1424+
})
1425+
1426+
return safeCompare(computedHash, signature)
1427+
} catch (error) {
1428+
logger.error('Error validating Attio signature:', error)
1429+
return false
1430+
}
1431+
}
1432+
13981433
/**
13991434
* Validates a Circleback webhook request signature using HMAC SHA-256
14001435
* @param secret - Circleback signing secret (plain text)

apps/sim/tools/attio/create_list.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export const attioCreateListTool: ToolConfig<AttioCreateListParams, AttioCreateL
6666
}),
6767
body: (params) => {
6868
const apiSlug =
69-
params.apiSlug || params.name?.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
69+
params.apiSlug ||
70+
params.name
71+
?.toLowerCase()
72+
.replace(/[^a-z0-9]+/g, '_')
73+
.replace(/^_|_$/g, '')
7074
const data: Record<string, unknown> = {
7175
name: params.name,
7276
api_slug: apiSlug,

0 commit comments

Comments
 (0)