From 2e89fe5cade278118561504790817dc7bde074f4 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 13:50:42 -0700 Subject: [PATCH 01/23] fix(triggers): apply webhook audit follow-ups Align the Greenhouse webhook matcher with provider conventions and clarify the Notion webhook secret setup text after the audit review. Made-with: Cursor --- apps/sim/lib/webhooks/providers/greenhouse.ts | 8 +++----- apps/sim/triggers/notion/utils.ts | 5 +++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts index 65f3090dee8..e10f6a76030 100644 --- a/apps/sim/lib/webhooks/providers/greenhouse.ts +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -1,6 +1,5 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' import type { EventMatchContext, @@ -53,7 +52,7 @@ export const greenhouseHandler: WebhookProviderHandler = { } }, - async matchEvent({ webhook, body, requestId, providerConfig }: EventMatchContext) { + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined const b = body as Record const action = b.action as string | undefined @@ -64,14 +63,13 @@ export const greenhouseHandler: WebhookProviderHandler = { `[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`, { webhookId: webhook.id, + workflowId: workflow.id, triggerId, receivedAction: action, } ) - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) + return false } } diff --git a/apps/sim/triggers/notion/utils.ts b/apps/sim/triggers/notion/utils.ts index df8d7a4b5b2..15a2c76fc2a 100644 --- a/apps/sim/triggers/notion/utils.ts +++ b/apps/sim/triggers/notion/utils.ts @@ -28,6 +28,7 @@ export function notionSetupInstructions(eventType: string): string { 'Paste the Webhook URL above into the URL field.', `Select the ${eventType} event type(s).`, 'Notion will send a verification request. Copy the verification_token from the payload and paste it into the Notion UI to complete verification.', + 'Paste the same verification_token into the Webhook Secret field above to enable signature verification on incoming events.', 'Ensure the integration has access to the pages/databases you want to monitor (share them with the integration).', ] @@ -48,9 +49,9 @@ export function buildNotionExtraFields(triggerId: string): SubBlockConfig[] { id: 'webhookSecret', title: 'Webhook Secret', type: 'short-input', - placeholder: 'Enter your Notion webhook signing secret', + placeholder: 'Enter your Notion verification_token', description: - 'The signing secret from your Notion integration settings page, used to verify X-Notion-Signature headers. This is separate from the verification_token used during initial setup.', + 'The verification_token sent by Notion during webhook setup. This same token is used to verify X-Notion-Signature HMAC headers on all subsequent webhook deliveries.', password: true, required: false, mode: 'trigger', From bb716bbc5fe6d9ba9c9641f27d8b360c249cc40f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 15:47:11 -0700 Subject: [PATCH 02/23] fix(webhooks): Salesforce provider handler, Zoom CRC and block wiring Add salesforce WebhookProviderHandler with required shared secret auth, matchEvent filtering, formatInput aligned to trigger outputs, and idempotency keys. Require webhook secret and document JSON-only Flow setup; enforce objectType when configured. Zoom: pass raw body into URL validation signature check, try all active webhooks on a path for secret match, add extractIdempotencyId, tighten event matching for specialized triggers. Wire Zoom triggers into the Zoom block. Extend handleChallenge with optional rawBody. Register Salesforce pending verification probes for pre-save URL checks. --- .../app/api/webhooks/trigger/[path]/route.ts | 2 +- apps/sim/blocks/blocks/zoom.ts | 18 ++ apps/sim/lib/webhooks/pending-verification.ts | 5 + apps/sim/lib/webhooks/processor.ts | 5 +- apps/sim/lib/webhooks/providers/registry.ts | 2 + .../providers/salesforce-zoom-webhook.test.ts | 124 ++++++++ apps/sim/lib/webhooks/providers/salesforce.ts | 300 ++++++++++++++++++ apps/sim/lib/webhooks/providers/types.ts | 4 +- apps/sim/lib/webhooks/providers/zoom.ts | 100 +++--- .../salesforce/case_status_changed.ts | 2 + .../salesforce/opportunity_stage_changed.ts | 2 + apps/sim/triggers/salesforce/utils.ts | 227 +++++++++++-- apps/sim/triggers/salesforce/webhook.ts | 2 + apps/sim/triggers/zoom/utils.ts | 9 +- 14 files changed, 732 insertions(+), 70 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts create mode 100644 apps/sim/lib/webhooks/providers/salesforce.ts diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index a04c749af50..46ec98d4735 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -76,7 +76,7 @@ async function handleWebhookPost( const { body, rawBody } = parseResult - const challengeResponse = await handleProviderChallenges(body, request, requestId, path) + const challengeResponse = await handleProviderChallenges(body, request, requestId, path, rawBody) if (challengeResponse) { return challengeResponse } diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index 5c77ac856ed..c699431a643 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -3,6 +3,7 @@ import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { ZoomResponse } from '@/tools/zoom/types' +import { getTrigger } from '@/triggers' export const ZoomBlock: BlockConfig = { type: 'zoom', @@ -17,6 +18,17 @@ export const ZoomBlock: BlockConfig = { tags: ['meeting', 'calendar', 'scheduling'], bgColor: '#2D8CFF', icon: ZoomIcon, + triggers: { + enabled: true, + available: [ + 'zoom_meeting_started', + 'zoom_meeting_ended', + 'zoom_participant_joined', + 'zoom_participant_left', + 'zoom_recording_completed', + 'zoom_webhook', + ], + }, subBlocks: [ { id: 'operation', @@ -440,6 +452,12 @@ Return ONLY the date string - no explanations, no quotes, no extra text.`, value: ['zoom_delete_meeting'], }, }, + ...getTrigger('zoom_meeting_started').subBlocks, + ...getTrigger('zoom_meeting_ended').subBlocks, + ...getTrigger('zoom_participant_joined').subBlocks, + ...getTrigger('zoom_participant_left').subBlocks, + ...getTrigger('zoom_recording_completed').subBlocks, + ...getTrigger('zoom_webhook').subBlocks, ], tools: { access: [ diff --git a/apps/sim/lib/webhooks/pending-verification.ts b/apps/sim/lib/webhooks/pending-verification.ts index 4d77d35bd24..02c50204f92 100644 --- a/apps/sim/lib/webhooks/pending-verification.ts +++ b/apps/sim/lib/webhooks/pending-verification.ts @@ -47,6 +47,7 @@ const pendingWebhookVerificationRegistrationMatchers: Record< ashby: () => true, grain: () => true, generic: (registration) => registration.metadata?.verifyTestEvents === true, + salesforce: () => true, } const pendingWebhookVerificationProbeMatchers: Record< @@ -62,6 +63,10 @@ const pendingWebhookVerificationProbeMatchers: Record< method === 'GET' || method === 'HEAD' || (method === 'POST' && (!body || Object.keys(body).length === 0)), + salesforce: ({ method, body }) => + method === 'GET' || + method === 'HEAD' || + (method === 'POST' && (!body || Object.keys(body).length === 0)), } function getRedisKey(path: string): string { diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 4288d0287d6..eb1503c0029 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -123,12 +123,13 @@ export async function handleProviderChallenges( body: unknown, request: NextRequest, requestId: string, - path: string + path: string, + rawBody?: string ): Promise { for (const provider of CHALLENGE_PROVIDERS) { const handler = getProviderHandler(provider) if (handler.handleChallenge) { - const response = await handler.handleChallenge(body, request, requestId, path) + const response = await handler.handleChallenge(body, request, requestId, path, rawBody) if (response) { return response } diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 9de1628f0c9..789546a755b 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -27,6 +27,7 @@ import { notionHandler } from '@/lib/webhooks/providers/notion' import { outlookHandler } from '@/lib/webhooks/providers/outlook' import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' +import { salesforceHandler } from '@/lib/webhooks/providers/salesforce' import { slackHandler } from '@/lib/webhooks/providers/slack' import { stripeHandler } from '@/lib/webhooks/providers/stripe' import { telegramHandler } from '@/lib/webhooks/providers/telegram' @@ -70,6 +71,7 @@ const PROVIDER_HANDLERS: Record = { notion: notionHandler, outlook: outlookHandler, rss: rssHandler, + salesforce: salesforceHandler, slack: slackHandler, stripe: stripeHandler, telegram: telegramHandler, diff --git a/apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts b/apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts new file mode 100644 index 00000000000..83f6401f69e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts @@ -0,0 +1,124 @@ +import crypto from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { salesforceHandler } from '@/lib/webhooks/providers/salesforce' +import { validateZoomSignature, zoomHandler } from '@/lib/webhooks/providers/zoom' +import { isSalesforceEventMatch } from '@/triggers/salesforce/utils' +import { isZoomEventMatch } from '@/triggers/zoom/utils' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('Salesforce webhook provider', () => { + it('verifyAuth rejects when webhookSecret is missing', async () => { + const res = await salesforceHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts Authorization Bearer secret', async () => { + const res = await salesforceHandler.verifyAuth!({ + request: reqWithHeaders({ authorization: 'Bearer my-secret-value' }), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'my-secret-value' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('verifyAuth accepts X-Sim-Webhook-Secret', async () => { + const res = await salesforceHandler.verifyAuth!({ + request: reqWithHeaders({ 'x-sim-webhook-secret': 'abc' }), + rawBody: '{}', + requestId: 't3', + providerConfig: { webhookSecret: 'abc' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('isSalesforceEventMatch filters record triggers by eventType', () => { + expect( + isSalesforceEventMatch('salesforce_record_created', { eventType: 'created' }, undefined) + ).toBe(true) + expect( + isSalesforceEventMatch('salesforce_record_created', { eventType: 'updated' }, undefined) + ).toBe(false) + expect(isSalesforceEventMatch('salesforce_record_created', {}, undefined)).toBe(false) + }) + + it('isSalesforceEventMatch enforces objectType config for generic webhook', () => { + expect( + isSalesforceEventMatch('salesforce_webhook', { objectType: 'Account', Id: 'x' }, 'Account') + ).toBe(true) + expect( + isSalesforceEventMatch('salesforce_webhook', { objectType: 'Contact', Id: 'x' }, 'Account') + ).toBe(false) + }) + + it('formatInput maps record trigger fields', async () => { + const { input } = await salesforceHandler.formatInput!({ + body: { + eventType: 'created', + objectType: 'Lead', + Id: '00Q1', + Name: 'Test', + }, + headers: {}, + requestId: 't4', + webhook: { providerConfig: { triggerId: 'salesforce_record_created' } }, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.eventType).toBe('created') + expect(i.objectType).toBe('Lead') + expect(i.recordId).toBe('00Q1') + }) + + it('extractIdempotencyId includes record id', () => { + const id = salesforceHandler.extractIdempotencyId!({ + eventType: 'created', + Id: '001', + }) + expect(id).toContain('001') + }) +}) + +describe('Zoom webhook provider', () => { + it('isZoomEventMatch rejects empty event for specialized triggers', () => { + expect(isZoomEventMatch('zoom_meeting_started', '')).toBe(false) + expect(isZoomEventMatch('zoom_meeting_started', ' ')).toBe(false) + expect(isZoomEventMatch('zoom_meeting_started', 'meeting.started')).toBe(true) + expect(isZoomEventMatch('zoom_webhook', '')).toBe(true) + }) + + it('validateZoomSignature uses raw body bytes, not a re-serialized variant', () => { + const secret = 'test-secret' + const timestamp = String(Math.floor(Date.now() / 1000)) + const rawA = '{"a":1,"b":2}' + const rawB = '{"b":2,"a":1}' + const computed = crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${rawA}`) + const hashA = `v0=${computed.digest('hex')}` + expect(validateZoomSignature(secret, hashA, timestamp, rawA)).toBe(true) + expect(validateZoomSignature(secret, hashA, timestamp, rawB)).toBe(false) + }) + + it('extractIdempotencyId prefers meeting uuid', () => { + const zid = zoomHandler.extractIdempotencyId!({ + event: 'meeting.started', + event_ts: 123, + payload: { object: { uuid: 'u1', id: 55 } }, + }) + expect(zid).toBe('zoom:meeting.started:123:u1') + }) +}) diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts new file mode 100644 index 00000000000..9a6d8c82179 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -0,0 +1,300 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Salesforce') + +function verifySalesforceSharedSecret(request: Request, secret: string): boolean { + if (verifyTokenAuth(request, secret, 'x-sim-webhook-secret')) { + return true + } + return verifyTokenAuth(request, secret) +} + +function asRecord(body: unknown): Record { + return body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : {} +} + +function extractObjectTypeFromPayload(body: Record): string | undefined { + const direct = + (typeof body.objectType === 'string' && body.objectType) || + (typeof body.sobjectType === 'string' && body.sobjectType) || + undefined + if (direct) { + return direct + } + + const attrs = body.attributes as Record | undefined + if (typeof attrs?.type === 'string') { + return attrs.type + } + + const record = body.record + if (record && typeof record === 'object' && !Array.isArray(record)) { + const r = record as Record + if (typeof r.sobjectType === 'string') { + return r.sobjectType + } + const ra = r.attributes as Record | undefined + if (typeof ra?.type === 'string') { + return ra.type + } + } + + return undefined +} + +function normalizeSObjectType(t: string): string { + return t.trim().toLowerCase() +} + +function extractRecordCore(body: Record): Record { + const nested = body.record + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + return { ...(nested as Record) } + } + + const skip = new Set([ + 'eventType', + 'simEventType', + 'changedFields', + 'previousStage', + 'newStage', + 'previousStatus', + 'newStatus', + 'payload', + 'record', + ]) + const out: Record = {} + for (const [k, v] of Object.entries(body)) { + if (!skip.has(k)) { + out[k] = v + } + } + return out +} + +function pickTimestamp(body: Record, record: Record): string { + const candidates = [ + body.timestamp, + body.time, + record.SystemModstamp, + record.LastModifiedDate, + record.CreatedDate, + ] + for (const c of candidates) { + if (typeof c === 'string' && c.length > 0) { + return c + } + } + return new Date().toISOString() +} + +function pickRecordId(body: Record, record: Record): string { + const id = + (typeof body.recordId === 'string' && body.recordId) || + (typeof record.Id === 'string' && record.Id) || + (typeof body.Id === 'string' && body.Id) || + '' + return id +} + +export const salesforceHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret?.trim()) { + logger.warn(`[${requestId}] Salesforce webhook missing webhookSecret — rejecting`) + return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 }) + } + + if (!verifySalesforceSharedSecret(request, secret.trim())) { + logger.warn(`[${requestId}] Salesforce webhook secret verification failed`) + return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 }) + } + + return null + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId) { + return true + } + + const { isSalesforceEventMatch } = await import('@/triggers/salesforce/utils') + const configuredObjectType = providerConfig.objectType as string | undefined + const obj = asRecord(body) + + if (!isSalesforceEventMatch(triggerId, obj, configuredObjectType)) { + logger.debug( + `[${requestId}] Salesforce event mismatch for trigger ${triggerId}. Skipping execution.`, + { webhookId: webhook.id, workflowId: workflow.id, triggerId } + ) + return false + } + + return true + }, + + async formatInput(ctx: FormatInputContext): Promise { + const rawPc = (ctx.webhook as { providerConfig?: unknown }).providerConfig + const pc = + rawPc && typeof rawPc === 'object' && !Array.isArray(rawPc) + ? (rawPc as Record) + : {} + const id = typeof pc.triggerId === 'string' ? pc.triggerId : '' + const body = asRecord(ctx.body) + + const record = extractRecordCore(body) + const objectType = + extractObjectTypeFromPayload(body) || + (typeof record.attributes === 'object' && + record.attributes && + typeof (record.attributes as Record).type === 'string' + ? String((record.attributes as Record).type) + : '') || + (typeof record.sobjectType === 'string' ? record.sobjectType : '') + const recordId = pickRecordId(body, record) + const timestamp = pickTimestamp(body, record) + const eventTypeRaw = + (typeof body.eventType === 'string' && body.eventType) || + (typeof body.simEventType === 'string' && body.simEventType) || + '' + + if (id === 'salesforce_webhook') { + return { + input: { + eventType: eventTypeRaw || 'webhook', + objectType: objectType || '', + recordId, + timestamp, + record: Object.keys(record).length > 0 ? record : body, + payload: ctx.body, + }, + } + } + + if ( + id === 'salesforce_record_created' || + id === 'salesforce_record_updated' || + id === 'salesforce_record_deleted' + ) { + const changedFields = body.changedFields + return { + input: { + eventType: eventTypeRaw || id.replace('salesforce_', '').replace(/_/g, ' '), + objectType: objectType || '', + recordId, + timestamp, + record: { + Id: typeof record.Id === 'string' ? record.Id : recordId, + Name: typeof record.Name === 'string' ? record.Name : '', + CreatedDate: typeof record.CreatedDate === 'string' ? record.CreatedDate : '', + LastModifiedDate: + typeof record.LastModifiedDate === 'string' ? record.LastModifiedDate : '', + }, + changedFields: changedFields !== undefined ? changedFields : null, + payload: ctx.body, + }, + } + } + + if (id === 'salesforce_opportunity_stage_changed') { + return { + input: { + eventType: eventTypeRaw || 'opportunity_stage_changed', + objectType: objectType || 'Opportunity', + recordId, + timestamp, + record: { + Id: typeof record.Id === 'string' ? record.Id : recordId, + Name: typeof record.Name === 'string' ? record.Name : '', + StageName: typeof record.StageName === 'string' ? record.StageName : '', + Amount: record.Amount !== undefined ? String(record.Amount) : '', + CloseDate: typeof record.CloseDate === 'string' ? record.CloseDate : '', + Probability: record.Probability !== undefined ? String(record.Probability) : '', + }, + previousStage: + typeof body.previousStage === 'string' + ? body.previousStage + : typeof body.PriorStage === 'string' + ? body.PriorStage + : '', + newStage: + typeof body.newStage === 'string' + ? body.newStage + : typeof record.StageName === 'string' + ? record.StageName + : '', + payload: ctx.body, + }, + } + } + + if (id === 'salesforce_case_status_changed') { + return { + input: { + eventType: eventTypeRaw || 'case_status_changed', + objectType: objectType || 'Case', + recordId, + timestamp, + record: { + Id: typeof record.Id === 'string' ? record.Id : recordId, + Subject: typeof record.Subject === 'string' ? record.Subject : '', + Status: typeof record.Status === 'string' ? record.Status : '', + Priority: typeof record.Priority === 'string' ? record.Priority : '', + CaseNumber: typeof record.CaseNumber === 'string' ? record.CaseNumber : '', + }, + previousStatus: + typeof body.previousStatus === 'string' + ? body.previousStatus + : typeof body.PriorStatus === 'string' + ? body.PriorStatus + : '', + newStatus: + typeof body.newStatus === 'string' + ? body.newStatus + : typeof record.Status === 'string' + ? record.Status + : '', + payload: ctx.body, + }, + } + } + + return { + input: { + eventType: eventTypeRaw || 'webhook', + objectType: objectType || '', + recordId, + timestamp, + record: Object.keys(record).length > 0 ? record : body, + payload: ctx.body, + }, + } + }, + + extractIdempotencyId(body: unknown): string | null { + const b = asRecord(body) + const record = extractRecordCore(b) + const id = pickRecordId(b, record) + const et = + (typeof b.eventType === 'string' && b.eventType) || + (typeof b.simEventType === 'string' && b.simEventType) || + '' + const ts = pickTimestamp(b, record) + if (!id) { + return null + } + return `salesforce:${et || 'event'}:${id}:${ts}` + }, +} diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index 34587ce7a42..2698f4ce8fa 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -138,6 +138,8 @@ export interface WebhookProviderHandler { body: unknown, request: NextRequest, requestId: string, - path: string + path: string, + /** Raw request body bytes (when available); required for signature checks that must match the provider (e.g. Zoom). */ + rawBody?: string ): Promise | NextResponse | null } diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index ed33b2bac2e..ad9937d01da 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -18,7 +18,8 @@ const logger = createLogger('WebhookProvider:Zoom') * Zoom sends `x-zm-signature` as `v0=` and `x-zm-request-timestamp`. * The message to hash is `v0:{timestamp}:{rawBody}`. */ -function validateZoomSignature( +/** Exported for tests — Zoom signs `v0:{timestamp}:{rawBody}`. */ +export function validateZoomSignature( secretToken: string, signature: string, timestamp: string, @@ -72,14 +73,30 @@ export const zoomHandler: WebhookProviderHandler = { return null }, + extractIdempotencyId(body: unknown): string | null { + const obj = body as Record + const event = obj.event + const ts = obj.event_ts + if (typeof event !== 'string' || ts === undefined || ts === null) { + return null + } + const payload = obj.payload as Record | undefined + const inner = payload?.object as Record | undefined + const stable = + (typeof inner?.uuid === 'string' && inner.uuid) || + (inner?.id !== undefined && inner.id !== null ? String(inner.id) : '') || + '' + return `zoom:${event}:${String(ts)}:${stable}` + }, + async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined const obj = body as Record - const event = obj.event as string | undefined + const event = typeof obj.event === 'string' ? obj.event : '' if (triggerId) { const { isZoomEventMatch } = await import('@/triggers/zoom/utils') - if (!isZoomEventMatch(triggerId, event || '')) { + if (!isZoomEventMatch(triggerId, event)) { logger.debug( `[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`, { @@ -101,7 +118,13 @@ export const zoomHandler: WebhookProviderHandler = { * Zoom sends an `endpoint.url_validation` event with a `plainToken` that must * be hashed with the app's secret token and returned alongside the original token. */ - async handleChallenge(body: unknown, request: NextRequest, requestId: string, path: string) { + async handleChallenge( + body: unknown, + request: NextRequest, + requestId: string, + path: string, + rawBody?: string + ) { const obj = body as Record | null if (obj?.event !== 'endpoint.url_validation') { return null @@ -115,52 +138,51 @@ export const zoomHandler: WebhookProviderHandler = { logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`) - // Look up the webhook record to get the secret token from providerConfig - let secretToken = '' + const signature = request.headers.get('x-zm-signature') + const timestamp = request.headers.get('x-zm-request-timestamp') + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`) + return null + } + + const bodyForSignature = + rawBody !== undefined && rawBody !== null ? rawBody : JSON.stringify(body) + + let rows: { providerConfig: unknown }[] = [] try { - const webhooks = await db - .select() + rows = await db + .select({ providerConfig: webhook.providerConfig }) .from(webhook) .where( and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true)) ) - if (webhooks.length > 0) { - const config = webhooks[0].providerConfig as Record | null - secretToken = (config?.secretToken as string) || '' - } } catch (err) { logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err) return null } - if (!secretToken) { - logger.warn( - `[${requestId}] No secret token configured for Zoom URL validation on path: ${path}` - ) - return null - } - - // Verify the challenge request's signature to prevent HMAC oracle attacks - const signature = request.headers.get('x-zm-signature') - const timestamp = request.headers.get('x-zm-request-timestamp') - if (!signature || !timestamp) { - logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`) - return null - } - const rawBody = JSON.stringify(body) - if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { - logger.warn(`[${requestId}] Zoom challenge request failed signature verification`) - return null + for (const row of rows) { + const config = row.providerConfig as Record | null + const secretToken = (config?.secretToken as string) || '' + if ( + secretToken && + validateZoomSignature(secretToken, signature, timestamp, bodyForSignature) + ) { + const hashForValidate = crypto + .createHmac('sha256', secretToken) + .update(plainToken) + .digest('hex') + + return NextResponse.json({ + plainToken, + encryptedToken: hashForValidate, + }) + } } - const hashForValidate = crypto - .createHmac('sha256', secretToken) - .update(plainToken) - .digest('hex') - - return NextResponse.json({ - plainToken, - encryptedToken: hashForValidate, - }) + logger.warn( + `[${requestId}] Zoom challenge: no matching secret for path ${path} (${rows.length} webhook row(s))` + ) + return null }, } diff --git a/apps/sim/triggers/salesforce/case_status_changed.ts b/apps/sim/triggers/salesforce/case_status_changed.ts index a3ad5802112..9506c615f87 100644 --- a/apps/sim/triggers/salesforce/case_status_changed.ts +++ b/apps/sim/triggers/salesforce/case_status_changed.ts @@ -1,6 +1,7 @@ import { SalesforceIcon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' import { + buildSalesforceAuthOnlyFields, buildSalesforceCaseStatusOutputs, salesforceSetupInstructions, salesforceTriggerOptions, @@ -22,6 +23,7 @@ export const salesforceCaseStatusChangedTrigger: TriggerConfig = { triggerId: 'salesforce_case_status_changed', triggerOptions: salesforceTriggerOptions, setupInstructions: salesforceSetupInstructions('Case Status Changed'), + extraFields: buildSalesforceAuthOnlyFields('salesforce_case_status_changed'), }), outputs: buildSalesforceCaseStatusOutputs(), diff --git a/apps/sim/triggers/salesforce/opportunity_stage_changed.ts b/apps/sim/triggers/salesforce/opportunity_stage_changed.ts index 43d72a972c3..495757a732c 100644 --- a/apps/sim/triggers/salesforce/opportunity_stage_changed.ts +++ b/apps/sim/triggers/salesforce/opportunity_stage_changed.ts @@ -1,6 +1,7 @@ import { SalesforceIcon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' import { + buildSalesforceAuthOnlyFields, buildSalesforceOpportunityStageOutputs, salesforceSetupInstructions, salesforceTriggerOptions, @@ -22,6 +23,7 @@ export const salesforceOpportunityStageChangedTrigger: TriggerConfig = { triggerId: 'salesforce_opportunity_stage_changed', triggerOptions: salesforceTriggerOptions, setupInstructions: salesforceSetupInstructions('Opportunity Stage Changed'), + extraFields: buildSalesforceAuthOnlyFields('salesforce_opportunity_stage_changed'), }), outputs: buildSalesforceOpportunityStageOutputs(), diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index a2c1db4b715..1a9168e536b 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -13,34 +13,190 @@ export const salesforceTriggerOptions = [ { label: 'Generic Webhook (All Events)', id: 'salesforce_webhook' }, ] +function normalizeToken(s: string): string { + return s + .trim() + .toLowerCase() + .replace(/[\s-]+/g, '_') +} + +function payloadObjectType(body: Record): string | undefined { + const direct = + (typeof body.objectType === 'string' && body.objectType) || + (typeof body.sobjectType === 'string' && body.sobjectType) || + undefined + if (direct) { + return direct + } + const attrs = body.attributes as Record | undefined + if (typeof attrs?.type === 'string') { + return attrs.type + } + const record = body.record + if (record && typeof record === 'object' && !Array.isArray(record)) { + const r = record as Record + if (typeof r.sobjectType === 'string') { + return r.sobjectType + } + const ra = r.attributes as Record | undefined + if (typeof ra?.type === 'string') { + return ra.type + } + } + return undefined +} + +const RECORD_CREATED = new Set([ + 'record_created', + 'created', + 'create', + 'after_insert', + 'afterinsert', + 'insert', +]) + +const RECORD_UPDATED = new Set([ + 'record_updated', + 'updated', + 'update', + 'after_update', + 'afterupdate', +]) + +const RECORD_DELETED = new Set([ + 'record_deleted', + 'deleted', + 'delete', + 'after_delete', + 'afterdelete', +]) + +const OPP_STAGE = new Set([ + 'opportunity_stage_changed', + 'stage_changed', + 'stage_change', + 'opportunity_stage_change', + 'opportunitystagechanged', +]) + +const CASE_STATUS = new Set([ + 'case_status_changed', + 'status_changed', + 'status_change', + 'case_status_change', + 'casestatuschanged', +]) + +function matchesRecordTrigger(triggerId: string, normalizedEvent: string): boolean { + if (triggerId === 'salesforce_record_created') { + return RECORD_CREATED.has(normalizedEvent) + } + if (triggerId === 'salesforce_record_updated') { + return RECORD_UPDATED.has(normalizedEvent) + } + if (triggerId === 'salesforce_record_deleted') { + return RECORD_DELETED.has(normalizedEvent) + } + return false +} + +/** + * Server-side filter for Salesforce Flow (JSON) payloads. + * Users should send a string `eventType` (or `simEventType`) from the Flow body. + * Optional `objectType` in provider config is enforced against payload when set. + */ +export function isSalesforceEventMatch( + triggerId: string, + body: Record, + configuredObjectType?: string +): boolean { + if (triggerId === 'salesforce_webhook') { + const want = configuredObjectType?.trim() + if (!want) { + return true + } + const got = payloadObjectType(body) + if (!got) { + return false + } + return normalizeToken(got) === normalizeToken(want) + } + + const wantType = configuredObjectType?.trim() + const gotType = payloadObjectType(body) + if (wantType && gotType && normalizeToken(gotType) !== normalizeToken(wantType)) { + return false + } + + if (triggerId === 'salesforce_opportunity_stage_changed') { + if (gotType && normalizeToken(gotType) !== 'opportunity') { + return false + } + const etRaw = + (typeof body.eventType === 'string' && body.eventType) || + (typeof body.simEventType === 'string' && body.simEventType) || + '' + if (!etRaw.trim()) { + return Boolean(gotType && normalizeToken(gotType) === 'opportunity') + } + return OPP_STAGE.has(normalizeToken(etRaw)) + } + + if (triggerId === 'salesforce_case_status_changed') { + if (gotType && normalizeToken(gotType) !== 'case') { + return false + } + const etRaw = + (typeof body.eventType === 'string' && body.eventType) || + (typeof body.simEventType === 'string' && body.simEventType) || + '' + if (!etRaw.trim()) { + return Boolean(gotType && normalizeToken(gotType) === 'case') + } + return CASE_STATUS.has(normalizeToken(etRaw)) + } + + const etRaw = + (typeof body.eventType === 'string' && body.eventType) || + (typeof body.simEventType === 'string' && body.simEventType) || + '' + + if (!etRaw.trim()) { + return false + } + + const normalized = normalizeToken(etRaw) + return matchesRecordTrigger(triggerId, normalized) +} + /** * Generates HTML setup instructions for the Salesforce trigger. - * Salesforce has no native webhook API — users must configure - * Flow HTTP Callouts or Outbound Messages manually. + * Use Flow HTTP Callouts with a JSON body. Outbound Messages are SOAP/XML and are not supported. */ export function salesforceSetupInstructions(eventType: string): string { const isGeneric = eventType === 'All Events' const instructions = isGeneric ? [ - 'Copy the Webhook URL above.', + 'Copy the Webhook URL above and generate a Webhook Secret (any strong random string). Paste the secret in the Webhook Secret field here.', + 'In your Flow’s HTTP Callout, set header Authorization: Bearer <your secret> or X-Sim-Webhook-Secret: <your secret> (same value).', 'In Salesforce, go to Setup → Flows and click New Flow.', 'Select Record-Triggered Flow and choose the object(s) you want to monitor.', - 'Add an HTTP Callout action — set the method to POST and paste the webhook URL.', - 'In the request body, include the record fields you want sent as JSON (e.g., Id, Name, and any relevant fields).', - 'Repeat for each object type you want to send events for.', + 'Add an Action that performs an HTTP Callout — method POST, Content-Type: application/json, and paste the webhook URL.', + 'Build the request body as JSON (not SOAP/XML). Include eventType and record fields (e.g. Id, Name). Outbound Messages use SOAP and will not work with this trigger.', 'Save and Activate the Flow(s).', + 'Save this trigger in Sim first so the URL is registered; Salesforce connectivity checks may arrive before the Flow runs.', 'Click "Save" above to activate your trigger.', ] : [ - 'Copy the Webhook URL above.', + 'Copy the Webhook URL above and set a Webhook Secret. In the Flow HTTP Callout, send the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: ….', 'In Salesforce, go to Setup → Flows and click New Flow.', - `Select Record-Triggered Flow and choose the object and ${eventType} trigger condition.`, - 'Add an HTTP Callout action — set the method to POST and paste the webhook URL.', - 'In the request body, include the record fields you want sent as JSON (e.g., Id, Name, and any relevant fields).', + `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`, + 'Add an HTTP CalloutPOST, JSON body, URL = webhook URL.', + `Include eventType in the JSON body using a value this trigger accepts (e.g. for Record Created use record_created, created, or after_insert).`, + 'Include attributes.type / object API name or objectType so filtering can run when you set Object Type above.', 'Save and Activate the Flow.', 'Click "Save" above to activate your trigger.', - 'Alternative: You can also use Setup → Outbound Messages with a Workflow Rule, but this sends SOAP/XML instead of JSON.', ] return instructions @@ -51,21 +207,42 @@ export function salesforceSetupInstructions(eventType: string): string { .join('') } -/** - * Extra fields for Salesforce triggers (object type filter). - */ +function salesforceWebhookSecretField(triggerId: string): SubBlockConfig { + return { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Generate a secret and paste it here', + description: + 'Required. Use the same value in your Salesforce HTTP Callout as Bearer token or X-Sim-Webhook-Secret.', + password: true, + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + } +} + +function salesforceObjectTypeField(triggerId: string): SubBlockConfig { + return { + id: 'objectType', + title: 'Object Type (Optional)', + type: 'short-input', + placeholder: 'e.g., Account, Contact, Opportunity', + description: + 'When set, only payloads for this Salesforce object API name are accepted (matched case-insensitively).', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + } +} + +/** Secret + optional object filter (record triggers and generic webhook). */ export function buildSalesforceExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'objectType', - title: 'Object Type (Optional)', - type: 'short-input', - placeholder: 'e.g., Account, Contact, Lead, Opportunity', - description: 'Optionally filter to a specific Salesforce object type', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] + return [salesforceWebhookSecretField(triggerId), salesforceObjectTypeField(triggerId)] +} + +/** Webhook secret only (Opportunity / Case specialized triggers — object is implied). */ +export function buildSalesforceAuthOnlyFields(triggerId: string): SubBlockConfig[] { + return [salesforceWebhookSecretField(triggerId)] } /** diff --git a/apps/sim/triggers/salesforce/webhook.ts b/apps/sim/triggers/salesforce/webhook.ts index 32d0165db24..855f17174ec 100644 --- a/apps/sim/triggers/salesforce/webhook.ts +++ b/apps/sim/triggers/salesforce/webhook.ts @@ -1,6 +1,7 @@ import { SalesforceIcon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' import { + buildSalesforceExtraFields, buildSalesforceWebhookOutputs, salesforceSetupInstructions, salesforceTriggerOptions, @@ -24,6 +25,7 @@ export const salesforceWebhookTrigger: TriggerConfig = { triggerId: 'salesforce_webhook', triggerOptions: salesforceTriggerOptions, setupInstructions: salesforceSetupInstructions('All Events'), + extraFields: buildSalesforceExtraFields('salesforce_webhook'), }), outputs: buildSalesforceWebhookOutputs(), diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts index 20e316f0400..fd6d850f4ea 100644 --- a/apps/sim/triggers/zoom/utils.ts +++ b/apps/sim/triggers/zoom/utils.ts @@ -26,7 +26,12 @@ export function isZoomEventMatch(triggerId: string, event: string): boolean { return false } - return allowedEvents.includes(event) + const ev = event?.trim() + if (!ev) { + return false + } + + return allowedEvents.includes(ev) } /** @@ -64,7 +69,7 @@ export function zoomSetupInstructions(eventType: ZoomEventType): string { const instructions = [ 'Copy the Webhook URL above.', - 'Go to the Zoom Marketplace and open your app (or create a new Webhook Only app).', + 'Go to the Zoom Marketplace and create or open a Webhook-only or general app with Event Subscriptions enabled (Meeting / Recording events as needed). Admin approval may be required for account-level webhooks.', "Copy the Secret Token from your Zoom app's Features page and paste it in the Secret Token field above.", 'Click "Save Configuration" above to activate the trigger.', 'Navigate to Features > Event Subscriptions and click Add Event Subscription.', From 0ddc769f90b5827e4b49bec27807f24274d58d1c Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 16:03:55 -0700 Subject: [PATCH 03/23] fix(webhooks): harden Resend and Linear triggers (idempotency, auth, outputs) - Dedupe Resend deliveries via svix-id and Linear via Linear-Delivery in idempotency keys - Require Resend signing secret; validate createSubscription id and signing_secret - Single source for Resend event maps in triggers/utils; fail closed on unknown trigger IDs - Add raw event data to Resend trigger outputs and formatInput - Linear: remove body-based idempotency key; timestamp skew after HMAC verify; format url and actorType - Tighten isLinearEventMatch for unknown triggers; clarify generic webhook copy; fix header examples - Add focused tests for idempotency headers and Linear matchEvent --- apps/sim/lib/core/idempotency/service.ts | 4 +- .../idempotency/service.webhook-key.test.ts | 28 ++++++ apps/sim/lib/webhooks/providers/linear.ts | 85 +++++++++++++++---- apps/sim/lib/webhooks/providers/resend.ts | 84 +++++++++--------- .../triggers/linear/utils.match-event.test.ts | 20 +++++ apps/sim/triggers/linear/utils.ts | 35 +++++++- apps/sim/triggers/linear/webhook.ts | 13 ++- apps/sim/triggers/linear/webhook_v2.ts | 13 ++- apps/sim/triggers/resend/utils.ts | 53 ++++++++++++ apps/sim/triggers/resend/webhook.ts | 3 +- 10 files changed, 263 insertions(+), 75 deletions(-) create mode 100644 apps/sim/lib/core/idempotency/service.webhook-key.test.ts create mode 100644 apps/sim/triggers/linear/utils.match-event.test.ts diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 27d0746e2a9..0f6c726b992 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -419,7 +419,9 @@ export class IdempotencyService { normalizedHeaders?.['x-shopify-webhook-id'] || normalizedHeaders?.['x-github-delivery'] || normalizedHeaders?.['x-event-id'] || - normalizedHeaders?.['x-teams-notification-id'] + normalizedHeaders?.['x-teams-notification-id'] || + normalizedHeaders?.['svix-id'] || + normalizedHeaders?.['linear-delivery'] if (webhookIdHeader) { return `${webhookId}:${webhookIdHeader}` diff --git a/apps/sim/lib/core/idempotency/service.webhook-key.test.ts b/apps/sim/lib/core/idempotency/service.webhook-key.test.ts new file mode 100644 index 00000000000..bcb93769a18 --- /dev/null +++ b/apps/sim/lib/core/idempotency/service.webhook-key.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { IdempotencyService } from '@/lib/core/idempotency/service' + +describe('IdempotencyService.createWebhookIdempotencyKey', () => { + it('prefers svix-id for Resend / Svix duplicate delivery deduplication', () => { + const key = IdempotencyService.createWebhookIdempotencyKey( + 'wh_1', + { 'svix-id': 'msg_abc123' }, + { type: 'email.sent' }, + 'resend' + ) + expect(key).toBe('wh_1:msg_abc123') + }) + + it('prefers Linear-Delivery so repeated updates to the same entity are not treated as one idempotent run', () => { + const key = IdempotencyService.createWebhookIdempotencyKey( + 'wh_linear', + { 'linear-delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72' }, + { action: 'update', data: { id: 'shared-entity-id' } }, + 'linear' + ) + expect(key).toBe('wh_linear:234d1a4e-b617-4388-90fe-adc3633d6b72') + }) +}) diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 7df343dfc6f..a9323aa442e 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -1,9 +1,11 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, EventMatchContext, FormatInputContext, @@ -12,7 +14,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Linear') @@ -41,16 +42,68 @@ function validateLinearSignature(secret: string, signature: string, body: string } } +const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000 + export const linearHandler: WebhookProviderHandler = { - verifyAuth: createHmacVerifier({ - configKey: 'webhookSecret', - headerName: 'Linear-Signature', - validateFn: validateLinearSignature, - providerLabel: 'Linear', - }), + async verifyAuth({ + request, + rawBody, + requestId, + providerConfig, + }: AuthContext): Promise { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const signature = request.headers.get('Linear-Signature') + if (!signature) { + logger.warn(`[${requestId}] Linear webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Linear signature', { status: 401 }) + } + + if (!validateLinearSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] Linear signature verification failed`) + return new NextResponse('Unauthorized - Invalid Linear signature', { status: 401 }) + } + + try { + const parsed = JSON.parse(rawBody) as Record + const ts = parsed.webhookTimestamp + if (typeof ts === 'number' && Number.isFinite(ts)) { + if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) { + logger.warn( + `[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)` + ) + return new NextResponse('Unauthorized - Webhook timestamp skew too large', { + status: 401, + }) + } + } + } catch (error) { + logger.warn( + `[${requestId}] Linear webhook body parse failed after signature verification`, + error + ) + return new NextResponse('Unauthorized - Invalid webhook body', { status: 401 }) + } + + return null + }, async formatInput({ body }: FormatInputContext): Promise { const b = body as Record + const rawActor = b.actor + let actor: unknown = null + if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) { + const a = rawActor as Record + const { type: linearActorType, ...rest } = a + actor = { + ...rest, + actorType: typeof linearActorType === 'string' ? linearActorType : null, + } + } + return { input: { action: b.action || '', @@ -59,7 +112,8 @@ export const linearHandler: WebhookProviderHandler = { webhookTimestamp: b.webhookTimestamp || 0, organizationId: b.organizationId || '', createdAt: b.createdAt || '', - actor: b.actor || null, + url: typeof b.url === 'string' ? b.url : '', + actor, data: b.data || null, updatedFrom: b.updatedFrom || null, }, @@ -160,6 +214,12 @@ export const linearHandler: WebhookProviderHandler = { } const externalId = result.webhook?.id + if (typeof externalId !== 'string' || !externalId.trim()) { + throw new Error( + 'Linear webhook was created but the API response did not include a webhook id.' + ) + } + logger.info( `[${ctx.requestId}] Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}` ) @@ -228,13 +288,4 @@ export const linearHandler: WebhookProviderHandler = { }) } }, - - extractIdempotencyId(body: unknown) { - const obj = body as Record - const data = obj.data as Record | undefined - if (obj.action && data?.id) { - return `${obj.action}:${data.id}` - } - return null - }, } diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 82d452ba8cd..280d142a53f 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -13,29 +13,13 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { + RESEND_ALL_WEBHOOK_EVENT_TYPES, + RESEND_TRIGGER_TO_EVENT_TYPE, +} from '@/triggers/resend/utils' const logger = createLogger('WebhookProvider:Resend') -const ALL_RESEND_EVENTS = [ - 'email.sent', - 'email.delivered', - 'email.delivery_delayed', - 'email.bounced', - 'email.complained', - 'email.opened', - 'email.clicked', - 'email.failed', - 'email.received', - 'email.scheduled', - 'email.suppressed', - 'contact.created', - 'contact.updated', - 'contact.deleted', - 'domain.created', - 'domain.updated', - 'domain.deleted', -] - /** * Verify a Resend webhook signature using the Svix signing scheme. * Resend uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}` @@ -86,8 +70,9 @@ export const resendHandler: WebhookProviderHandler = { providerConfig, }: AuthContext): Promise { const signingSecret = providerConfig.signingSecret as string | undefined - if (!signingSecret) { - return null + if (!signingSecret?.trim()) { + logger.warn(`[${requestId}] Resend webhook missing signing secret in provider configuration`) + return new NextResponse('Unauthorized - Resend signing secret is required', { status: 401 }) } const svixId = request.headers.get('svix-id') @@ -113,20 +98,15 @@ export const resendHandler: WebhookProviderHandler = { return true } - const EVENT_TYPE_MAP: Record = { - resend_email_sent: 'email.sent', - resend_email_delivered: 'email.delivered', - resend_email_bounced: 'email.bounced', - resend_email_complained: 'email.complained', - resend_email_opened: 'email.opened', - resend_email_clicked: 'email.clicked', - resend_email_failed: 'email.failed', + const expectedType = RESEND_TRIGGER_TO_EVENT_TYPE[triggerId] + if (!expectedType) { + logger.debug(`[${requestId}] Unknown Resend triggerId ${triggerId}, skipping.`) + return false } - const expectedType = EVENT_TYPE_MAP[triggerId] const actualType = (body as Record)?.type as string | undefined - if (expectedType && actualType !== expectedType) { + if (actualType !== expectedType) { logger.debug( `[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.` ) @@ -146,6 +126,7 @@ export const resendHandler: WebhookProviderHandler = { input: { type: payload.type, created_at: payload.created_at, + data: data ?? null, email_id: data?.email_id ?? null, from: data?.from ?? null, to: data?.to ?? null, @@ -177,18 +158,17 @@ export const resendHandler: WebhookProviderHandler = { ) } - const eventTypeMap: Record = { - resend_email_sent: ['email.sent'], - resend_email_delivered: ['email.delivered'], - resend_email_bounced: ['email.bounced'], - resend_email_complained: ['email.complained'], - resend_email_opened: ['email.opened'], - resend_email_clicked: ['email.clicked'], - resend_email_failed: ['email.failed'], - resend_webhook: ALL_RESEND_EVENTS, + const events = + triggerId === 'resend_webhook' + ? RESEND_ALL_WEBHOOK_EVENT_TYPES + : triggerId && RESEND_TRIGGER_TO_EVENT_TYPE[triggerId] + ? [RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]] + : null + + if (!events?.length) { + throw new Error(`Unknown or unsupported Resend trigger type: ${triggerId ?? '(missing)'}`) } - const events = eventTypeMap[triggerId ?? ''] ?? ALL_RESEND_EVENTS const notificationUrl = getNotificationUrl(webhook) logger.info(`[${requestId}] Creating Resend webhook`, { @@ -231,17 +211,31 @@ export const resendHandler: WebhookProviderHandler = { throw new Error(userFriendlyMessage) } + const externalId = responseBody.id + const signingSecretOut = responseBody.signing_secret + + if (typeof externalId !== 'string' || !externalId.trim()) { + throw new Error( + 'Resend webhook was created but the API response did not include a webhook id.' + ) + } + if (typeof signingSecretOut !== 'string' || !signingSecretOut.trim()) { + throw new Error( + 'Resend webhook was created but the API response did not include a signing secret.' + ) + } + logger.info( `[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`, { - resendWebhookId: responseBody.id, + resendWebhookId: externalId, } ) return { providerConfigUpdates: { - externalId: responseBody.id, - signingSecret: responseBody.signing_secret, + externalId, + signingSecret: signingSecretOut, }, } } catch (error: unknown) { diff --git a/apps/sim/triggers/linear/utils.match-event.test.ts b/apps/sim/triggers/linear/utils.match-event.test.ts new file mode 100644 index 00000000000..eec0c6a6eba --- /dev/null +++ b/apps/sim/triggers/linear/utils.match-event.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { isLinearEventMatch } from '@/triggers/linear/utils' + +describe('isLinearEventMatch', () => { + it('returns false for unknown trigger ids (fail closed)', () => { + expect(isLinearEventMatch('linear_unknown_trigger', 'Issue', 'create')).toBe(false) + }) + + it('returns true when type and action match a known trigger', () => { + expect(isLinearEventMatch('linear_issue_created', 'Issue', 'create')).toBe(true) + }) + + it('normalizes _v2 suffix when matching', () => { + expect(isLinearEventMatch('linear_issue_created_v2', 'Issue', 'create')).toBe(true) + }) +}) diff --git a/apps/sim/triggers/linear/utils.ts b/apps/sim/triggers/linear/utils.ts index 788a6f22127..a4e0df41383 100644 --- a/apps/sim/triggers/linear/utils.ts +++ b/apps/sim/triggers/linear/utils.ts @@ -206,9 +206,10 @@ export const userOutputs = { type: 'string', description: 'User display name', }, - user_type: { + /** Linear sends this as `actor.type`; exposed as `actorType` here (TriggerOutput reserves `type`). */ + actorType: { type: 'string', - description: 'Actor type (user, bot, etc.)', + description: 'Actor type from Linear (e.g. user, OauthClient, Integration)', }, } as const @@ -297,6 +298,10 @@ export function buildIssueOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -476,6 +481,10 @@ export function buildCommentOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -563,6 +572,10 @@ export function buildProjectOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -706,6 +719,10 @@ export function buildCycleOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -809,6 +826,10 @@ export function buildLabelOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -896,6 +917,10 @@ export function buildProjectUpdateOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -971,6 +996,10 @@ export function buildCustomerRequestOutputs(): Record { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { id: { @@ -1049,7 +1078,7 @@ export function isLinearEventMatch(triggerId: string, eventType: string, action? const normalizedId = triggerId.replace(/_v2$/, '') const config = eventMap[normalizedId] if (!config) { - return true // Unknown trigger, allow through + return false } // Check event type diff --git a/apps/sim/triggers/linear/webhook.ts b/apps/sim/triggers/linear/webhook.ts index 9239e4b494f..cf6eef2d09a 100644 --- a/apps/sim/triggers/linear/webhook.ts +++ b/apps/sim/triggers/linear/webhook.ts @@ -6,7 +6,8 @@ export const linearWebhookTrigger: TriggerConfig = { id: 'linear_webhook', name: 'Linear Webhook', provider: 'linear', - description: 'Trigger workflow from any Linear webhook event', + description: + 'Trigger workflow from Linear events you select when creating the webhook in Linear (not guaranteed to be every model or event type).', version: '1.0.0', icon: LinearIcon, @@ -58,7 +59,7 @@ export const linearWebhookTrigger: TriggerConfig = { type: 'text', defaultValue: linearSetupInstructions( 'all events', - 'This webhook will receive all Linear events. Use the type and action fields in the payload to filter and handle different event types.' + 'When you select resource types in Linear, you receive those data-change events only (see Linear webhooks for supported models). This is not every possible Linear event. Use the type and action fields in the payload to filter further.' ), mode: 'trigger', condition: { @@ -93,6 +94,10 @@ export const linearWebhookTrigger: TriggerConfig = { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { type: 'object', @@ -109,8 +114,8 @@ export const linearWebhookTrigger: TriggerConfig = { headers: { 'Content-Type': 'application/json', 'Linear-Event': 'Issue', - 'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - 'Linear-Signature': 'sha256...', + 'Linear-Delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72', + 'Linear-Signature': '766e1d90a96e2f5ecec342a99c5552999dd95d49250171b902d703fd674f5086', 'User-Agent': 'Linear-Webhook', }, }, diff --git a/apps/sim/triggers/linear/webhook_v2.ts b/apps/sim/triggers/linear/webhook_v2.ts index 4b84363ca7b..567c47213bb 100644 --- a/apps/sim/triggers/linear/webhook_v2.ts +++ b/apps/sim/triggers/linear/webhook_v2.ts @@ -6,7 +6,8 @@ export const linearWebhookV2Trigger: TriggerConfig = { id: 'linear_webhook_v2', name: 'Linear Webhook', provider: 'linear', - description: 'Trigger workflow from any Linear webhook event', + description: + 'Trigger workflow from Linear data-change events included in this webhook subscription (Issues, Comments, Projects, etc.—not every Linear model).', version: '2.0.0', icon: LinearIcon, @@ -14,7 +15,7 @@ export const linearWebhookV2Trigger: TriggerConfig = { triggerId: 'linear_webhook_v2', eventType: 'All Events', additionalNotes: - 'This webhook will receive all Linear events. Use the type and action fields in the payload to filter and handle different event types.', + 'Sim registers this webhook for Issues, Comments, Projects, Cycles, Issue labels, Project updates, and Customer requests—matching what the Linear API allows in one subscription. It does not include every model Linear documents separately (e.g. Documents, Reactions). Use type and action in the payload to filter.', }), outputs: { @@ -42,6 +43,10 @@ export const linearWebhookV2Trigger: TriggerConfig = { type: 'string', description: 'Event creation timestamp', }, + url: { + type: 'string', + description: 'URL of the subject entity in Linear (top-level webhook payload)', + }, actor: userOutputs, data: { type: 'object', @@ -58,8 +63,8 @@ export const linearWebhookV2Trigger: TriggerConfig = { headers: { 'Content-Type': 'application/json', 'Linear-Event': 'Issue', - 'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - 'Linear-Signature': 'sha256...', + 'Linear-Delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72', + 'Linear-Signature': '766e1d90a96e2f5ecec342a99c5552999dd95d49250171b902d703fd674f5086', 'User-Agent': 'Linear-Webhook', }, }, diff --git a/apps/sim/triggers/resend/utils.ts b/apps/sim/triggers/resend/utils.ts index 3ab99c35692..a77b0b1cfaf 100644 --- a/apps/sim/triggers/resend/utils.ts +++ b/apps/sim/triggers/resend/utils.ts @@ -1,5 +1,42 @@ import type { TriggerOutput } from '@/triggers/types' +/** + * Maps Sim Resend trigger IDs to a single Resend webhook event type. + * Kept in sync with subscription registration in `resend` webhook provider. + */ +export const RESEND_TRIGGER_TO_EVENT_TYPE: Record = { + resend_email_sent: 'email.sent', + resend_email_delivered: 'email.delivered', + resend_email_bounced: 'email.bounced', + resend_email_complained: 'email.complained', + resend_email_opened: 'email.opened', + resend_email_clicked: 'email.clicked', + resend_email_failed: 'email.failed', +} + +/** + * Event types registered for the catch-all `resend_webhook` trigger (API + matchEvent). + */ +export const RESEND_ALL_WEBHOOK_EVENT_TYPES: string[] = [ + 'email.sent', + 'email.delivered', + 'email.delivery_delayed', + 'email.bounced', + 'email.complained', + 'email.opened', + 'email.clicked', + 'email.failed', + 'email.received', + 'email.scheduled', + 'email.suppressed', + 'contact.created', + 'contact.updated', + 'contact.deleted', + 'domain.created', + 'domain.updated', + 'domain.deleted', +] + /** * Shared trigger dropdown options for all Resend triggers */ @@ -92,6 +129,14 @@ const recipientOutputs = { }, } as const +const resendEventDataOutput: Record = { + data: { + type: 'json', + description: + 'Raw event `data` from Resend (shape varies by event type: email, contact, domain, etc.)', + }, +} + /** * Build outputs for email sent events */ @@ -99,6 +144,7 @@ export function buildEmailSentOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, } as Record } @@ -109,6 +155,7 @@ export function buildEmailDeliveredOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, } as Record } @@ -119,6 +166,7 @@ export function buildEmailBouncedOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, bounceType: { type: 'string', description: 'Bounce type (e.g., Permanent)' }, bounceSubType: { type: 'string', description: 'Bounce sub-type (e.g., Suppressed)' }, bounceMessage: { type: 'string', description: 'Bounce error message' }, @@ -132,6 +180,7 @@ export function buildEmailComplainedOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, } as Record } @@ -142,6 +191,7 @@ export function buildEmailOpenedOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, } as Record } @@ -152,6 +202,7 @@ export function buildEmailClickedOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, clickIpAddress: { type: 'string', description: 'IP address of the click' }, clickLink: { type: 'string', description: 'URL that was clicked' }, clickTimestamp: { type: 'string', description: 'Click timestamp (ISO 8601)' }, @@ -166,6 +217,7 @@ export function buildEmailFailedOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, } as Record } @@ -177,6 +229,7 @@ export function buildResendOutputs(): Record { return { ...commonEmailOutputs, ...recipientOutputs, + ...resendEventDataOutput, bounceType: { type: 'string', description: 'Bounce type (e.g., Permanent)' }, bounceSubType: { type: 'string', description: 'Bounce sub-type (e.g., Suppressed)' }, bounceMessage: { type: 'string', description: 'Bounce error message' }, diff --git a/apps/sim/triggers/resend/webhook.ts b/apps/sim/triggers/resend/webhook.ts index e320f0be7aa..e5990a5d1ab 100644 --- a/apps/sim/triggers/resend/webhook.ts +++ b/apps/sim/triggers/resend/webhook.ts @@ -16,7 +16,8 @@ export const resendWebhookTrigger: TriggerConfig = { id: 'resend_webhook', name: 'Resend Webhook (All Events)', provider: 'resend', - description: 'Trigger workflow on any Resend webhook event', + description: + 'Trigger on Resend webhook events we subscribe to (email lifecycle, contacts, domains—see Resend docs). Flattened email fields may be null for non-email events; use data for the full payload.', version: '1.0.0', icon: ResendIcon, From e9618d989fe194928ca179ebe4b7544e7cae2a0f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 15:44:25 -0700 Subject: [PATCH 04/23] fix(webhooks): harden Vercel and Greenhouse trigger handlers Require Vercel signing secret and validate x-vercel-signature; add matchEvent with dynamic import, delivery idempotency, strict createSubscription trigger IDs, and formatInput aligned to string IDs. Greenhouse: dynamic import in matchEvent, strict unknown trigger IDs, Greenhouse-Event-ID idempotency header, body fallback keys, clearer optional secret copy. Update generic trigger wording and add tests. --- apps/sim/lib/core/idempotency/service.ts | 3 +- .../lib/core/idempotency/webhook-key.test.ts | 21 +++ apps/sim/lib/webhooks/providers/greenhouse.ts | 33 +++- .../sim/lib/webhooks/providers/vercel.test.ts | 74 ++++++++ apps/sim/lib/webhooks/providers/vercel.ts | 163 +++++++++++++++--- apps/sim/triggers/greenhouse/utils.test.ts | 18 ++ apps/sim/triggers/greenhouse/utils.ts | 13 +- apps/sim/triggers/greenhouse/webhook.ts | 12 +- apps/sim/triggers/vercel/utils.test.ts | 21 +++ apps/sim/triggers/vercel/utils.ts | 38 +++- apps/sim/triggers/vercel/webhook.ts | 13 +- 11 files changed, 363 insertions(+), 46 deletions(-) create mode 100644 apps/sim/lib/core/idempotency/webhook-key.test.ts create mode 100644 apps/sim/lib/webhooks/providers/vercel.test.ts create mode 100644 apps/sim/triggers/greenhouse/utils.test.ts create mode 100644 apps/sim/triggers/vercel/utils.test.ts diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 0f6c726b992..9c4961bd274 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -421,7 +421,8 @@ export class IdempotencyService { normalizedHeaders?.['x-event-id'] || normalizedHeaders?.['x-teams-notification-id'] || normalizedHeaders?.['svix-id'] || - normalizedHeaders?.['linear-delivery'] + normalizedHeaders?.['linear-delivery'] || + normalizedHeaders?.['greenhouse-event-id'] if (webhookIdHeader) { return `${webhookId}:${webhookIdHeader}` diff --git a/apps/sim/lib/core/idempotency/webhook-key.test.ts b/apps/sim/lib/core/idempotency/webhook-key.test.ts new file mode 100644 index 00000000000..e72cc3aa168 --- /dev/null +++ b/apps/sim/lib/core/idempotency/webhook-key.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { IdempotencyService } from '@/lib/core/idempotency/service' + +vi.mock('@/lib/core/utils/uuid', () => ({ + generateId: vi.fn(() => 'fallback-uuid'), +})) + +describe('IdempotencyService.createWebhookIdempotencyKey', () => { + it('uses Greenhouse-Event-ID when present', () => { + const key = IdempotencyService.createWebhookIdempotencyKey( + 'wh_1', + { 'greenhouse-event-id': 'evt-gh-99' }, + {}, + 'greenhouse' + ) + expect(key).toBe('wh_1:evt-gh-99') + }) +}) diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts index e10f6a76030..4cb412ca069 100644 --- a/apps/sim/lib/webhooks/providers/greenhouse.ts +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -8,7 +8,6 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils' const logger = createLogger('WebhookProvider:Greenhouse') @@ -58,6 +57,7 @@ export const greenhouseHandler: WebhookProviderHandler = { const action = b.action as string | undefined if (triggerId && triggerId !== 'greenhouse_webhook') { + const { isGreenhouseEventMatch } = await import('@/triggers/greenhouse/utils') if (!isGreenhouseEventMatch(triggerId, action || '')) { logger.debug( `[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`, @@ -75,4 +75,35 @@ export const greenhouseHandler: WebhookProviderHandler = { return true }, + + /** + * Fallback when Greenhouse-Event-ID is not available on headers (see idempotency service). + * Prefer stable resource keys; offer events include version for new versions. + */ + extractIdempotencyId(body: unknown) { + const b = body as Record + const action = typeof b.action === 'string' ? b.action : '' + const payload = (b.payload || {}) as Record + + const application = (payload.application || {}) as Record + const appId = application.id + if (appId !== undefined && appId !== null && appId !== '') { + return `greenhouse:${action}:application:${String(appId)}` + } + + const offerId = payload.id + const offerVersion = payload.version + if (offerId !== undefined && offerId !== null && offerId !== '') { + const v = offerVersion !== undefined && offerVersion !== null ? String(offerVersion) : '0' + return `greenhouse:${action}:offer:${String(offerId)}:${v}` + } + + const job = (payload.job || {}) as Record + const jobId = job.id + if (jobId !== undefined && jobId !== null && jobId !== '') { + return `greenhouse:${action}:job:${String(jobId)}` + } + + return null + }, } diff --git a/apps/sim/lib/webhooks/providers/vercel.test.ts b/apps/sim/lib/webhooks/providers/vercel.test.ts new file mode 100644 index 00000000000..0792b26c45e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/vercel.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import crypto from 'crypto' +import { createMockRequest } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' +import { vercelHandler } from '@/lib/webhooks/providers/vercel' + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +describe('vercelHandler', () => { + describe('verifyAuth', () => { + const secret = 'test-signing-secret' + const rawBody = JSON.stringify({ type: 'deployment.created', id: 'del_1' }) + const signature = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex') + + it('returns 401 when webhookSecret is missing', async () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'x-vercel-signature': signature, + }) + const res = await vercelHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns 401 when signature header is missing', async () => { + const request = createMockRequest('POST', JSON.parse(rawBody), {}) + const res = await vercelHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns null when signature is valid', async () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'x-vercel-signature': signature, + }) + const res = await vercelHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + }) + + describe('extractIdempotencyId', () => { + it('uses top-level delivery id from Vercel payload', () => { + expect(vercelHandler.extractIdempotencyId!({ id: 'abc123' })).toBe('vercel:abc123') + expect(vercelHandler.extractIdempotencyId!({})).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 7f8a0f5eccc..6ee5a14907a 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -1,29 +1,79 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, + EventMatchContext, FormatInputContext, FormatInputResult, SubscriptionContext, SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Vercel') +function verifyVercelSignature(secret: string, signature: string, rawBody: string): boolean { + const hash = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex') + return safeCompare(hash, signature) +} + export const vercelHandler: WebhookProviderHandler = { - verifyAuth: createHmacVerifier({ - configKey: 'webhookSecret', - headerName: 'x-vercel-signature', - validateFn: (secret, signature, body) => { - const hash = crypto.createHmac('sha1', secret).update(body, 'utf8').digest('hex') - return safeCompare(hash, signature) - }, - providerLabel: 'Vercel', - }), + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { + const secret = (providerConfig.webhookSecret as string | undefined)?.trim() + if (!secret) { + logger.warn(`[${requestId}] Vercel webhook secret missing; rejecting delivery`) + return new NextResponse( + 'Unauthorized - Vercel webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.', + { status: 401 } + ) + } + + const signature = request.headers.get('x-vercel-signature') + if (!signature) { + logger.warn(`[${requestId}] Vercel webhook missing x-vercel-signature header`) + return new NextResponse('Unauthorized - Missing Vercel signature', { status: 401 }) + } + + if (!verifyVercelSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] Vercel signature verification failed`) + return new NextResponse('Unauthorized - Invalid Vercel signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + const eventType = obj.type as string | undefined + + if (triggerId && triggerId !== 'vercel_webhook') { + const { isVercelEventMatch } = await import('@/triggers/vercel/utils') + if (!isVercelEventMatch(triggerId, eventType)) { + logger.debug(`[${requestId}] Vercel event mismatch for trigger ${triggerId}. Skipping.`, { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + eventType, + }) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const id = (body as Record)?.id + if (id === undefined || id === null || id === '') { + return null + } + return `vercel:${String(id)}` + }, async createSubscription(ctx: SubscriptionContext): Promise { const { webhook, requestId } = ctx @@ -52,9 +102,8 @@ export const vercelHandler: WebhookProviderHandler = { } if (triggerId && !(triggerId in eventTypeMap)) { - logger.warn( - `[${requestId}] Unknown triggerId for Vercel: ${triggerId}, defaulting to all events`, - { triggerId, webhookId: webhook.id } + throw new Error( + `Unknown Vercel trigger "${triggerId}". Remove and re-add the Vercel trigger, then save again.` ) } @@ -147,10 +196,17 @@ export const vercelHandler: WebhookProviderHandler = { { vercelWebhookId: externalId } ) + const signingSecret = responseBody.secret as string | undefined + if (!signingSecret) { + throw new Error( + 'Vercel webhook was created but no signing secret was returned. Delete the webhook in Vercel and save this trigger again.' + ) + } + return { providerConfigUpdates: { externalId, - webhookSecret: (responseBody.secret as string) || '', + webhookSecret: signingSecret, }, } } catch (error: unknown) { @@ -206,20 +262,77 @@ export const vercelHandler: WebhookProviderHandler = { const body = ctx.body as Record const payload = (body.payload || {}) as Record + const deployment = payload.deployment ?? null + const project = payload.project ?? null + const team = payload.team ?? null + const user = payload.user ?? null + const domain = payload.domain ?? null + return { input: { - type: body.type || '', - id: body.id || '', - createdAt: body.createdAt || 0, - region: body.region || null, + type: body.type ?? '', + id: body.id != null ? String(body.id) : '', + createdAt: (() => { + const v = body.createdAt + if (typeof v === 'number' && !Number.isNaN(v)) { + return v + } + if (typeof v === 'string') { + const parsed = Date.parse(v) + return Number.isNaN(parsed) ? 0 : parsed + } + const n = Number(v) + return Number.isNaN(n) ? 0 : n + })(), + region: body.region != null ? String(body.region) : null, payload, - deployment: payload.deployment || null, - project: payload.project || null, - team: payload.team || null, - user: payload.user || null, - target: payload.target || null, - plan: payload.plan || null, - domain: payload.domain || null, + deployment: + deployment && typeof deployment === 'object' + ? { + id: + (deployment as Record).id != null + ? String((deployment as Record).id) + : '', + url: ((deployment as Record).url as string) ?? '', + name: ((deployment as Record).name as string) ?? '', + } + : null, + project: + project && typeof project === 'object' + ? { + id: + (project as Record).id != null + ? String((project as Record).id) + : '', + name: ((project as Record).name as string) ?? '', + } + : null, + team: + team && typeof team === 'object' + ? { + id: + (team as Record).id != null + ? String((team as Record).id) + : '', + } + : null, + user: + user && typeof user === 'object' + ? { + id: + (user as Record).id != null + ? String((user as Record).id) + : '', + } + : null, + target: payload.target != null ? String(payload.target) : null, + plan: payload.plan != null ? String(payload.plan) : null, + domain: + domain && typeof domain === 'object' + ? { + name: ((domain as Record).name as string) ?? '', + } + : null, }, } }, diff --git a/apps/sim/triggers/greenhouse/utils.test.ts b/apps/sim/triggers/greenhouse/utils.test.ts new file mode 100644 index 00000000000..cba0a733323 --- /dev/null +++ b/apps/sim/triggers/greenhouse/utils.test.ts @@ -0,0 +1,18 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils' + +describe('isGreenhouseEventMatch', () => { + it('matches mapped trigger ids to Greenhouse action strings', () => { + expect(isGreenhouseEventMatch('greenhouse_new_application', 'new_candidate_application')).toBe( + true + ) + expect(isGreenhouseEventMatch('greenhouse_new_application', 'hire_candidate')).toBe(false) + }) + + it('rejects unknown trigger ids (no permissive fallback)', () => { + expect(isGreenhouseEventMatch('greenhouse_unknown', 'new_candidate_application')).toBe(false) + }) +}) diff --git a/apps/sim/triggers/greenhouse/utils.ts b/apps/sim/triggers/greenhouse/utils.ts index 15972379e03..d125810fc45 100644 --- a/apps/sim/triggers/greenhouse/utils.ts +++ b/apps/sim/triggers/greenhouse/utils.ts @@ -12,7 +12,7 @@ export const greenhouseTriggerOptions = [ { label: 'Offer Created', id: 'greenhouse_offer_created' }, { label: 'Job Created', id: 'greenhouse_job_created' }, { label: 'Job Updated', id: 'greenhouse_job_updated' }, - { label: 'Generic Webhook (All Events)', id: 'greenhouse_webhook' }, + { label: 'All configured Greenhouse events', id: 'greenhouse_webhook' }, ] /** @@ -34,8 +34,8 @@ export const GREENHOUSE_EVENT_MAP: Record = { */ export function isGreenhouseEventMatch(triggerId: string, action: string): boolean { const expectedAction = GREENHOUSE_EVENT_MAP[triggerId] - if (!expectedAction) { - return true + if (expectedAction === undefined) { + return false } return action === expectedAction } @@ -51,7 +51,8 @@ export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[] title: 'Secret Key (Optional)', type: 'short-input', placeholder: 'Enter the same secret key configured in Greenhouse', - description: 'Used to verify webhook signatures via HMAC-SHA256.', + description: + 'When set, requests must include a valid Signature header (HMAC-SHA256). If left empty, the endpoint does not verify signatures—only use on a private URL you fully control.', password: true, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, @@ -69,8 +70,8 @@ export function greenhouseSetupInstructions(eventType: string): string { 'In Greenhouse, go to Configure > Dev Center > Webhooks.', 'Click Create New Webhook.', 'Paste the Webhook URL into the Endpoint URL field.', - 'Enter a Secret Key for signature verification (optional).', - `Under When, select the ${eventType} event.`, + 'Enter a Secret Key for HMAC signature verification (recommended). Leave empty only if you accept unauthenticated POSTs to this URL.', + `Under When, select the appropriate ${eventType}.`, 'Click Create Webhook to save.', 'Click "Save" above to activate your trigger.', ] diff --git a/apps/sim/triggers/greenhouse/webhook.ts b/apps/sim/triggers/greenhouse/webhook.ts index de436a89748..a527442366f 100644 --- a/apps/sim/triggers/greenhouse/webhook.ts +++ b/apps/sim/triggers/greenhouse/webhook.ts @@ -9,22 +9,22 @@ import { import type { TriggerConfig } from '@/triggers/types' /** - * Greenhouse Generic Webhook Trigger - * - * Accepts all Greenhouse webhook events without filtering. + * Greenhouse generic webhook trigger. + * Event filtering is determined by which events you enable on the Greenhouse webhook endpoint. */ export const greenhouseWebhookTrigger: TriggerConfig = { id: 'greenhouse_webhook', - name: 'Greenhouse Webhook (All Events)', + name: 'Greenhouse Webhook (Endpoint Events)', provider: 'greenhouse', - description: 'Trigger workflow on any Greenhouse webhook event', + description: + 'Trigger on whichever event types you select for this URL in Greenhouse. Sim does not filter deliveries for this trigger.', version: '1.0.0', icon: GreenhouseIcon, subBlocks: buildTriggerSubBlocks({ triggerId: 'greenhouse_webhook', triggerOptions: greenhouseTriggerOptions, - setupInstructions: greenhouseSetupInstructions('All Events'), + setupInstructions: greenhouseSetupInstructions('Greenhouse event types for this URL'), extraFields: buildGreenhouseExtraFields('greenhouse_webhook'), }), diff --git a/apps/sim/triggers/vercel/utils.test.ts b/apps/sim/triggers/vercel/utils.test.ts new file mode 100644 index 00000000000..2622950f226 --- /dev/null +++ b/apps/sim/triggers/vercel/utils.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isVercelEventMatch } from '@/triggers/vercel/utils' + +describe('isVercelEventMatch', () => { + it('matches specialized triggers to Vercel type strings', () => { + expect(isVercelEventMatch('vercel_deployment_created', 'deployment.created')).toBe(true) + expect(isVercelEventMatch('vercel_deployment_created', 'deployment.ready')).toBe(false) + }) + + it('does not match unknown trigger ids', () => { + expect(isVercelEventMatch('vercel_unknown_trigger', 'deployment.created')).toBe(false) + }) + + it('allows any event type for the curated generic trigger id', () => { + expect(isVercelEventMatch('vercel_webhook', 'deployment.succeeded')).toBe(true) + expect(isVercelEventMatch('vercel_webhook', undefined)).toBe(true) + }) +}) diff --git a/apps/sim/triggers/vercel/utils.ts b/apps/sim/triggers/vercel/utils.ts index e0f016dc9f5..e86dc8248c2 100644 --- a/apps/sim/triggers/vercel/utils.ts +++ b/apps/sim/triggers/vercel/utils.ts @@ -12,9 +12,35 @@ export const vercelTriggerOptions = [ { label: 'Project Created', id: 'vercel_project_created' }, { label: 'Project Removed', id: 'vercel_project_removed' }, { label: 'Domain Created', id: 'vercel_domain_created' }, - { label: 'Generic Webhook (All Events)', id: 'vercel_webhook' }, + { label: 'Common events (curated list)', id: 'vercel_webhook' }, ] +/** Maps Sim trigger IDs to Vercel webhook `type` strings (see Vercel Webhooks API). */ +export const VERCEL_TRIGGER_EVENT_TYPES: Record = { + vercel_deployment_created: 'deployment.created', + vercel_deployment_ready: 'deployment.ready', + vercel_deployment_error: 'deployment.error', + vercel_deployment_canceled: 'deployment.canceled', + vercel_project_created: 'project.created', + vercel_project_removed: 'project.removed', + vercel_domain_created: 'domain.created', +} + +/** + * Returns whether the incoming Vercel event matches the configured trigger. + * `vercel_webhook` is handled only at subscription time; deliveries are not filtered here. + */ +export function isVercelEventMatch(triggerId: string, eventType: string | undefined): boolean { + if (triggerId === 'vercel_webhook') { + return true + } + const expected = VERCEL_TRIGGER_EVENT_TYPES[triggerId] + if (!expected) { + return false + } + return eventType === expected +} + /** * Generates setup instructions for Vercel webhooks. * Webhooks are automatically created via the Vercel API. @@ -84,7 +110,7 @@ const coreOutputs = { }, id: { type: 'string', - description: 'Unique webhook delivery ID', + description: 'Unique webhook delivery ID (string)', }, createdAt: { type: 'number', @@ -96,6 +122,11 @@ const coreOutputs = { }, } as const +/** Raw `payload` object from the Vercel webhook body (event-specific shape). */ +const payloadOutput = { + payload: { type: 'json' as const, description: 'Raw event payload from Vercel' }, +} as const + /** * Deployment-specific output fields */ @@ -165,6 +196,7 @@ const domainOutputs = { export function buildDeploymentOutputs(): Record { return { ...coreOutputs, + ...payloadOutput, ...deploymentOutputs, } as Record } @@ -175,6 +207,7 @@ export function buildDeploymentOutputs(): Record { export function buildProjectOutputs(): Record { return { ...coreOutputs, + ...payloadOutput, ...projectOutputs, } as Record } @@ -185,6 +218,7 @@ export function buildProjectOutputs(): Record { export function buildDomainOutputs(): Record { return { ...coreOutputs, + ...payloadOutput, ...domainOutputs, } as Record } diff --git a/apps/sim/triggers/vercel/webhook.ts b/apps/sim/triggers/vercel/webhook.ts index dbe7868ff59..e4c0a00634f 100644 --- a/apps/sim/triggers/vercel/webhook.ts +++ b/apps/sim/triggers/vercel/webhook.ts @@ -9,21 +9,24 @@ import { } from '@/triggers/vercel/utils' /** - * Generic Vercel Webhook Trigger - * Captures all Vercel webhook events + * Vercel webhook trigger for a curated bundle of frequent event types. + * Vercel requires an explicit event list; this is not every event in their catalog. */ export const vercelWebhookTrigger: TriggerConfig = { id: 'vercel_webhook', - name: 'Vercel Webhook (All Events)', + name: 'Vercel Webhook (Common Events)', provider: 'vercel', - description: 'Trigger workflow on any Vercel webhook event', + description: + 'Trigger on a curated set of common Vercel events (deployments, projects, domains, edge config). Pick a specific trigger to listen to one event type only.', version: '1.0.0', icon: VercelIcon, subBlocks: buildTriggerSubBlocks({ triggerId: 'vercel_webhook', triggerOptions: vercelTriggerOptions, - setupInstructions: vercelSetupInstructions('All Events'), + setupInstructions: vercelSetupInstructions( + 'common deployment, project, domain, and edge-config events' + ), extraFields: buildVercelExtraFields('vercel_webhook'), }), From 317d4abc5d37eaf50e70c5acdfee452b13b53b07 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 15:27:15 -0700 Subject: [PATCH 05/23] fix(gong): JWT verification, trigger UX, alignment script - Optional RS256 verification when Gong JWT public key is configured (webhook_url + body_sha256 per Gong docs); URL secrecy when unset. - Document that Gong rules filter calls; payload has no event type; add eventType + callId outputs for discoverability. - Refactor Gong triggers to buildTriggerSubBlocks + shared JWT field; setup copy matches security model. - Add check-trigger-alignment.ts (Gong bundled; extend PROVIDER_CHECKS for others) and update add-trigger guidance paths. Made-with: Cursor --- .agents/skills/add-trigger/SKILL.md | 4 +- .claude/commands/add-trigger.md | 6 +- apps/sim/lib/webhooks/providers/gong.test.ts | 129 +++++++++++++++++++ apps/sim/lib/webhooks/providers/gong.ts | 124 ++++++++++++++++++ apps/sim/scripts/check-trigger-alignment.ts | 86 +++++++++++++ apps/sim/triggers/gong/call_completed.ts | 57 +++----- apps/sim/triggers/gong/utils.ts | 47 ++++++- apps/sim/triggers/gong/webhook.ts | 64 ++------- 8 files changed, 413 insertions(+), 104 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/gong.test.ts create mode 100644 apps/sim/scripts/check-trigger-alignment.ts diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index fbf27ef625d..fa9b7be1d32 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -596,7 +596,7 @@ if (foundWebhook.provider === '{service}') { Run the alignment checker: ```bash -bunx scripts/check-trigger-alignment.ts {service} +bun run apps/sim/scripts/check-trigger-alignment.ts {service} ``` ## Trigger Outputs @@ -699,7 +699,7 @@ export const {service}WebhookTrigger: TriggerConfig = { ### Webhook Input Formatting - [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) - [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment +- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify alignment ### Testing - [ ] Run `bun run type-check` to verify no TypeScript errors diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index d53e1bb609f..dbda690b3df 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -708,9 +708,9 @@ export const {service}Handler: WebhookProviderHandler = { ### Verify Alignment -Run the alignment checker: +Run the alignment checker (from the `sim` git root). Supported providers have a check in `apps/sim/scripts/check-trigger-alignment.ts` (`PROVIDER_CHECKS`); others exit 0 with a note to add a handler-only entry or verify manually. ```bash -bunx scripts/check-trigger-alignment.ts {service} +bun run apps/sim/scripts/check-trigger-alignment.ts {service} ``` ## Trigger Outputs @@ -820,7 +820,7 @@ export const {service}WebhookTrigger: TriggerConfig = { ### Testing - [ ] Run `bun run type-check` to verify no TypeScript errors -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify output alignment +- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify output alignment - [ ] Restart dev server to pick up new triggers - [ ] Test trigger UI shows correctly in the block - [ ] Test automatic webhook creation works (if applicable) diff --git a/apps/sim/lib/webhooks/providers/gong.test.ts b/apps/sim/lib/webhooks/providers/gong.test.ts new file mode 100644 index 00000000000..93e84116299 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gong.test.ts @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto' +import * as jose from 'jose' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { + GONG_JWT_PUBLIC_KEY_CONFIG_KEY, + gongHandler, + normalizeGongPublicKeyPem, + verifyGongJwtAuth, +} from '@/lib/webhooks/providers/gong' + +describe('normalizeGongPublicKeyPem', () => { + it('passes through PEM', () => { + const pem = '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----' + expect(normalizeGongPublicKeyPem(pem)).toBe(pem) + }) + + it('wraps raw base64', () => { + const raw = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3' + const out = normalizeGongPublicKeyPem(raw) + expect(out).toContain('BEGIN PUBLIC KEY') + expect(out).toContain('END PUBLIC KEY') + expect(out).toContain('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3') + }) + + it('returns null for garbage', () => { + expect(normalizeGongPublicKeyPem('not-base64!!!')).toBeNull() + }) +}) + +describe('gongHandler verifyAuth (JWT)', () => { + it('returns null when JWT public key is not configured', async () => { + const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', { + method: 'POST', + body: '{}', + }) + const rawBody = '{}' + const res = await verifyGongJwtAuth({ + webhook: {}, + workflow: {}, + request, + rawBody, + requestId: 't1', + providerConfig: {}, + }) + expect(res).toBeNull() + }) + + it('returns 401 when key is configured but Authorization is missing', async () => { + const { publicKey } = await jose.generateKeyPair('RS256') + const spki = await jose.exportSPKI(publicKey) + const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', { + method: 'POST', + body: '{}', + }) + const res = await verifyGongJwtAuth({ + webhook: {}, + workflow: {}, + request, + rawBody: '{}', + requestId: 't2', + providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki }, + }) + expect(res?.status).toBe(401) + }) + + it('accepts a valid Gong-style JWT', async () => { + const { publicKey, privateKey } = await jose.generateKeyPair('RS256') + const spki = await jose.exportSPKI(publicKey) + const url = 'https://app.example.com/api/webhooks/trigger/test-path' + const rawBody = '{"callData":{}}' + const bodySha = createHash('sha256').update(rawBody, 'utf8').digest('hex') + + const jwt = await new jose.SignJWT({ + webhook_url: url, + body_sha256: bodySha, + }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(privateKey) + + const request = new NextRequest(url, { + method: 'POST', + body: rawBody, + headers: { Authorization: `Bearer ${jwt}` }, + }) + + const res = await gongHandler.verifyAuth!({ + webhook: {}, + workflow: {}, + request, + rawBody, + requestId: 't3', + providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki }, + }) + expect(res).toBeNull() + }) + + it('rejects JWT when body hash does not match', async () => { + const { publicKey, privateKey } = await jose.generateKeyPair('RS256') + const spki = await jose.exportSPKI(publicKey) + const url = 'https://app.example.com/api/webhooks/trigger/x' + const rawBody = '{"a":1}' + + const jwt = await new jose.SignJWT({ + webhook_url: url, + body_sha256: 'deadbeef', + }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(privateKey) + + const request = new NextRequest(url, { + method: 'POST', + body: rawBody, + headers: { Authorization: jwt }, + }) + + const res = await verifyGongJwtAuth({ + webhook: {}, + workflow: {}, + request, + rawBody, + requestId: 't4', + providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki }, + }) + expect(res?.status).toBe(401) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts index c3272f5c4c1..9c0eec6b297 100644 --- a/apps/sim/lib/webhooks/providers/gong.ts +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -1,15 +1,137 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import * as jose from 'jose' +import { NextResponse } from 'next/server' import type { + AuthContext, FormatInputContext, FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +const logger = createLogger('WebhookProvider:Gong') + +/** providerConfig key: PEM or raw base64 RSA public key from Gong (Signed JWT header auth). */ +export const GONG_JWT_PUBLIC_KEY_CONFIG_KEY = 'gongJwtPublicKeyPem' + +/** + * Gong automation webhooks support either URL secrecy (token in path) or a signed JWT in + * `Authorization` (see https://help.gong.io/docs/create-a-webhook-rule). + * When {@link GONG_JWT_PUBLIC_KEY_CONFIG_KEY} is set, we verify RS256 per Gong's JWT guide. + * When unset, only the unguessable Sim webhook path authenticates the request (same as before). + */ +export function normalizeGongPublicKeyPem(input: string): string | null { + const trimmed = input.trim() + if (!trimmed) return null + if (trimmed.includes('BEGIN PUBLIC KEY')) { + return trimmed + } + const b64 = trimmed.replace(/\s/g, '') + if (!/^[A-Za-z0-9+/]+=*$/.test(b64)) { + return null + } + const chunked = b64.match(/.{1,64}/g)?.join('\n') ?? b64 + return `-----BEGIN PUBLIC KEY-----\n${chunked}\n-----END PUBLIC KEY-----` +} + +function normalizeUrlForGongJwtClaim(url: string): string { + try { + const u = new URL(url) + let path = u.pathname + if (path.length > 1 && path.endsWith('/')) { + path = path.slice(0, -1) + } + return `${u.protocol}//${u.host.toLowerCase()}${path}` + } catch { + return url.trim() + } +} + +function parseAuthorizationJwt(authHeader: string | null): string | null { + if (!authHeader) return null + const trimmed = authHeader.trim() + if (trimmed.toLowerCase().startsWith('bearer ')) { + return trimmed.slice(7).trim() || null + } + return trimmed || null +} + +export async function verifyGongJwtAuth(ctx: AuthContext): Promise { + const { request, rawBody, requestId, providerConfig } = ctx + const rawKey = providerConfig[GONG_JWT_PUBLIC_KEY_CONFIG_KEY] + if (typeof rawKey !== 'string') { + return null + } + + const pem = normalizeGongPublicKeyPem(rawKey) + if (!pem) { + logger.warn(`[${requestId}] Gong JWT public key configured but could not be normalized`) + return new NextResponse('Unauthorized - Invalid Gong JWT public key configuration', { + status: 401, + }) + } + + const token = parseAuthorizationJwt(request.headers.get('authorization')) + if (!token) { + logger.warn(`[${requestId}] Gong JWT verification enabled but Authorization header missing`) + return new NextResponse('Unauthorized - Missing Gong JWT', { status: 401 }) + } + + let payload: jose.JWTPayload + try { + const key = await jose.importSPKI(pem, 'RS256') + const verified = await jose.jwtVerify(token, key, { algorithms: ['RS256'] }) + payload = verified.payload + } catch (error) { + logger.warn(`[${requestId}] Gong JWT verification failed`, { + message: error instanceof Error ? error.message : String(error), + }) + return new NextResponse('Unauthorized - Invalid Gong JWT', { status: 401 }) + } + + const claimUrl = payload.webhook_url + if (typeof claimUrl !== 'string' || !claimUrl) { + logger.warn(`[${requestId}] Gong JWT missing webhook_url claim`) + return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 }) + } + + const claimDigest = payload.body_sha256 + if (typeof claimDigest !== 'string' || !claimDigest) { + logger.warn(`[${requestId}] Gong JWT missing body_sha256 claim`) + return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 }) + } + + const expectedDigest = createHash('sha256').update(rawBody, 'utf8').digest('hex') + if (claimDigest !== expectedDigest) { + logger.warn(`[${requestId}] Gong JWT body_sha256 mismatch`) + return new NextResponse('Unauthorized - Gong JWT body mismatch', { status: 401 }) + } + + const receivedNorm = normalizeUrlForGongJwtClaim(request.url) + const claimNorm = normalizeUrlForGongJwtClaim(claimUrl) + if (receivedNorm !== claimNorm) { + logger.warn(`[${requestId}] Gong JWT webhook_url mismatch`, { + receivedNorm, + claimNorm, + }) + return new NextResponse('Unauthorized - Gong JWT URL mismatch', { status: 401 }) + } + + return null +} + export const gongHandler: WebhookProviderHandler = { + verifyAuth: verifyGongJwtAuth, + async formatInput({ body }: FormatInputContext): Promise { const b = body as Record const callData = b.callData as Record | undefined const metaData = (callData?.metaData as Record) || {} const content = callData?.content as Record | undefined + const callId = + typeof metaData.id === 'string' || typeof metaData.id === 'number' + ? String(metaData.id) + : null return { input: { @@ -19,6 +141,8 @@ export const gongHandler: WebhookProviderHandler = { parties: (callData?.parties as unknown[]) || [], context: (callData?.context as unknown[]) || [], trackers: (content?.trackers as unknown[]) || [], + eventType: 'gong.automation_rule', + callId: callId ?? null, }, } }, diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts new file mode 100644 index 00000000000..cacdde96227 --- /dev/null +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env bun + +/** + * Compares top-level trigger output keys with keys returned from the provider's formatInput. + * + * Many trigger files import `buildTriggerSubBlocks` from `@/triggers`, which pulls the full + * registry and is unsafe to load from a standalone script. This runner uses **per-provider + * entry points** (utils + handler only) where implemented. + * + * Usage (from repo root): + * bun run apps/sim/scripts/check-trigger-alignment.ts + * + * Or from apps/sim: + * bun run scripts/check-trigger-alignment.ts + */ + +if (!process.env.DATABASE_URL) { + process.env.DATABASE_URL = + 'postgresql://127.0.0.1:5432/__sim_trigger_alignment_check_placeholder__' +} + +import type { TriggerOutput } from '@/triggers/types' + +type CheckFn = () => Promise<{ + referenceLabel: string + outputKeys: string[] + formatInputKeys: string[] +}> + +const PROVIDER_CHECKS: Record = { + gong: async () => { + const { buildCallOutputs } = await import('@/triggers/gong/utils') + const { gongHandler } = await import('@/lib/webhooks/providers/gong') + const outputs = buildCallOutputs() as Record + const result = await gongHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: {}, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildCallOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, +} + +const provider = process.argv[2]?.trim() +if (!provider) { + console.error('Usage: bun run apps/sim/scripts/check-trigger-alignment.ts ') + process.exit(1) +} + +const run = PROVIDER_CHECKS[provider] +if (!run) { + console.log( + `[${provider}] No bundled alignment check yet. Add an entry to PROVIDER_CHECKS in apps/sim/scripts/check-trigger-alignment.ts (import utils + handler only, not @/triggers/registry), or compare output keys manually.` + ) + process.exit(0) +} + +const { referenceLabel, outputKeys, formatInputKeys } = await run() +const missingInInput = outputKeys.filter((k) => !formatInputKeys.includes(k)) +const extraInInput = formatInputKeys.filter((k) => !outputKeys.includes(k)) + +console.log(`Provider: ${provider}`) +console.log(`Reference: ${referenceLabel}`) +console.log('outputs (top-level):', outputKeys.join(', ') || '(none)') +console.log('formatInput keys:', formatInputKeys.join(', ') || '(none)') + +if (missingInInput.length > 0) { + console.error('MISSING in formatInput:', missingInInput.join(', ')) +} +if (extraInInput.length > 0) { + console.warn('EXTRA in formatInput (not in outputs):', extraInInput.join(', ')) +} + +if (missingInInput.length > 0) { + process.exit(1) +} + +console.log(`\n[${provider}] Alignment check passed.`) +process.exit(0) diff --git a/apps/sim/triggers/gong/call_completed.ts b/apps/sim/triggers/gong/call_completed.ts index b98e6bd5c22..900d09786e2 100644 --- a/apps/sim/triggers/gong/call_completed.ts +++ b/apps/sim/triggers/gong/call_completed.ts @@ -1,12 +1,19 @@ import { GongIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' import type { TriggerConfig } from '@/triggers/types' -import { buildCallOutputs, gongSetupInstructions } from './utils' +import { + buildCallOutputs, + buildGongExtraFields, + gongSetupInstructions, + gongTriggerOptions, +} from './utils' /** * Gong Call Completed Trigger * * Secondary trigger - does NOT include the dropdown (the generic webhook trigger has it). - * Fires when a call matching the configured rule is processed in Gong. + * Use this when the workflow is scoped to “completed call” rules; Gong still filters calls in the rule — + * the payload shape is the same as other call webhooks. */ export const gongCallCompletedTrigger: TriggerConfig = { id: 'gong_call_completed', @@ -16,46 +23,12 @@ export const gongCallCompletedTrigger: TriggerConfig = { version: '1.0.0', icon: GongIcon, - subBlocks: [ - { - id: 'webhookUrlDisplay', - title: 'Webhook URL', - type: 'short-input', - readOnly: true, - showCopyButton: true, - useWebhookUrl: true, - placeholder: 'Webhook URL will be generated', - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'gong_call_completed', - }, - }, - { - id: 'triggerSave', - title: '', - type: 'trigger-save', - hideFromPreview: true, - mode: 'trigger', - triggerId: 'gong_call_completed', - condition: { - field: 'selectedTriggerId', - value: 'gong_call_completed', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: gongSetupInstructions('Call Completed'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'gong_call_completed', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gong_call_completed', + triggerOptions: gongTriggerOptions, + setupInstructions: gongSetupInstructions('Call Completed'), + extraFields: buildGongExtraFields('gong_call_completed'), + }), outputs: buildCallOutputs(), diff --git a/apps/sim/triggers/gong/utils.ts b/apps/sim/triggers/gong/utils.ts index a203478ba27..a9b8bd3024b 100644 --- a/apps/sim/triggers/gong/utils.ts +++ b/apps/sim/triggers/gong/utils.ts @@ -1,3 +1,5 @@ +import { GONG_JWT_PUBLIC_KEY_CONFIG_KEY } from '@/lib/webhooks/providers/gong' +import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' /** @@ -8,6 +10,26 @@ export const gongTriggerOptions = [ { label: 'Call Completed', id: 'gong_call_completed' }, ] +/** + * Optional Gong "Signed JWT header" verification (paste the public key from Gong). + * When empty, security relies on the unguessable webhook URL path (Gong "URL includes key"). + */ +export function buildGongExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: GONG_JWT_PUBLIC_KEY_CONFIG_KEY, + title: 'Gong JWT public key (optional)', + type: 'long-input', + placeholder: + 'Paste the full PEM from Gong (-----BEGIN PUBLIC KEY----- …) or raw base64. Leave empty if the rule uses URL-includes-key only.', + description: + 'Required only when your Gong rule uses **Signed JWT header**. Sim verifies RS256, `webhook_url`, and `body_sha256` per Gong. If empty, only the webhook URL path authenticates the request.', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + /** * Generate setup instructions for a specific Gong event type */ @@ -17,18 +39,20 @@ export function gongSetupInstructions(eventType: string): string { 'Copy the Webhook URL above.', 'In Gong, go to Admin center > Settings > Ecosystem > Automation rules.', 'Click "+ Add Rule" to create a new automation rule.', - `Configure rule filters to match ${eventType} calls.`, + `Configure rule filters in Gong for the calls you want (e.g. ${eventType}). Gong does not send a separate event name in the JSON payload — filtering happens in the rule.`, 'Under Actions, select "Fire webhook".', - 'Paste the Webhook URL into the destination field.', - 'Choose an authentication method (URL includes key or Signed JWT header).', + 'Paste this workflow’s Webhook URL into the destination field.', + 'Authentication: Use either URL includes key (recommended default — Sim’s URL is already secret) or Signed JWT header. If you use Signed JWT, paste Gong’s public key into the field above so Sim can verify the Authorization token.', 'Save the rule and click "Save" above to activate your trigger.', ] return instructions - .map( - (instruction, index) => - `
${index === 0 ? instruction : `${index}. ${instruction}`}
` - ) + .map((instruction, index) => { + if (index === 0) { + return `
${instruction}
` + } + return `
${index}. ${instruction}
` + }) .join('') } @@ -38,6 +62,15 @@ export function gongSetupInstructions(eventType: string): string { */ export function buildCallOutputs(): Record { return { + eventType: { + type: 'string', + description: + 'Constant identifier for automation-rule webhooks (`gong.automation_rule`). Gong does not send distinct event names in the payload.', + }, + callId: { + type: 'string', + description: 'Gong call ID (same value as metaData.id when present)', + }, isTest: { type: 'boolean', description: 'Whether this is a test webhook from the Gong UI', diff --git a/apps/sim/triggers/gong/webhook.ts b/apps/sim/triggers/gong/webhook.ts index 164a81f2d23..2e59fd0d2df 100644 --- a/apps/sim/triggers/gong/webhook.ts +++ b/apps/sim/triggers/gong/webhook.ts @@ -1,6 +1,12 @@ import { GongIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' import type { TriggerConfig } from '@/triggers/types' -import { buildGenericOutputs, gongSetupInstructions, gongTriggerOptions } from './utils' +import { + buildGenericOutputs, + buildGongExtraFields, + gongSetupInstructions, + gongTriggerOptions, +} from './utils' /** * Gong Generic Webhook Trigger @@ -16,55 +22,13 @@ export const gongWebhookTrigger: TriggerConfig = { version: '1.0.0', icon: GongIcon, - subBlocks: [ - { - id: 'selectedTriggerId', - title: 'Trigger Type', - type: 'dropdown', - mode: 'trigger', - options: gongTriggerOptions, - value: () => 'gong_webhook', - required: true, - }, - { - id: 'webhookUrlDisplay', - title: 'Webhook URL', - type: 'short-input', - readOnly: true, - showCopyButton: true, - useWebhookUrl: true, - placeholder: 'Webhook URL will be generated', - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'gong_webhook', - }, - }, - { - id: 'triggerSave', - title: '', - type: 'trigger-save', - hideFromPreview: true, - mode: 'trigger', - triggerId: 'gong_webhook', - condition: { - field: 'selectedTriggerId', - value: 'gong_webhook', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: gongSetupInstructions('All Events'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'gong_webhook', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gong_webhook', + triggerOptions: gongTriggerOptions, + includeDropdown: true, + setupInstructions: gongSetupInstructions('All Events'), + extraFields: buildGongExtraFields('gong_webhook'), + }), outputs: buildGenericOutputs(), From 729667a9225e8f84a314f3af8a89d506c8a16d3d Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 16:07:14 -0700 Subject: [PATCH 06/23] fix(notion): align webhook lifecycle and outputs Handle Notion verification requests safely, expose the documented webhook fields in the trigger contract, and update setup guidance so runtime data and user-facing configuration stay aligned. Made-with: Cursor --- apps/sim/lib/webhooks/providers/notion.ts | 42 +++++++++++++++- apps/sim/triggers/notion/utils.ts | 61 ++++++++++++++++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts index 8155fc67084..e39734a03ea 100644 --- a/apps/sim/lib/webhooks/providers/notion.ts +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -53,21 +53,59 @@ export const notionHandler: WebhookProviderHandler = { providerLabel: 'Notion', }), + handleReachabilityTest(body: unknown, requestId: string) { + const obj = body as Record | null + const verificationToken = obj?.verification_token + + if (typeof verificationToken === 'string' && verificationToken.length > 0) { + logger.info(`[${requestId}] Notion verification request detected - returning 200`) + return NextResponse.json({ + status: 'ok', + message: 'Webhook endpoint verified', + }) + } + + return null + }, + async formatInput({ body }: FormatInputContext): Promise { const b = body as Record + const rawEntity = + b.entity && typeof b.entity === 'object' ? (b.entity as Record) : {} + const rawData = b.data && typeof b.data === 'object' ? (b.data as Record) : {} + const rawParent = + rawData.parent && typeof rawData.parent === 'object' + ? (rawData.parent as Record) + : null + return { input: { id: b.id, type: b.type, timestamp: b.timestamp, + api_version: b.api_version, workspace_id: b.workspace_id, workspace_name: b.workspace_name, subscription_id: b.subscription_id, integration_id: b.integration_id, attempt_number: b.attempt_number, authors: b.authors || [], - entity: b.entity || {}, - data: b.data || {}, + accessible_by: b.accessible_by || [], + entity: { + ...rawEntity, + entity_type: rawEntity.type, + }, + data: { + ...rawData, + ...(rawParent + ? { + parent: { + ...rawParent, + parent_type: rawParent.type, + }, + } + : {}), + }, }, } }, diff --git a/apps/sim/triggers/notion/utils.ts b/apps/sim/triggers/notion/utils.ts index 15a2c76fc2a..2a8e3cfb28a 100644 --- a/apps/sim/triggers/notion/utils.ts +++ b/apps/sim/triggers/notion/utils.ts @@ -32,6 +32,14 @@ export function notionSetupInstructions(eventType: string): string { 'Ensure the integration has access to the pages/databases you want to monitor (share them with the integration).', ] + if (eventType === 'comment.created') { + instructions.splice( + 7, + 0, + 'Enable the comment read capability in your Notion integration settings so comment events can be delivered.' + ) + } + return instructions .map( (instruction, index) => @@ -71,11 +79,16 @@ function buildBaseOutputs(): Record { description: 'Event type (e.g., page.created, database.schema_updated)', }, timestamp: { type: 'string', description: 'ISO 8601 timestamp of the event' }, + api_version: { type: 'string', description: 'Notion API version included with the event' }, workspace_id: { type: 'string', description: 'Workspace ID where the event occurred' }, workspace_name: { type: 'string', description: 'Workspace name' }, subscription_id: { type: 'string', description: 'Webhook subscription ID' }, integration_id: { type: 'string', description: 'Integration ID that received the event' }, attempt_number: { type: 'number', description: 'Delivery attempt number' }, + accessible_by: { + type: 'array', + description: 'Array of users and bots that can access the entity', + }, } } @@ -85,7 +98,7 @@ function buildBaseOutputs(): Record { function buildEntityOutputs(): Record { return { id: { type: 'string', description: 'Entity ID (page or database ID)' }, - entity_type: { type: 'string', description: 'Entity type (page or database)' }, + entity_type: { type: 'string', description: 'Entity type (page, database, block, or comment)' }, } } @@ -101,9 +114,20 @@ export function buildPageEventOutputs(): Record { }, entity: buildEntityOutputs(), data: { + updated_blocks: { + type: 'array', + description: 'Blocks updated as part of the event, when provided by Notion', + }, + updated_properties: { + type: 'array', + description: 'Property IDs updated as part of the event, when provided by Notion', + }, parent: { id: { type: 'string', description: 'Parent page or database ID' }, - parent_type: { type: 'string', description: 'Parent type (database, page, workspace)' }, + parent_type: { + type: 'string', + description: 'Parent type (database, page, block, or workspace)', + }, }, }, } @@ -121,9 +145,17 @@ export function buildDatabaseEventOutputs(): Record { }, entity: buildEntityOutputs(), data: { + updated_blocks: { + type: 'array', + description: 'Blocks updated as part of the event, when provided by Notion', + }, + updated_properties: { + type: 'array', + description: 'Database properties updated as part of the event, when provided by Notion', + }, parent: { id: { type: 'string', description: 'Parent page or workspace ID' }, - parent_type: { type: 'string', description: 'Parent type (page, workspace)' }, + parent_type: { type: 'string', description: 'Parent type (page, database, or workspace)' }, }, }, } @@ -144,9 +176,10 @@ export function buildCommentEventOutputs(): Record { entity_type: { type: 'string', description: 'Entity type (comment)' }, }, data: { + page_id: { type: 'string', description: 'Page ID that owns the comment thread' }, parent: { - id: { type: 'string', description: 'Parent page ID' }, - parent_type: { type: 'string', description: 'Parent type (page)' }, + id: { type: 'string', description: 'Parent page or block ID' }, + parent_type: { type: 'string', description: 'Parent type (page or block)' }, }, }, } @@ -164,8 +197,22 @@ export function buildGenericWebhookOutputs(): Record { }, entity: buildEntityOutputs(), data: { - type: 'json', - description: 'Event-specific data including parent information', + parent: { + id: { type: 'string', description: 'Parent entity ID, when provided by Notion' }, + parent_type: { + type: 'string', + description: 'Parent entity type (page, database, block, or workspace), when present', + }, + }, + page_id: { type: 'string', description: 'Page ID related to the event, when present' }, + updated_blocks: { + type: 'array', + description: 'Blocks updated as part of the event, when provided by Notion', + }, + updated_properties: { + type: 'array', + description: 'Updated properties included with the event, when provided by Notion', + }, }, } } From e79c5563a5c969e432319a7cef00a8151a3218ea Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 17:27:01 -0700 Subject: [PATCH 07/23] fix(webhooks): tighten remaining provider hardening Close the remaining pre-merge caveats by tightening Salesforce, Zoom, and Linear behavior, and follow through on the deferred provider and tooling cleanup for Vercel, Greenhouse, Gong, and Notion. Made-with: Cursor --- .agents/skills/add-trigger/SKILL.md | 10 +- .claude/commands/add-trigger.md | 2 +- apps/sim/lib/webhooks/providers/gong.test.ts | 14 +++ apps/sim/lib/webhooks/providers/gong.ts | 6 +- apps/sim/lib/webhooks/providers/greenhouse.ts | 8 ++ .../sim/lib/webhooks/providers/linear.test.ts | 78 ++++++++++++ apps/sim/lib/webhooks/providers/linear.ts | 25 ++-- .../sim/lib/webhooks/providers/notion.test.ts | 28 +++++ apps/sim/lib/webhooks/providers/notion.ts | 13 ++ ...oom-webhook.test.ts => salesforce.test.ts} | 56 +++++++++ apps/sim/lib/webhooks/providers/salesforce.ts | 28 ++++- apps/sim/lib/webhooks/providers/vercel.ts | 38 ++---- apps/sim/lib/webhooks/providers/zoom.ts | 18 +++ apps/sim/scripts/check-trigger-alignment.ts | 115 ++++++++++++++++++ apps/sim/triggers/greenhouse/utils.test.ts | 15 +++ apps/sim/triggers/greenhouse/utils.ts | 26 ++-- apps/sim/triggers/notion/utils.ts | 2 +- apps/sim/triggers/salesforce/utils.ts | 13 +- apps/sim/triggers/vercel/utils.ts | 15 +++ 19 files changed, 446 insertions(+), 64 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/linear.test.ts create mode 100644 apps/sim/lib/webhooks/providers/notion.test.ts rename apps/sim/lib/webhooks/providers/{salesforce-zoom-webhook.test.ts => salesforce.test.ts} (72%) diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index fa9b7be1d32..26e828a74b5 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -691,13 +691,13 @@ export const {service}WebhookTrigger: TriggerConfig = { ### Automatic Webhook Registration (if supported) - [ ] Added API key field to `build{Service}ExtraFields` with `password: true` - [ ] Updated setup instructions for automatic webhook creation -- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts` -- [ ] Added `create{Service}WebhookSubscription` helper function -- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` -- [ ] Added provider to `cleanupExternalWebhook` function +- [ ] Added `createSubscription` to `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Added `deleteSubscription` to `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Did not add provider-specific orchestration logic to shared route / deploy / provider-subscriptions files unless absolutely required ### Webhook Input Formatting -- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) +- [ ] Added provider-owned `formatInput` in `apps/sim/lib/webhooks/providers/{service}.ts` when custom formatting is needed +- [ ] Used `createHmacVerifier` for standard HMAC providers, or a custom `verifyAuth()` when the provider requires non-standard signature semantics / stricter secret handling - [ ] Handler returns fields matching trigger `outputs` exactly - [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify alignment diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index dbda690b3df..de7f1355d9d 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -806,7 +806,7 @@ export const {service}WebhookTrigger: TriggerConfig = { - [ ] Created handler file in `apps/sim/lib/webhooks/providers/{service}.ts` - [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical) - [ ] Signature validator defined as private function inside handler file (not in a shared file) -- [ ] Used `createHmacVerifier` from `providers/utils` for HMAC-based auth +- [ ] Used `createHmacVerifier` from `providers/utils` for standard HMAC auth, or a provider-specific `verifyAuth()` when the provider requires custom signature semantics / stricter secret handling - [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth - [ ] Event matching uses dynamic `await import()` for trigger utils - [ ] Added `formatInput` if webhook payload needs transformation (returns `{ input: ... }`) diff --git a/apps/sim/lib/webhooks/providers/gong.test.ts b/apps/sim/lib/webhooks/providers/gong.test.ts index 93e84116299..68d7b02d1be 100644 --- a/apps/sim/lib/webhooks/providers/gong.test.ts +++ b/apps/sim/lib/webhooks/providers/gong.test.ts @@ -28,6 +28,20 @@ describe('normalizeGongPublicKeyPem', () => { }) }) +describe('gongHandler formatInput', () => { + it('always returns callId as a string', async () => { + const { input } = await gongHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { callData: { metaData: {} } }, + headers: {}, + requestId: 'gong-format', + }) + + expect((input as Record).callId).toBe('') + }) +}) + describe('gongHandler verifyAuth (JWT)', () => { it('returns null when JWT public key is not configured', async () => { const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', { diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts index 9c0eec6b297..fea4f8fb637 100644 --- a/apps/sim/lib/webhooks/providers/gong.ts +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -129,9 +129,7 @@ export const gongHandler: WebhookProviderHandler = { const metaData = (callData?.metaData as Record) || {} const content = callData?.content as Record | undefined const callId = - typeof metaData.id === 'string' || typeof metaData.id === 'number' - ? String(metaData.id) - : null + typeof metaData.id === 'string' || typeof metaData.id === 'number' ? String(metaData.id) : '' return { input: { @@ -142,7 +140,7 @@ export const gongHandler: WebhookProviderHandler = { context: (callData?.context as unknown[]) || [], trackers: (content?.trackers as unknown[]) || [], eventType: 'gong.automation_rule', - callId: callId ?? null, + callId, }, } }, diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts index 4cb412ca069..08c09986d87 100644 --- a/apps/sim/lib/webhooks/providers/greenhouse.ts +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -98,6 +98,14 @@ export const greenhouseHandler: WebhookProviderHandler = { return `greenhouse:${action}:offer:${String(offerId)}:${v}` } + const offer = (payload.offer || {}) as Record + const nestedOfferId = offer.id + if (nestedOfferId !== undefined && nestedOfferId !== null && nestedOfferId !== '') { + const nestedVersion = + offer.version !== undefined && offer.version !== null ? String(offer.version) : '0' + return `greenhouse:${action}:offer:${String(nestedOfferId)}:${nestedVersion}` + } + const job = (payload.job || {}) as Record const jobId = job.id if (jobId !== undefined && jobId !== null && jobId !== '') { diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts new file mode 100644 index 00000000000..86f97864bfa --- /dev/null +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -0,0 +1,78 @@ +import crypto from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { linearHandler } from '@/lib/webhooks/providers/linear' + +function signLinearBody(secret: string, rawBody: string): string { + return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex') +} + +function requestWithLinearSignature(secret: string, rawBody: string): NextRequest { + const signature = signLinearBody(secret, rawBody) + return new NextRequest('http://localhost/test', { + headers: { + 'Linear-Signature': signature, + }, + }) +} + +describe('Linear webhook provider', () => { + it('rejects signed requests when webhookTimestamp is missing', async () => { + const secret = 'linear-secret' + const rawBody = JSON.stringify({ + action: 'create', + type: 'Issue', + }) + + const res = await linearHandler.verifyAuth!({ + request: requestWithLinearSignature(secret, rawBody), + rawBody, + requestId: 'linear-t1', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + + expect(res?.status).toBe(401) + }) + + it('rejects signed requests when webhookTimestamp skew is too large', async () => { + const secret = 'linear-secret' + const rawBody = JSON.stringify({ + action: 'update', + type: 'Issue', + webhookTimestamp: Date.now() - 120_000, + }) + + const res = await linearHandler.verifyAuth!({ + request: requestWithLinearSignature(secret, rawBody), + rawBody, + requestId: 'linear-t2', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + + expect(res?.status).toBe(401) + }) + + it('accepts signed requests within the allowed timestamp window', async () => { + const secret = 'linear-secret' + const rawBody = JSON.stringify({ + action: 'update', + type: 'Issue', + webhookTimestamp: Date.now(), + }) + + const res = await linearHandler.verifyAuth!({ + request: requestWithLinearSignature(secret, rawBody), + rawBody, + requestId: 'linear-t3', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + + expect(res).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index a9323aa442e..1748887a962 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -42,7 +42,7 @@ function validateLinearSignature(secret: string, signature: string, body: string } } -const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000 +const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 60 * 1000 export const linearHandler: WebhookProviderHandler = { async verifyAuth({ @@ -70,15 +70,20 @@ export const linearHandler: WebhookProviderHandler = { try { const parsed = JSON.parse(rawBody) as Record const ts = parsed.webhookTimestamp - if (typeof ts === 'number' && Number.isFinite(ts)) { - if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) { - logger.warn( - `[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)` - ) - return new NextResponse('Unauthorized - Webhook timestamp skew too large', { - status: 401, - }) - } + if (typeof ts !== 'number' || !Number.isFinite(ts)) { + logger.warn(`[${requestId}] Linear webhookTimestamp missing or invalid`) + return new NextResponse('Unauthorized - Invalid webhook timestamp', { + status: 401, + }) + } + + if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) { + logger.warn( + `[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)` + ) + return new NextResponse('Unauthorized - Webhook timestamp skew too large', { + status: 401, + }) } } catch (error) { logger.warn( diff --git a/apps/sim/lib/webhooks/providers/notion.test.ts b/apps/sim/lib/webhooks/providers/notion.test.ts new file mode 100644 index 00000000000..2a16911373a --- /dev/null +++ b/apps/sim/lib/webhooks/providers/notion.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { notionHandler } from '@/lib/webhooks/providers/notion' +import { isNotionPayloadMatch } from '@/triggers/notion/utils' + +describe('Notion webhook provider', () => { + it('matches both legacy and newer schema updated event names', () => { + expect( + isNotionPayloadMatch('notion_database_schema_updated', { + type: 'database.schema_updated', + }) + ).toBe(true) + + expect( + isNotionPayloadMatch('notion_database_schema_updated', { + type: 'data_source.schema_updated', + }) + ).toBe(true) + }) + + it('builds a stable idempotency key from event type and id', () => { + const key = notionHandler.extractIdempotencyId!({ + id: 'evt_123', + type: 'page.created', + }) + + expect(key).toBe('notion:page.created:evt_123') + }) +}) diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts index e39734a03ea..c1d2aac41e9 100644 --- a/apps/sim/lib/webhooks/providers/notion.ts +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -135,4 +135,17 @@ export const notionHandler: WebhookProviderHandler = { return true }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const id = obj.id + const type = obj.type + if ( + (typeof id === 'string' || typeof id === 'number') && + (typeof type === 'string' || typeof type === 'number') + ) { + return `notion:${String(type)}:${String(id)}` + } + return null + }, } diff --git a/apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts b/apps/sim/lib/webhooks/providers/salesforce.test.ts similarity index 72% rename from apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts rename to apps/sim/lib/webhooks/providers/salesforce.test.ts index 83f6401f69e..cb33a170961 100644 --- a/apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.test.ts @@ -64,6 +64,17 @@ describe('Salesforce webhook provider', () => { expect( isSalesforceEventMatch('salesforce_webhook', { objectType: 'Contact', Id: 'x' }, 'Account') ).toBe(false) + expect(isSalesforceEventMatch('salesforce_webhook', { Id: 'x' }, 'Account')).toBe(false) + }) + + it('isSalesforceEventMatch fails closed for record triggers when configured objectType is missing', () => { + expect( + isSalesforceEventMatch( + 'salesforce_record_created', + { eventType: 'created', Id: '001' }, + 'Account' + ) + ).toBe(false) }) it('formatInput maps record trigger fields', async () => { @@ -92,6 +103,23 @@ describe('Salesforce webhook provider', () => { }) expect(id).toContain('001') }) + + it('extractIdempotencyId is stable without timestamps for identical payloads', () => { + const body = { + eventType: 'updated', + objectType: 'Account', + Id: '001', + Name: 'Acme', + changedFields: ['Name'], + } + + const first = salesforceHandler.extractIdempotencyId!(body) + const second = salesforceHandler.extractIdempotencyId!({ ...body }) + + expect(first).toBe(second) + expect(first).toContain('001') + expect(first).toContain('updated') + }) }) describe('Zoom webhook provider', () => { @@ -121,4 +149,32 @@ describe('Zoom webhook provider', () => { }) expect(zid).toBe('zoom:meeting.started:123:u1') }) + + it('extractIdempotencyId uses participant identity when available', () => { + const zid = zoomHandler.extractIdempotencyId!({ + event: 'meeting.participant_joined', + event_ts: 123, + payload: { + object: { + uuid: 'meeting-uuid', + participant: { + user_id: 'participant-1', + }, + }, + }, + }) + expect(zid).toBe('zoom:meeting.participant_joined:123:participant-1') + }) + + it('matchEvent never executes endpoint validation payloads', async () => { + const result = await zoomHandler.matchEvent!({ + webhook: { id: 'w' }, + workflow: { id: 'wf' }, + body: { event: 'endpoint.url_validation' }, + request: reqWithHeaders({}), + requestId: 't5', + providerConfig: { triggerId: 'zoom_webhook' }, + }) + expect(result).toBe(false) + }) }) diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 9a6d8c82179..d81e9c51dcc 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import type { @@ -96,7 +97,26 @@ function pickTimestamp(body: Record, record: Record stableSerialize(item)).join(',')}]` + } + + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +function buildFallbackDeliveryFingerprint(body: Record): string { + return crypto.createHash('sha256').update(stableSerialize(body), 'utf8').digest('hex') } function pickRecordId(body: Record, record: Record): string { @@ -295,6 +315,10 @@ export const salesforceHandler: WebhookProviderHandler = { if (!id) { return null } - return `salesforce:${et || 'event'}:${id}:${ts}` + if (ts) { + return `salesforce:${et || 'event'}:${id}:${ts}` + } + + return `salesforce:${et || 'event'}:${id}:${buildFallbackDeliveryFingerprint(b)}` }, } diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 6ee5a14907a..69d2b4a48d4 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -90,24 +90,24 @@ export const vercelHandler: WebhookProviderHandler = { ) } - const eventTypeMap: Record = { - vercel_deployment_created: ['deployment.created'], - vercel_deployment_ready: ['deployment.ready'], - vercel_deployment_error: ['deployment.error'], - vercel_deployment_canceled: ['deployment.canceled'], - vercel_project_created: ['project.created'], - vercel_project_removed: ['project.removed'], - vercel_domain_created: ['domain.created'], - vercel_webhook: undefined, - } + const { VERCEL_GENERIC_TRIGGER_EVENT_TYPES, VERCEL_TRIGGER_EVENT_TYPES } = await import( + '@/triggers/vercel/utils' + ) - if (triggerId && !(triggerId in eventTypeMap)) { + if ( + triggerId && + triggerId !== 'vercel_webhook' && + !(triggerId in VERCEL_TRIGGER_EVENT_TYPES) + ) { throw new Error( `Unknown Vercel trigger "${triggerId}". Remove and re-add the Vercel trigger, then save again.` ) } - const events = eventTypeMap[triggerId ?? ''] + const events = + triggerId && triggerId !== 'vercel_webhook' + ? [VERCEL_TRIGGER_EVENT_TYPES[triggerId]] + : undefined const notificationUrl = getNotificationUrl(webhook) logger.info(`[${requestId}] Creating Vercel webhook`, { @@ -125,19 +125,7 @@ export const vercelHandler: WebhookProviderHandler = { */ const requestBody: Record = { url: notificationUrl, - events: events || [ - 'deployment.created', - 'deployment.ready', - 'deployment.succeeded', - 'deployment.error', - 'deployment.canceled', - 'deployment.promoted', - 'project.created', - 'project.removed', - 'domain.created', - 'edge-config.created', - 'edge-config.deleted', - ], + events: events || [...VERCEL_GENERIC_TRIGGER_EVENT_TYPES], } if (filterProjectIds) { diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index ad9937d01da..6315ec633d8 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -82,7 +82,21 @@ export const zoomHandler: WebhookProviderHandler = { } const payload = obj.payload as Record | undefined const inner = payload?.object as Record | undefined + const participant = + inner?.participant && + typeof inner.participant === 'object' && + !Array.isArray(inner.participant) + ? (inner.participant as Record) + : null + const participantStable = + (typeof participant?.user_id === 'string' && participant.user_id) || + (typeof participant?.id === 'string' && participant.id) || + (typeof participant?.email === 'string' && participant.email) || + (typeof participant?.join_time === 'string' && participant.join_time) || + (typeof participant?.leave_time === 'string' && participant.leave_time) || + '' const stable = + participantStable || (typeof inner?.uuid === 'string' && inner.uuid) || (inner?.id !== undefined && inner.id !== null ? String(inner.id) : '') || '' @@ -94,6 +108,10 @@ export const zoomHandler: WebhookProviderHandler = { const obj = body as Record const event = typeof obj.event === 'string' ? obj.event : '' + if (event === 'endpoint.url_validation') { + return false + } + if (triggerId) { const { isZoomEventMatch } = await import('@/triggers/zoom/utils') if (!isZoomEventMatch(triggerId, event)) { diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts index cacdde96227..725edc55097 100644 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -46,6 +46,121 @@ const PROVIDER_CHECKS: Record = { formatInputKeys: Object.keys(input).sort(), } }, + linear: async () => { + const { buildIssueOutputs } = await import('@/triggers/linear/utils') + const { linearHandler } = await import('@/lib/webhooks/providers/linear') + const outputs = buildIssueOutputs() as Record + const result = await linearHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + action: 'create', + type: 'Issue', + webhookId: 'wh_123', + webhookTimestamp: Date.now(), + organizationId: 'org_123', + createdAt: new Date().toISOString(), + url: 'https://linear.app', + actor: { id: 'user_1', type: 'user', name: 'Test User' }, + data: {}, + updatedFrom: null, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildIssueOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, + notion: async () => { + const { buildPageEventOutputs } = await import('@/triggers/notion/utils') + const { notionHandler } = await import('@/lib/webhooks/providers/notion') + const outputs = buildPageEventOutputs() as Record + const result = await notionHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + id: 'evt_123', + type: 'page.created', + timestamp: new Date().toISOString(), + workspace_id: 'workspace_1', + workspace_name: 'Workspace', + subscription_id: 'sub_1', + integration_id: 'int_1', + attempt_number: 1, + authors: [], + accessible_by: [], + entity: { id: 'page_1', type: 'page' }, + data: { parent: { id: 'parent_1', type: 'page' } }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildPageEventOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, + salesforce: async () => { + const { buildSalesforceWebhookOutputs } = await import('@/triggers/salesforce/utils') + const { salesforceHandler } = await import('@/lib/webhooks/providers/salesforce') + const outputs = buildSalesforceWebhookOutputs() as Record + const result = await salesforceHandler.formatInput!({ + webhook: { providerConfig: { triggerId: 'salesforce_webhook' } }, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + eventType: 'record_created', + objectType: 'Account', + Id: '001', + Name: 'Acme', + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildSalesforceWebhookOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, + vercel: async () => { + const { buildVercelOutputs } = await import('@/triggers/vercel/utils') + const { vercelHandler } = await import('@/lib/webhooks/providers/vercel') + const outputs = buildVercelOutputs() as Record + const result = await vercelHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + type: 'deployment.created', + id: 'evt_123', + createdAt: Date.now(), + region: 'iad1', + payload: { + deployment: { id: 'dep_1', url: 'example.vercel.app', name: 'preview' }, + project: { id: 'prj_1', name: 'project' }, + team: { id: 'team_1' }, + user: { id: 'user_1' }, + target: 'preview', + plan: 'pro', + domain: { name: 'example.com' }, + }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildVercelOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, } const provider = process.argv[2]?.trim() diff --git a/apps/sim/triggers/greenhouse/utils.test.ts b/apps/sim/triggers/greenhouse/utils.test.ts index cba0a733323..b16e7a155e3 100644 --- a/apps/sim/triggers/greenhouse/utils.test.ts +++ b/apps/sim/triggers/greenhouse/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { greenhouseHandler } from '@/lib/webhooks/providers/greenhouse' import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils' describe('isGreenhouseEventMatch', () => { @@ -15,4 +16,18 @@ describe('isGreenhouseEventMatch', () => { it('rejects unknown trigger ids (no permissive fallback)', () => { expect(isGreenhouseEventMatch('greenhouse_unknown', 'new_candidate_application')).toBe(false) }) + + it('builds fallback idempotency keys for nested offer payloads', () => { + const key = greenhouseHandler.extractIdempotencyId!({ + action: 'offer_deleted', + payload: { + offer: { + id: 42, + version: 3, + }, + }, + }) + + expect(key).toBe('greenhouse:offer_deleted:offer:42:3') + }) }) diff --git a/apps/sim/triggers/greenhouse/utils.ts b/apps/sim/triggers/greenhouse/utils.ts index d125810fc45..ee089960056 100644 --- a/apps/sim/triggers/greenhouse/utils.ts +++ b/apps/sim/triggers/greenhouse/utils.ts @@ -60,6 +60,17 @@ export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[] ] } +function buildSourceOutputs(): Record { + return { + id: { type: 'number', description: 'Source ID' }, + name: { type: 'string', description: 'Source name when provided by Greenhouse' }, + public_name: { + type: 'string', + description: 'Public-facing source name when provided by Greenhouse', + }, + } +} + /** * Generates HTML setup instructions for Greenhouse webhooks. * Webhooks are manually configured in the Greenhouse admin panel. @@ -115,10 +126,7 @@ export function buildCandidateHiredOutputs(): Record { coordinator: { type: 'json', description: 'Assigned coordinator' }, }, jobs: { type: 'json', description: 'Associated jobs (array)' }, - source: { - id: { type: 'number', description: 'Source ID' }, - public_name: { type: 'string', description: 'Source name' }, - }, + source: buildSourceOutputs(), offer: { id: { type: 'number', description: 'Offer ID' }, version: { type: 'number', description: 'Offer version' }, @@ -161,10 +169,7 @@ export function buildNewApplicationOutputs(): Record { tags: { type: 'json', description: 'Candidate tags' }, }, jobs: { type: 'json', description: 'Associated jobs (array)' }, - source: { - id: { type: 'number', description: 'Source ID' }, - public_name: { type: 'string', description: 'Source name' }, - }, + source: buildSourceOutputs(), answers: { type: 'json', description: 'Application question answers' }, attachments: { type: 'json', description: 'Application attachments' }, custom_fields: { type: 'json', description: 'Application custom fields' }, @@ -202,10 +207,7 @@ export function buildCandidateStageChangeOutputs(): Record = { notion_page_content_updated: ['page.content_updated'], notion_page_deleted: ['page.deleted'], notion_database_created: ['database.created'], - notion_database_schema_updated: ['database.schema_updated'], + notion_database_schema_updated: ['database.schema_updated', 'data_source.schema_updated'], notion_database_deleted: ['database.deleted'], notion_comment_created: ['comment.created'], } diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index 1a9168e536b..161334b4d86 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -124,8 +124,13 @@ export function isSalesforceEventMatch( const wantType = configuredObjectType?.trim() const gotType = payloadObjectType(body) - if (wantType && gotType && normalizeToken(gotType) !== normalizeToken(wantType)) { - return false + if (wantType) { + if (!gotType) { + return false + } + if (normalizeToken(gotType) !== normalizeToken(wantType)) { + return false + } } if (triggerId === 'salesforce_opportunity_stage_changed') { @@ -194,7 +199,7 @@ export function salesforceSetupInstructions(eventType: string): string { `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`, 'Add an HTTP CalloutPOST, JSON body, URL = webhook URL.', `Include eventType in the JSON body using a value this trigger accepts (e.g. for Record Created use record_created, created, or after_insert).`, - 'Include attributes.type / object API name or objectType so filtering can run when you set Object Type above.', + 'If you use Object Type (Optional), you must also include matching type metadata in the JSON body (for example objectType, sobjectType, or attributes.type) or the event will be rejected.', 'Save and Activate the Flow.', 'Click "Save" above to activate your trigger.', ] @@ -229,7 +234,7 @@ function salesforceObjectTypeField(triggerId: string): SubBlockConfig { type: 'short-input', placeholder: 'e.g., Account, Contact, Opportunity', description: - 'When set, only payloads for this Salesforce object API name are accepted (matched case-insensitively).', + 'When set, the payload must include matching object type metadata (for example objectType, sobjectType, or attributes.type) or the event is rejected.', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, } diff --git a/apps/sim/triggers/vercel/utils.ts b/apps/sim/triggers/vercel/utils.ts index e86dc8248c2..671e388c70f 100644 --- a/apps/sim/triggers/vercel/utils.ts +++ b/apps/sim/triggers/vercel/utils.ts @@ -26,6 +26,21 @@ export const VERCEL_TRIGGER_EVENT_TYPES: Record = { vercel_domain_created: 'domain.created', } +/** Curated set used by the generic Vercel webhook trigger. */ +export const VERCEL_GENERIC_TRIGGER_EVENT_TYPES = [ + 'deployment.created', + 'deployment.ready', + 'deployment.succeeded', + 'deployment.error', + 'deployment.canceled', + 'deployment.promoted', + 'project.created', + 'project.removed', + 'domain.created', + 'edge-config.created', + 'edge-config.deleted', +] as const + /** * Returns whether the incoming Vercel event matches the configured trigger. * `vercel_webhook` is handled only at subscription time; deliveries are not filtered here. From 23ccc9ba41f2877e6105639d7e51bf957e94bcb7 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 18:04:03 -0700 Subject: [PATCH 08/23] refactor(webhooks): move subscription helpers out of providers Move provider subscription helpers alongside the subscription lifecycle module and add targeted TSDoc so the file placement matches the responsibility boundaries in the webhook architecture. Made-with: Cursor --- ...ion-utils.ts => provider-subscription-utils.ts} | 9 +++++++++ apps/sim/lib/webhooks/provider-subscriptions.ts | 14 ++++++++++++++ apps/sim/lib/webhooks/providers/airtable.ts | 2 +- apps/sim/lib/webhooks/providers/ashby.ts | 2 +- apps/sim/lib/webhooks/providers/attio.ts | 2 +- apps/sim/lib/webhooks/providers/calendly.ts | 2 +- apps/sim/lib/webhooks/providers/fathom.ts | 2 +- apps/sim/lib/webhooks/providers/grain.ts | 2 +- apps/sim/lib/webhooks/providers/lemlist.ts | 2 +- apps/sim/lib/webhooks/providers/linear.ts | 2 +- apps/sim/lib/webhooks/providers/microsoft-teams.ts | 2 +- apps/sim/lib/webhooks/providers/resend.ts | 2 +- apps/sim/lib/webhooks/providers/telegram.ts | 2 +- apps/sim/lib/webhooks/providers/typeform.ts | 2 +- apps/sim/lib/webhooks/providers/vercel.ts | 2 +- apps/sim/lib/webhooks/providers/webflow.ts | 2 +- 16 files changed, 37 insertions(+), 14 deletions(-) rename apps/sim/lib/webhooks/{providers/subscription-utils.ts => provider-subscription-utils.ts} (76%) diff --git a/apps/sim/lib/webhooks/providers/subscription-utils.ts b/apps/sim/lib/webhooks/provider-subscription-utils.ts similarity index 76% rename from apps/sim/lib/webhooks/providers/subscription-utils.ts rename to apps/sim/lib/webhooks/provider-subscription-utils.ts index 17c6ca29514..e52e1eeefa1 100644 --- a/apps/sim/lib/webhooks/providers/subscription-utils.ts +++ b/apps/sim/lib/webhooks/provider-subscription-utils.ts @@ -7,14 +7,23 @@ import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProviderSubscriptions') +/** Safely read a webhook row's provider config as a plain object. */ export function getProviderConfig(webhook: Record): Record { return (webhook.providerConfig as Record) || {} } +/** Build the public callback URL providers should deliver webhook events to. */ export function getNotificationUrl(webhook: Record): string { return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` } +/** + * Resolve an OAuth-backed credential to the owning user and account. + * + * Provider subscription handlers use this when they need to refresh tokens or + * make provider API calls on behalf of the credential owner during webhook + * registration and cleanup. + */ export async function getCredentialOwner( credentialId: string, requestId: string diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 0d9906e378a..cbf6a05f184 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -31,6 +31,13 @@ const SYSTEM_MANAGED_FIELDS = new Set([ 'userId', ]) +/** + * Determine whether a webhook with provider-managed registration should be + * recreated after its persisted provider config changes. + * + * Only user-controlled fields are considered; provider-managed fields such as + * external IDs and generated secrets are ignored. + */ export function shouldRecreateExternalWebhookSubscription({ previousProvider, nextProvider, @@ -69,6 +76,13 @@ export function shouldRecreateExternalWebhookSubscription({ return false } +/** + * Ask the provider handler to create an external webhook subscription, if that + * provider supports automatic registration. + * + * The returned provider-managed fields are merged back into `providerConfig` + * by the caller. + */ export async function createExternalWebhookSubscription( request: NextRequest, webhookData: Record, diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 80fecf73854..61daafa4f30 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -8,7 +8,7 @@ import { getCredentialOwner, getNotificationUrl, getProviderConfig, -} from '@/lib/webhooks/providers/subscription-utils' +} from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, FormatInputContext, diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index ce044495009..b89d516c5cc 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, FormatInputContext, diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 883d979334f..84c6b740780 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, DeleteSubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts index 7fcca4a8e8f..a85b108c5bf 100644 --- a/apps/sim/lib/webhooks/providers/calendly.ts +++ b/apps/sim/lib/webhooks/providers/calendly.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, FormatInputContext, diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts index c705d00353f..c158c73e369 100644 --- a/apps/sim/lib/webhooks/providers/fathom.ts +++ b/apps/sim/lib/webhooks/providers/fathom.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, SubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts index 02bb0122076..39be11cab66 100644 --- a/apps/sim/lib/webhooks/providers/grain.ts +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, EventFilterContext, diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts index 2127512f9d3..2215839b8de 100644 --- a/apps/sim/lib/webhooks/providers/lemlist.ts +++ b/apps/sim/lib/webhooks/providers/lemlist.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, SubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 1748887a962..9490c3c206d 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, DeleteSubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index 8270eb93e01..11af3634290 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -15,7 +15,7 @@ import { getCredentialOwner, getNotificationUrl, getProviderConfig, -} from '@/lib/webhooks/providers/subscription-utils' +} from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, DeleteSubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 280d142a53f..43afe204e71 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, DeleteSubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts index 8511f9b1198..0bb2fd427f0 100644 --- a/apps/sim/lib/webhooks/providers/telegram.ts +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, DeleteSubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 068c72d9cd4..8c96d05907f 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, FormatInputContext, diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 69d2b4a48d4..251f7f23b24 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, DeleteSubscriptionContext, diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 4596e8381fc..9399bcd54e3 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, EventFilterContext, From e000c5bd60cf8ed3f11f2d2bd1183d73fa1d77e4 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 18:18:35 -0700 Subject: [PATCH 09/23] fix(zoom): resolve env-backed secrets during validation Use the same env-aware secret resolution path for Zoom endpoint validation as regular delivery verification so URL validation works correctly when the secret token is stored via env references. Made-with: Cursor --- apps/sim/lib/webhooks/providers/zoom.ts | 55 ++++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 6315ec633d8..0a4e09d23e0 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -1,10 +1,11 @@ import crypto from 'crypto' -import { db, webhook } from '@sim/db' +import { db, webhook, workflow } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' +import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import type { AuthContext, EventMatchContext, @@ -47,6 +48,46 @@ export function validateZoomSignature( } } +async function resolveZoomChallengeSecrets( + path: string, + requestId: string +): Promise> { + const rows = await db + .select({ + providerConfig: webhook.providerConfig, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true))) + + const resolvedRows = await Promise.all( + rows.map(async (row) => { + const rawConfig = + row.providerConfig && + typeof row.providerConfig === 'object' && + !Array.isArray(row.providerConfig) + ? (row.providerConfig as Record) + : {} + + try { + const config = await resolveEnvVarsInObject(rawConfig, row.userId, row.workspaceId) + const secretToken = typeof config.secretToken === 'string' ? config.secretToken : '' + return { secretToken } + } catch (error) { + logger.warn(`[${requestId}] Failed to resolve Zoom webhook secret for challenge`, { + error: error instanceof Error ? error.message : String(error), + path, + }) + return { secretToken: '' } + } + }) + ) + + return resolvedRows.filter((row) => row.secretToken) +} + export const zoomHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secretToken = providerConfig.secretToken as string | undefined @@ -166,22 +207,16 @@ export const zoomHandler: WebhookProviderHandler = { const bodyForSignature = rawBody !== undefined && rawBody !== null ? rawBody : JSON.stringify(body) - let rows: { providerConfig: unknown }[] = [] + let rows: Array<{ secretToken: string }> = [] try { - rows = await db - .select({ providerConfig: webhook.providerConfig }) - .from(webhook) - .where( - and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true)) - ) + rows = await resolveZoomChallengeSecrets(path, requestId) } catch (err) { logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err) return null } for (const row of rows) { - const config = row.providerConfig as Record | null - const secretToken = (config?.secretToken as string) || '' + const secretToken = row.secretToken if ( secretToken && validateZoomSignature(secretToken, signature, timestamp, bodyForSignature) From b50a902509e9306a49fc44aed3809914eea14dde Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 18:27:06 -0700 Subject: [PATCH 10/23] fix build --- apps/sim/lib/webhooks/providers/zoom.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 0a4e09d23e0..ba4ef01dc71 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -72,7 +72,11 @@ async function resolveZoomChallengeSecrets( : {} try { - const config = await resolveEnvVarsInObject(rawConfig, row.userId, row.workspaceId) + const config = await resolveEnvVarsInObject( + rawConfig, + row.userId, + row.workspaceId ?? undefined + ) const secretToken = typeof config.secretToken === 'string' ? config.secretToken : '' return { secretToken } } catch (error) { From 732755a9e7d57fcb8c4853f9cecf962a7d72314c Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 18:36:17 -0700 Subject: [PATCH 11/23] consolidate tests --- ...ce.webhook-key.test.ts => service.test.ts} | 10 +++++++++ .../lib/core/idempotency/webhook-key.test.ts | 21 ------------------- 2 files changed, 10 insertions(+), 21 deletions(-) rename apps/sim/lib/core/idempotency/{service.webhook-key.test.ts => service.test.ts} (78%) delete mode 100644 apps/sim/lib/core/idempotency/webhook-key.test.ts diff --git a/apps/sim/lib/core/idempotency/service.webhook-key.test.ts b/apps/sim/lib/core/idempotency/service.test.ts similarity index 78% rename from apps/sim/lib/core/idempotency/service.webhook-key.test.ts rename to apps/sim/lib/core/idempotency/service.test.ts index bcb93769a18..52ef9e7d019 100644 --- a/apps/sim/lib/core/idempotency/service.webhook-key.test.ts +++ b/apps/sim/lib/core/idempotency/service.test.ts @@ -6,6 +6,16 @@ import { describe, expect, it } from 'vitest' import { IdempotencyService } from '@/lib/core/idempotency/service' describe('IdempotencyService.createWebhookIdempotencyKey', () => { + it('uses Greenhouse-Event-ID when present', () => { + const key = IdempotencyService.createWebhookIdempotencyKey( + 'wh_1', + { 'greenhouse-event-id': 'evt-gh-99' }, + {}, + 'greenhouse' + ) + expect(key).toBe('wh_1:evt-gh-99') + }) + it('prefers svix-id for Resend / Svix duplicate delivery deduplication', () => { const key = IdempotencyService.createWebhookIdempotencyKey( 'wh_1', diff --git a/apps/sim/lib/core/idempotency/webhook-key.test.ts b/apps/sim/lib/core/idempotency/webhook-key.test.ts deleted file mode 100644 index e72cc3aa168..00000000000 --- a/apps/sim/lib/core/idempotency/webhook-key.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' -import { IdempotencyService } from '@/lib/core/idempotency/service' - -vi.mock('@/lib/core/utils/uuid', () => ({ - generateId: vi.fn(() => 'fallback-uuid'), -})) - -describe('IdempotencyService.createWebhookIdempotencyKey', () => { - it('uses Greenhouse-Event-ID when present', () => { - const key = IdempotencyService.createWebhookIdempotencyKey( - 'wh_1', - { 'greenhouse-event-id': 'evt-gh-99' }, - {}, - 'greenhouse' - ) - expect(key).toBe('wh_1:evt-gh-99') - }) -}) From 7c31044f6a9be2f26074ec8ea7c7f47e21cfdb82 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 18:46:38 -0700 Subject: [PATCH 12/23] refactor(salesforce): share payload object type parsing Remove dead code in the Salesforce provider and move shared object-type extraction into a single helper so trigger matching and input shaping stay in sync. Made-with: Cursor --- apps/sim/lib/webhooks/providers/salesforce.ts | 36 ++----------------- .../lib/webhooks/salesforce-payload-utils.ts | 30 ++++++++++++++++ apps/sim/triggers/salesforce/utils.ts | 31 ++-------------- 3 files changed, 35 insertions(+), 62 deletions(-) create mode 100644 apps/sim/lib/webhooks/salesforce-payload-utils.ts diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index d81e9c51dcc..6f52de23277 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -9,6 +9,7 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' +import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/salesforce-payload-utils' const logger = createLogger('WebhookProvider:Salesforce') @@ -25,39 +26,6 @@ function asRecord(body: unknown): Record { : {} } -function extractObjectTypeFromPayload(body: Record): string | undefined { - const direct = - (typeof body.objectType === 'string' && body.objectType) || - (typeof body.sobjectType === 'string' && body.sobjectType) || - undefined - if (direct) { - return direct - } - - const attrs = body.attributes as Record | undefined - if (typeof attrs?.type === 'string') { - return attrs.type - } - - const record = body.record - if (record && typeof record === 'object' && !Array.isArray(record)) { - const r = record as Record - if (typeof r.sobjectType === 'string') { - return r.sobjectType - } - const ra = r.attributes as Record | undefined - if (typeof ra?.type === 'string') { - return ra.type - } - } - - return undefined -} - -function normalizeSObjectType(t: string): string { - return t.trim().toLowerCase() -} - function extractRecordCore(body: Record): Record { const nested = body.record if (nested && typeof nested === 'object' && !Array.isArray(nested)) { @@ -176,7 +144,7 @@ export const salesforceHandler: WebhookProviderHandler = { const record = extractRecordCore(body) const objectType = - extractObjectTypeFromPayload(body) || + extractSalesforceObjectTypeFromPayload(body) || (typeof record.attributes === 'object' && record.attributes && typeof (record.attributes as Record).type === 'string' diff --git a/apps/sim/lib/webhooks/salesforce-payload-utils.ts b/apps/sim/lib/webhooks/salesforce-payload-utils.ts new file mode 100644 index 00000000000..a88014608d4 --- /dev/null +++ b/apps/sim/lib/webhooks/salesforce-payload-utils.ts @@ -0,0 +1,30 @@ +export function extractSalesforceObjectTypeFromPayload( + body: Record +): string | undefined { + const direct = + (typeof body.objectType === 'string' && body.objectType) || + (typeof body.sobjectType === 'string' && body.sobjectType) || + undefined + if (direct) { + return direct + } + + const attrs = body.attributes as Record | undefined + if (typeof attrs?.type === 'string') { + return attrs.type + } + + const record = body.record + if (record && typeof record === 'object' && !Array.isArray(record)) { + const r = record as Record + if (typeof r.sobjectType === 'string') { + return r.sobjectType + } + const ra = r.attributes as Record | undefined + if (typeof ra?.type === 'string') { + return ra.type + } + } + + return undefined +} diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index 161334b4d86..e0c752a598e 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -1,3 +1,4 @@ +import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/salesforce-payload-utils' import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' @@ -20,32 +21,6 @@ function normalizeToken(s: string): string { .replace(/[\s-]+/g, '_') } -function payloadObjectType(body: Record): string | undefined { - const direct = - (typeof body.objectType === 'string' && body.objectType) || - (typeof body.sobjectType === 'string' && body.sobjectType) || - undefined - if (direct) { - return direct - } - const attrs = body.attributes as Record | undefined - if (typeof attrs?.type === 'string') { - return attrs.type - } - const record = body.record - if (record && typeof record === 'object' && !Array.isArray(record)) { - const r = record as Record - if (typeof r.sobjectType === 'string') { - return r.sobjectType - } - const ra = r.attributes as Record | undefined - if (typeof ra?.type === 'string') { - return ra.type - } - } - return undefined -} - const RECORD_CREATED = new Set([ 'record_created', 'created', @@ -115,7 +90,7 @@ export function isSalesforceEventMatch( if (!want) { return true } - const got = payloadObjectType(body) + const got = extractSalesforceObjectTypeFromPayload(body) if (!got) { return false } @@ -123,7 +98,7 @@ export function isSalesforceEventMatch( } const wantType = configuredObjectType?.trim() - const gotType = payloadObjectType(body) + const gotType = extractSalesforceObjectTypeFromPayload(body) if (wantType) { if (!gotType) { return false From 41b03484158ed4354dd13442271a6f2e5708f81a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:08:43 -0700 Subject: [PATCH 13/23] fix(webhooks): address remaining review follow-ups Loosen Linear's replay window to better tolerate delayed retries and make Notion event mismatches return false consistently with the rest of the hardened providers. Made-with: Cursor --- apps/sim/lib/webhooks/providers/linear.test.ts | 2 +- apps/sim/lib/webhooks/providers/linear.ts | 2 +- apps/sim/lib/webhooks/providers/notion.ts | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts index 86f97864bfa..0f94977f3e7 100644 --- a/apps/sim/lib/webhooks/providers/linear.test.ts +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -41,7 +41,7 @@ describe('Linear webhook provider', () => { const rawBody = JSON.stringify({ action: 'update', type: 'Issue', - webhookTimestamp: Date.now() - 120_000, + webhookTimestamp: Date.now() - 600_000, }) const res = await linearHandler.verifyAuth!({ diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 9490c3c206d..97e9d79a877 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -42,7 +42,7 @@ function validateLinearSignature(secret: string, signature: string, body: string } } -const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 60 * 1000 +const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000 export const linearHandler: WebhookProviderHandler = { async verifyAuth({ diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts index c1d2aac41e9..0b190507bf3 100644 --- a/apps/sim/lib/webhooks/providers/notion.ts +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -127,9 +127,7 @@ export const notionHandler: WebhookProviderHandler = { receivedEvent: eventType, } ) - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) + return false } } From cae9c8b543fae4d99f9ff118f3778941b96d8606 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:27:03 -0700 Subject: [PATCH 14/23] test(webhooks): separate Zoom coverage and clean Notion output shape Move Zoom provider coverage into its own test file and strip undeclared Notion type fields from normalized output objects so the runtime shape better matches the trigger contract. Made-with: Cursor --- apps/sim/lib/webhooks/providers/notion.ts | 8 ++- .../lib/webhooks/providers/salesforce.test.ts | 60 ----------------- apps/sim/lib/webhooks/providers/zoom.test.ts | 66 +++++++++++++++++++ 3 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/zoom.test.ts diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts index 0b190507bf3..ed3f1b1c965 100644 --- a/apps/sim/lib/webhooks/providers/notion.ts +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -77,6 +77,8 @@ export const notionHandler: WebhookProviderHandler = { rawData.parent && typeof rawData.parent === 'object' ? (rawData.parent as Record) : null + const { type: entityType, ...entityRest } = rawEntity + const { type: _rawParentType, ...parentRest } = rawParent ?? {} return { input: { @@ -92,15 +94,15 @@ export const notionHandler: WebhookProviderHandler = { authors: b.authors || [], accessible_by: b.accessible_by || [], entity: { - ...rawEntity, - entity_type: rawEntity.type, + ...entityRest, + entity_type: entityType, }, data: { ...rawData, ...(rawParent ? { parent: { - ...rawParent, + ...parentRest, parent_type: rawParent.type, }, } diff --git a/apps/sim/lib/webhooks/providers/salesforce.test.ts b/apps/sim/lib/webhooks/providers/salesforce.test.ts index cb33a170961..1be71dc5912 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.test.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.test.ts @@ -1,10 +1,7 @@ -import crypto from 'node:crypto' import { NextRequest } from 'next/server' import { describe, expect, it } from 'vitest' import { salesforceHandler } from '@/lib/webhooks/providers/salesforce' -import { validateZoomSignature, zoomHandler } from '@/lib/webhooks/providers/zoom' import { isSalesforceEventMatch } from '@/triggers/salesforce/utils' -import { isZoomEventMatch } from '@/triggers/zoom/utils' function reqWithHeaders(headers: Record): NextRequest { return new NextRequest('http://localhost/test', { headers }) @@ -121,60 +118,3 @@ describe('Salesforce webhook provider', () => { expect(first).toContain('updated') }) }) - -describe('Zoom webhook provider', () => { - it('isZoomEventMatch rejects empty event for specialized triggers', () => { - expect(isZoomEventMatch('zoom_meeting_started', '')).toBe(false) - expect(isZoomEventMatch('zoom_meeting_started', ' ')).toBe(false) - expect(isZoomEventMatch('zoom_meeting_started', 'meeting.started')).toBe(true) - expect(isZoomEventMatch('zoom_webhook', '')).toBe(true) - }) - - it('validateZoomSignature uses raw body bytes, not a re-serialized variant', () => { - const secret = 'test-secret' - const timestamp = String(Math.floor(Date.now() / 1000)) - const rawA = '{"a":1,"b":2}' - const rawB = '{"b":2,"a":1}' - const computed = crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${rawA}`) - const hashA = `v0=${computed.digest('hex')}` - expect(validateZoomSignature(secret, hashA, timestamp, rawA)).toBe(true) - expect(validateZoomSignature(secret, hashA, timestamp, rawB)).toBe(false) - }) - - it('extractIdempotencyId prefers meeting uuid', () => { - const zid = zoomHandler.extractIdempotencyId!({ - event: 'meeting.started', - event_ts: 123, - payload: { object: { uuid: 'u1', id: 55 } }, - }) - expect(zid).toBe('zoom:meeting.started:123:u1') - }) - - it('extractIdempotencyId uses participant identity when available', () => { - const zid = zoomHandler.extractIdempotencyId!({ - event: 'meeting.participant_joined', - event_ts: 123, - payload: { - object: { - uuid: 'meeting-uuid', - participant: { - user_id: 'participant-1', - }, - }, - }, - }) - expect(zid).toBe('zoom:meeting.participant_joined:123:participant-1') - }) - - it('matchEvent never executes endpoint validation payloads', async () => { - const result = await zoomHandler.matchEvent!({ - webhook: { id: 'w' }, - workflow: { id: 'wf' }, - body: { event: 'endpoint.url_validation' }, - request: reqWithHeaders({}), - requestId: 't5', - providerConfig: { triggerId: 'zoom_webhook' }, - }) - expect(result).toBe(false) - }) -}) diff --git a/apps/sim/lib/webhooks/providers/zoom.test.ts b/apps/sim/lib/webhooks/providers/zoom.test.ts new file mode 100644 index 00000000000..3ac74a7f694 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/zoom.test.ts @@ -0,0 +1,66 @@ +import crypto from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { validateZoomSignature, zoomHandler } from '@/lib/webhooks/providers/zoom' +import { isZoomEventMatch } from '@/triggers/zoom/utils' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('Zoom webhook provider', () => { + it('isZoomEventMatch rejects empty event for specialized triggers', () => { + expect(isZoomEventMatch('zoom_meeting_started', '')).toBe(false) + expect(isZoomEventMatch('zoom_meeting_started', ' ')).toBe(false) + expect(isZoomEventMatch('zoom_meeting_started', 'meeting.started')).toBe(true) + expect(isZoomEventMatch('zoom_webhook', '')).toBe(true) + }) + + it('validateZoomSignature uses raw body bytes, not a re-serialized variant', () => { + const secret = 'test-secret' + const timestamp = String(Math.floor(Date.now() / 1000)) + const rawA = '{"a":1,"b":2}' + const rawB = '{"b":2,"a":1}' + const computed = crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${rawA}`) + const hashA = `v0=${computed.digest('hex')}` + expect(validateZoomSignature(secret, hashA, timestamp, rawA)).toBe(true) + expect(validateZoomSignature(secret, hashA, timestamp, rawB)).toBe(false) + }) + + it('extractIdempotencyId prefers meeting uuid', () => { + const zid = zoomHandler.extractIdempotencyId!({ + event: 'meeting.started', + event_ts: 123, + payload: { object: { uuid: 'u1', id: 55 } }, + }) + expect(zid).toBe('zoom:meeting.started:123:u1') + }) + + it('extractIdempotencyId uses participant identity when available', () => { + const zid = zoomHandler.extractIdempotencyId!({ + event: 'meeting.participant_joined', + event_ts: 123, + payload: { + object: { + uuid: 'meeting-uuid', + participant: { + user_id: 'participant-1', + }, + }, + }, + }) + expect(zid).toBe('zoom:meeting.participant_joined:123:participant-1') + }) + + it('matchEvent never executes endpoint validation payloads', async () => { + const result = await zoomHandler.matchEvent!({ + webhook: { id: 'w' }, + workflow: { id: 'wf' }, + body: { event: 'endpoint.url_validation' }, + request: reqWithHeaders({}), + requestId: 't5', + providerConfig: { triggerId: 'zoom_webhook' }, + }) + expect(result).toBe(false) + }) +}) From a305fc29793f27791373c54f6b6e4bd1f8bdaccd Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:47:01 -0700 Subject: [PATCH 15/23] feat(triggers): enrich Vercel and Greenhouse webhook output shapes Document and pass through Vercel links, regions, deployment.meta, and domain.delegated; add top-level Greenhouse applicationId, candidateId, and jobId aligned with webhook common attributes. Extend alignment checker for greenhouse, update provider docs, and add formatInput tests. Made-with: Cursor --- .../docs/content/docs/en/tools/greenhouse.mdx | 14 +++ apps/docs/content/docs/en/tools/vercel.mdx | 15 +++ apps/sim/lib/webhooks/providers/greenhouse.ts | 28 ++++++ apps/sim/lib/webhooks/providers/vercel.ts | 32 +++++++ apps/sim/scripts/check-trigger-alignment.ts | 41 ++++++++- apps/sim/triggers/greenhouse/utils.test.ts | 55 +++++++++++ apps/sim/triggers/greenhouse/utils.ts | 28 ++++++ apps/sim/triggers/vercel/utils.test.ts | 40 ++++++++ apps/sim/triggers/vercel/utils.ts | 92 +++++++++++++------ 9 files changed, 314 insertions(+), 31 deletions(-) diff --git a/apps/docs/content/docs/en/tools/greenhouse.mdx b/apps/docs/content/docs/en/tools/greenhouse.mdx index a96605a0325..c1deb378011 100644 --- a/apps/docs/content/docs/en/tools/greenhouse.mdx +++ b/apps/docs/content/docs/en/tools/greenhouse.mdx @@ -29,6 +29,20 @@ In Sim, the Greenhouse integration enables your agents to interact with your rec Integrate Greenhouse into the workflow. List and retrieve candidates, jobs, applications, users, departments, offices, and job stages from your Greenhouse ATS account. +### Triggers (webhooks) + +Sim accepts [Greenhouse webhooks](https://developers.greenhouse.io/webhooks.html) POSTed to your trigger URL. Each delivery is normalized to workflow inputs with: + +| Field | Description | +| ----- | ----------- | +| `action` | Webhook action string (e.g. `new_candidate_application`) | +| `applicationId` | `payload.application.id`, or `payload.application_id` on flat offer payloads | +| `candidateId` | `payload.application.candidate.id` when the nested candidate object is present | +| `jobId` | `payload.job.id` for job webhooks, or `payload.job_id` when provided on the payload (e.g. offers) | +| `payload` | Full JSON `payload` for the event | + +Nested objects such as `email_addresses[].type` remain inside `payload` as JSON so they do not collide with Sim’s trigger output schema rules. + ## Tools diff --git a/apps/docs/content/docs/en/tools/vercel.mdx b/apps/docs/content/docs/en/tools/vercel.mdx index 0baacc3f610..3f4f8050e50 100644 --- a/apps/docs/content/docs/en/tools/vercel.mdx +++ b/apps/docs/content/docs/en/tools/vercel.mdx @@ -28,6 +28,21 @@ In Sim, the Vercel integration lets your agents programmatically manage deployme Integrate with Vercel to manage deployments, projects, domains, DNS records, environment variables, aliases, edge configs, teams, and more. +### Triggers (webhooks) + +Sim can subscribe to [Vercel webhooks](https://vercel.com/docs/webhooks/webhooks-api). Incoming events are normalized to workflow inputs that mirror the documented webhook body: + +| Field | Description | +| ----- | ----------- | +| `type`, `id`, `createdAt`, `region` | Top-level delivery metadata from the webhook JSON | +| `payload` | Full `payload` object for the event | +| `links.deployment`, `links.project` | Dashboard URLs when Vercel includes `payload.links` | +| `regions` | `payload.regions` list when present (e.g. deployment events) | +| `deployment` | `id`, `url`, `name`, and `meta` (metadata map) when `payload.deployment` exists | +| `project`, `team`, `user` | Normalized ids (and project name) from the payload when present | +| `target`, `plan` | From `payload.target` and `payload.plan` when present | +| `domain` | `name` and `delegated` when `payload.domain` exists (e.g. `domain.created`) | + ## Tools diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts index 08c09986d87..241e2221d10 100644 --- a/apps/sim/lib/webhooks/providers/greenhouse.ts +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -43,9 +43,37 @@ export const greenhouseHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const b = body as Record + const payload = (b.payload || {}) as Record + const application = (payload.application || {}) as Record + const candidate = (application.candidate || {}) as Record + const jobNested = payload.job + + let applicationId: number | null = null + if (typeof application.id === 'number') { + applicationId = application.id + } else if (typeof payload.application_id === 'number') { + applicationId = payload.application_id + } + + const candidateId = typeof candidate.id === 'number' ? candidate.id : null + + let jobId: number | null = null + if ( + jobNested && + typeof jobNested === 'object' && + typeof (jobNested as Record).id === 'number' + ) { + jobId = (jobNested as Record).id as number + } else if (typeof payload.job_id === 'number') { + jobId = payload.job_id + } + return { input: { action: b.action, + applicationId, + candidateId, + jobId, payload: b.payload || {}, }, } diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts index 251f7f23b24..218afb3d6fd 100644 --- a/apps/sim/lib/webhooks/providers/vercel.ts +++ b/apps/sim/lib/webhooks/providers/vercel.ts @@ -256,6 +256,31 @@ export const vercelHandler: WebhookProviderHandler = { const user = payload.user ?? null const domain = payload.domain ?? null + const linksRaw = payload.links + let links: { deployment: string; project: string } | null = null + if (linksRaw && typeof linksRaw === 'object' && !Array.isArray(linksRaw)) { + const L = linksRaw as Record + const dep = L.deployment + const proj = L.project + if (typeof dep === 'string' || typeof proj === 'string') { + links = { + deployment: typeof dep === 'string' ? dep : '', + project: typeof proj === 'string' ? proj : '', + } + } + } + + const regionsRaw = payload.regions + const regions = Array.isArray(regionsRaw) ? regionsRaw : null + + let deploymentMeta: Record | null = null + if (deployment && typeof deployment === 'object') { + const meta = (deployment as Record).meta + if (meta && typeof meta === 'object' && !Array.isArray(meta)) { + deploymentMeta = meta as Record + } + } + return { input: { type: body.type ?? '', @@ -274,6 +299,8 @@ export const vercelHandler: WebhookProviderHandler = { })(), region: body.region != null ? String(body.region) : null, payload, + links, + regions, deployment: deployment && typeof deployment === 'object' ? { @@ -283,6 +310,7 @@ export const vercelHandler: WebhookProviderHandler = { : '', url: ((deployment as Record).url as string) ?? '', name: ((deployment as Record).name as string) ?? '', + meta: deploymentMeta, } : null, project: @@ -319,6 +347,10 @@ export const vercelHandler: WebhookProviderHandler = { domain && typeof domain === 'object' ? { name: ((domain as Record).name as string) ?? '', + delegated: + typeof (domain as Record).delegated === 'boolean' + ? ((domain as Record).delegated as boolean) + : null, } : null, }, diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts index 725edc55097..3882d432af9 100644 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -142,13 +142,23 @@ const PROVIDER_CHECKS: Record = { createdAt: Date.now(), region: 'iad1', payload: { - deployment: { id: 'dep_1', url: 'example.vercel.app', name: 'preview' }, + deployment: { + id: 'dep_1', + url: 'example.vercel.app', + name: 'preview', + meta: { githubCommitSha: 'abc123' }, + }, project: { id: 'prj_1', name: 'project' }, team: { id: 'team_1' }, user: { id: 'user_1' }, target: 'preview', plan: 'pro', - domain: { name: 'example.com' }, + links: { + deployment: 'https://vercel.com/acme/project/dep', + project: 'https://vercel.com/acme/project', + }, + regions: ['iad1'], + domain: { name: 'example.com', delegated: false }, }, }, headers: {}, @@ -161,6 +171,33 @@ const PROVIDER_CHECKS: Record = { formatInputKeys: Object.keys(input).sort(), } }, + greenhouse: async () => { + const { buildWebhookOutputs } = await import('@/triggers/greenhouse/utils') + const { greenhouseHandler } = await import('@/lib/webhooks/providers/greenhouse') + const outputs = buildWebhookOutputs() as Record + const result = await greenhouseHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + action: 'new_candidate_application', + payload: { + application: { + id: 71980812, + candidate: { id: 60304594 }, + jobs: [{ id: 274075, name: 'Engineer' }], + }, + }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildWebhookOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, } const provider = process.argv[2]?.trim() diff --git a/apps/sim/triggers/greenhouse/utils.test.ts b/apps/sim/triggers/greenhouse/utils.test.ts index b16e7a155e3..8883171c445 100644 --- a/apps/sim/triggers/greenhouse/utils.test.ts +++ b/apps/sim/triggers/greenhouse/utils.test.ts @@ -31,3 +31,58 @@ describe('isGreenhouseEventMatch', () => { expect(key).toBe('greenhouse:offer_deleted:offer:42:3') }) }) + +describe('greenhouseHandler.formatInput', () => { + it('exposes application, candidate, and job ids alongside action and payload', async () => { + const { input } = await greenhouseHandler.formatInput!({ + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + body: { + action: 'new_candidate_application', + payload: { + application: { + id: 100, + candidate: { id: 200 }, + jobs: [{ id: 300 }], + }, + }, + }, + headers: {}, + requestId: 't', + }) + expect(input).toMatchObject({ + action: 'new_candidate_application', + applicationId: 100, + candidateId: 200, + jobId: null, + }) + expect(input).toHaveProperty('payload') + }) + + it('reads job id from payload.job and offer job_id', async () => { + const jobFromNested = await greenhouseHandler.formatInput!({ + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + body: { + action: 'job_created', + payload: { job: { id: 55 } }, + }, + headers: {}, + requestId: 't', + }) + expect((jobFromNested.input as Record).jobId).toBe(55) + + const jobFromOffer = await greenhouseHandler.formatInput!({ + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + body: { + action: 'offer_created', + payload: { id: 1, application_id: 2, job_id: 66 }, + }, + headers: {}, + requestId: 't', + }) + expect((jobFromOffer.input as Record).jobId).toBe(66) + expect((jobFromOffer.input as Record).applicationId).toBe(2) + }) +}) diff --git a/apps/sim/triggers/greenhouse/utils.ts b/apps/sim/triggers/greenhouse/utils.ts index ee089960056..8210761dc8c 100644 --- a/apps/sim/triggers/greenhouse/utils.ts +++ b/apps/sim/triggers/greenhouse/utils.ts @@ -1,6 +1,26 @@ import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' +/** + * Top-level ids mirrored from the webhook JSON for ergonomics (see Greenhouse webhook common attributes). + * Always present on `formatInput`; use null when not applicable to the event. + */ +const greenhouseIndexedOutputs = { + applicationId: { + type: 'number', + description: + 'Application id when present (`payload.application.id` or flat `payload.application_id` on offers)', + }, + candidateId: { + type: 'number', + description: 'Candidate id when `payload.application.candidate.id` is present', + }, + jobId: { + type: 'number', + description: 'Job id from `payload.job.id` or flat `payload.job_id` when present', + }, +} as const + /** * Dropdown options for the Greenhouse trigger type selector. */ @@ -103,6 +123,7 @@ export function greenhouseSetupInstructions(eventType: string): string { export function buildCandidateHiredOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (hire_candidate)' }, + ...greenhouseIndexedOutputs, payload: { application: { id: { type: 'number', description: 'Application ID' }, @@ -146,6 +167,7 @@ export function buildCandidateHiredOutputs(): Record { export function buildNewApplicationOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (new_candidate_application)' }, + ...greenhouseIndexedOutputs, payload: { application: { id: { type: 'number', description: 'Application ID' }, @@ -185,6 +207,7 @@ export function buildNewApplicationOutputs(): Record { export function buildCandidateStageChangeOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (candidate_stage_change)' }, + ...greenhouseIndexedOutputs, payload: { application: { id: { type: 'number', description: 'Application ID' }, @@ -221,6 +244,7 @@ export function buildCandidateStageChangeOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (reject_candidate)' }, + ...greenhouseIndexedOutputs, payload: { application: { id: { type: 'number', description: 'Application ID' }, @@ -259,6 +283,7 @@ export function buildCandidateRejectedOutputs(): Record { export function buildOfferCreatedOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (offer_created)' }, + ...greenhouseIndexedOutputs, payload: { id: { type: 'number', description: 'Offer ID' }, application_id: { type: 'number', description: 'Associated application ID' }, @@ -303,6 +328,7 @@ function buildJobPayload(): Record { export function buildJobCreatedOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (job_created)' }, + ...greenhouseIndexedOutputs, payload: { job: buildJobPayload() }, } as Record } @@ -314,6 +340,7 @@ export function buildJobCreatedOutputs(): Record { export function buildJobUpdatedOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type (job_updated)' }, + ...greenhouseIndexedOutputs, payload: { job: buildJobPayload() }, } as Record } @@ -324,6 +351,7 @@ export function buildJobUpdatedOutputs(): Record { export function buildWebhookOutputs(): Record { return { action: { type: 'string', description: 'The webhook event type' }, + ...greenhouseIndexedOutputs, payload: { type: 'json', description: 'Full event payload' }, } as Record } diff --git a/apps/sim/triggers/vercel/utils.test.ts b/apps/sim/triggers/vercel/utils.test.ts index 2622950f226..baa3927265c 100644 --- a/apps/sim/triggers/vercel/utils.test.ts +++ b/apps/sim/triggers/vercel/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { vercelHandler } from '@/lib/webhooks/providers/vercel' import { isVercelEventMatch } from '@/triggers/vercel/utils' describe('isVercelEventMatch', () => { @@ -19,3 +20,42 @@ describe('isVercelEventMatch', () => { expect(isVercelEventMatch('vercel_webhook', undefined)).toBe(true) }) }) + +describe('vercelHandler.formatInput', () => { + it('passes through documented deployment links, regions, meta, and domain.delegated', async () => { + const { input } = await vercelHandler.formatInput!({ + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + body: { + type: 'deployment.created', + id: 'evt_1', + createdAt: 1_700_000_000_000, + region: 'iad1', + payload: { + deployment: { + id: 'd1', + url: 'https://x.vercel.app', + name: 'x', + meta: { k: 'v' }, + }, + links: { deployment: 'https://vercel.com/d', project: 'https://vercel.com/p' }, + regions: ['iad1', 'sfo1'], + domain: { name: 'example.com', delegated: true }, + }, + }, + headers: {}, + requestId: 't', + }) + const i = input as Record + expect(i.links).toEqual({ + deployment: 'https://vercel.com/d', + project: 'https://vercel.com/p', + }) + expect(i.regions).toEqual(['iad1', 'sfo1']) + expect(i.deployment).toMatchObject({ + id: 'd1', + meta: { k: 'v' }, + }) + expect(i.domain).toMatchObject({ name: 'example.com', delegated: true }) + }) +}) diff --git a/apps/sim/triggers/vercel/utils.ts b/apps/sim/triggers/vercel/utils.ts index 671e388c70f..ee895efac2f 100644 --- a/apps/sim/triggers/vercel/utils.ts +++ b/apps/sim/triggers/vercel/utils.ts @@ -143,14 +143,44 @@ const payloadOutput = { } as const /** - * Deployment-specific output fields + * Dashboard deep links included on many deployment webhook events (Vercel Webhooks API). */ -const deploymentOutputs = { +const linksOutputs = { + links: { + deployment: { + type: 'string', + description: 'Vercel Dashboard URL for the deployment', + }, + project: { + type: 'string', + description: 'Vercel Dashboard URL for the project', + }, + }, + regions: { + type: 'json', + description: 'Regions associated with the deployment (array), when provided by Vercel', + }, +} as const + +/** Normalized deployment object from `formatInput` (null when no deployment on the event). */ +const deploymentResourceOutputs = { deployment: { id: { type: 'string', description: 'Deployment ID' }, url: { type: 'string', description: 'Deployment URL' }, name: { type: 'string', description: 'Deployment name' }, + meta: { + type: 'json', + description: 'Deployment metadata map (e.g. Git metadata), per Vercel Webhooks API', + }, }, +} as const + +/** + * Deployment-specific output fields + */ +const deploymentOutputs = { + ...linksOutputs, + ...deploymentResourceOutputs, project: { id: { type: 'string', description: 'Project ID' }, name: { type: 'string', description: 'Project name' }, @@ -163,12 +193,25 @@ const deploymentOutputs = { }, target: { type: 'string', - description: 'Deployment target (production, preview)', + description: 'Deployment target (production, staging, or preview)', }, plan: { type: 'string', description: 'Account plan type', }, + domain: { + name: { type: 'string', description: 'Domain name' }, + delegated: { + type: 'boolean', + description: 'Whether the domain was delegated/shared when present on the payload', + }, + }, +} as const + +const deploymentTargetPlanDomain = { + target: deploymentOutputs.target, + plan: deploymentOutputs.plan, + domain: deploymentOutputs.domain, } as const /** @@ -193,6 +236,11 @@ const projectOutputs = { const domainOutputs = { domain: { name: { type: 'string', description: 'Domain name' }, + delegated: { + type: 'boolean', + description: + 'Whether the domain was delegated/shared (domain.created), per Vercel Webhooks API', + }, }, project: { id: { type: 'string', description: 'Project ID' }, @@ -223,7 +271,10 @@ export function buildProjectOutputs(): Record { return { ...coreOutputs, ...payloadOutput, + ...linksOutputs, + ...deploymentResourceOutputs, ...projectOutputs, + ...deploymentTargetPlanDomain, } as Record } @@ -234,6 +285,9 @@ export function buildDomainOutputs(): Record { return { ...coreOutputs, ...payloadOutput, + ...linksOutputs, + ...deploymentResourceOutputs, + ...deploymentTargetPlanDomain, ...domainOutputs, } as Record } @@ -245,31 +299,11 @@ export function buildVercelOutputs(): Record { return { ...coreOutputs, payload: { type: 'json', description: 'Full event payload' }, - deployment: { - id: { type: 'string', description: 'Deployment ID' }, - url: { type: 'string', description: 'Deployment URL' }, - name: { type: 'string', description: 'Deployment name' }, - }, - project: { - id: { type: 'string', description: 'Project ID' }, - name: { type: 'string', description: 'Project name' }, - }, - team: { - id: { type: 'string', description: 'Team ID' }, - }, - user: { - id: { type: 'string', description: 'User ID' }, - }, - target: { - type: 'string', - description: 'Deployment target (production, preview)', - }, - plan: { - type: 'string', - description: 'Account plan type', - }, - domain: { - name: { type: 'string', description: 'Domain name' }, - }, + ...linksOutputs, + ...deploymentResourceOutputs, + project: deploymentOutputs.project, + team: deploymentOutputs.team, + user: deploymentOutputs.user, + ...deploymentTargetPlanDomain, } as Record } From 3e293410f802934fc7a5ed271bbf7de99be9333b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:45:07 -0700 Subject: [PATCH 16/23] feat(webhooks): enrich Resend trigger outputs; clarify Notion output docs - Resend: expose broadcast_id, template_id, tags, and data_created_at from payload data (per Resend webhook docs); keep alignment with formatInput. - Add resend entry to check-trigger-alignment and unit test for formatInput. - Notion: tighten output descriptions for authors, entity types, parent types, attempt_number, and accessible_by per Notion webhooks event reference. Made-with: Cursor --- .../sim/lib/webhooks/providers/resend.test.ts | 45 ++++++++++++++++++ apps/sim/lib/webhooks/providers/resend.ts | 11 +++++ apps/sim/scripts/check-trigger-alignment.ts | 37 +++++++++++++++ apps/sim/triggers/notion/utils.ts | 47 ++++++++++++++----- apps/sim/triggers/resend/utils.ts | 20 +++++++- 5 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/resend.test.ts diff --git a/apps/sim/lib/webhooks/providers/resend.test.ts b/apps/sim/lib/webhooks/providers/resend.test.ts new file mode 100644 index 00000000000..9919ff22930 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/resend.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { resendHandler } from '@/lib/webhooks/providers/resend' + +describe('Resend webhook provider', () => { + it('formatInput exposes documented email metadata and distinct data.created_at', async () => { + const { input } = await resendHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { + type: 'email.bounced', + created_at: '2024-11-22T23:41:12.126Z', + data: { + broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216', + created_at: '2024-11-22T23:41:11.894719+00:00', + email_id: '56761188-7520-42d8-8898-ff6fc54ce618', + from: 'Acme ', + to: ['delivered@resend.dev'], + subject: 'Sending this example', + template_id: '43f68331-0622-4e15-8202-246a0388854b', + tags: { category: 'confirm_email' }, + bounce: { + message: 'Hard bounce', + subType: 'Suppressed', + type: 'Permanent', + }, + }, + }, + headers: {}, + requestId: 'test', + }) + + expect(input).toMatchObject({ + type: 'email.bounced', + created_at: '2024-11-22T23:41:12.126Z', + data_created_at: '2024-11-22T23:41:11.894719+00:00', + email_id: '56761188-7520-42d8-8898-ff6fc54ce618', + broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216', + template_id: '43f68331-0622-4e15-8202-246a0388854b', + tags: { category: 'confirm_email' }, + bounceType: 'Permanent', + bounceSubType: 'Suppressed', + bounceMessage: 'Hard bounce', + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts index 43afe204e71..2238863525f 100644 --- a/apps/sim/lib/webhooks/providers/resend.ts +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -121,13 +121,24 @@ export const resendHandler: WebhookProviderHandler = { const data = payload.data as Record | undefined const bounce = data?.bounce as Record | undefined const click = data?.click as Record | undefined + const dataCreatedAt = data?.created_at + const dataCreatedAtStr = + typeof dataCreatedAt === 'string' + ? dataCreatedAt + : dataCreatedAt != null + ? String(dataCreatedAt) + : null return { input: { type: payload.type, created_at: payload.created_at, + data_created_at: dataCreatedAtStr, data: data ?? null, email_id: data?.email_id ?? null, + broadcast_id: data?.broadcast_id ?? null, + template_id: data?.template_id ?? null, + tags: data?.tags ?? null, from: data?.from ?? null, to: data?.to ?? null, subject: data?.subject ?? null, diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts index 3882d432af9..a825e283277 100644 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -75,6 +75,43 @@ const PROVIDER_CHECKS: Record = { formatInputKeys: Object.keys(input).sort(), } }, + resend: async () => { + const { buildResendOutputs } = await import('@/triggers/resend/utils') + const { resendHandler } = await import('@/lib/webhooks/providers/resend') + const outputs = buildResendOutputs() as Record + const result = await resendHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + type: 'email.bounced', + created_at: '2024-11-22T23:41:12.126Z', + data: { + broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216', + created_at: '2024-11-22T23:41:11.894719+00:00', + email_id: '56761188-7520-42d8-8898-ff6fc54ce618', + from: 'Acme ', + to: ['delivered@resend.dev'], + subject: 'Sending this example', + template_id: '43f68331-0622-4e15-8202-246a0388854b', + bounce: { + message: + "The recipient's email address is on the suppression list because it has a recent history of producing hard bounces.", + subType: 'Suppressed', + type: 'Permanent', + }, + tags: { category: 'confirm_email' }, + }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildResendOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, notion: async () => { const { buildPageEventOutputs } = await import('@/triggers/notion/utils') const { notionHandler } = await import('@/lib/webhooks/providers/notion') diff --git a/apps/sim/triggers/notion/utils.ts b/apps/sim/triggers/notion/utils.ts index 6e7be800bb1..c220252de22 100644 --- a/apps/sim/triggers/notion/utils.ts +++ b/apps/sim/triggers/notion/utils.ts @@ -84,10 +84,14 @@ function buildBaseOutputs(): Record { workspace_name: { type: 'string', description: 'Workspace name' }, subscription_id: { type: 'string', description: 'Webhook subscription ID' }, integration_id: { type: 'string', description: 'Integration ID that received the event' }, - attempt_number: { type: 'number', description: 'Delivery attempt number' }, + attempt_number: { + type: 'number', + description: 'Delivery attempt number (1-8 per Notion retries)', + }, accessible_by: { type: 'array', - description: 'Array of users and bots that can access the entity', + description: + 'Users and bots with access to the entity (`id` + `type` per object); `type` is `person` or `bot`. Omitted on some deliveries (treat as empty).', }, } } @@ -97,8 +101,14 @@ function buildBaseOutputs(): Record { */ function buildEntityOutputs(): Record { return { - id: { type: 'string', description: 'Entity ID (page or database ID)' }, - entity_type: { type: 'string', description: 'Entity type (page, database, block, or comment)' }, + id: { + type: 'string', + description: 'Entity ID (page, database, block, comment, or data source ID)', + }, + entity_type: { + type: 'string', + description: 'Entity type: `page`, `database`, `block`, `comment`, or `data_source`', + }, } } @@ -110,7 +120,8 @@ export function buildPageEventOutputs(): Record { ...buildBaseOutputs(), authors: { type: 'array', - description: 'Array of users who triggered the event', + description: + 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion', }, entity: buildEntityOutputs(), data: { @@ -123,10 +134,13 @@ export function buildPageEventOutputs(): Record { description: 'Property IDs updated as part of the event, when provided by Notion', }, parent: { - id: { type: 'string', description: 'Parent page or database ID' }, + id: { + type: 'string', + description: 'Parent page, database, workspace (space), or block ID', + }, parent_type: { type: 'string', - description: 'Parent type (database, page, block, or workspace)', + description: 'Parent type: `page`, `database`, `block`, `workspace`, or `space`', }, }, }, @@ -141,7 +155,8 @@ export function buildDatabaseEventOutputs(): Record { ...buildBaseOutputs(), authors: { type: 'array', - description: 'Array of users who triggered the event', + description: + 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion', }, entity: buildEntityOutputs(), data: { @@ -154,8 +169,11 @@ export function buildDatabaseEventOutputs(): Record { description: 'Database properties updated as part of the event, when provided by Notion', }, parent: { - id: { type: 'string', description: 'Parent page or workspace ID' }, - parent_type: { type: 'string', description: 'Parent type (page, database, or workspace)' }, + id: { type: 'string', description: 'Parent page, database, workspace, or space ID' }, + parent_type: { + type: 'string', + description: 'Parent type: `page`, `database`, `workspace`, or `space`', + }, }, }, } @@ -169,7 +187,8 @@ export function buildCommentEventOutputs(): Record { ...buildBaseOutputs(), authors: { type: 'array', - description: 'Array of users who triggered the event', + description: + 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion', }, entity: { id: { type: 'string', description: 'Comment ID' }, @@ -193,7 +212,8 @@ export function buildGenericWebhookOutputs(): Record { ...buildBaseOutputs(), authors: { type: 'array', - description: 'Array of users who triggered the event', + description: + 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion', }, entity: buildEntityOutputs(), data: { @@ -201,7 +221,8 @@ export function buildGenericWebhookOutputs(): Record { id: { type: 'string', description: 'Parent entity ID, when provided by Notion' }, parent_type: { type: 'string', - description: 'Parent entity type (page, database, block, or workspace), when present', + description: + 'Parent type (`page`, `database`, `block`, `workspace`, `space`, …), when present', }, }, page_id: { type: 'string', description: 'Page ID related to the event, when present' }, diff --git a/apps/sim/triggers/resend/utils.ts b/apps/sim/triggers/resend/utils.ts index a77b0b1cfaf..13f79262838 100644 --- a/apps/sim/triggers/resend/utils.ts +++ b/apps/sim/triggers/resend/utils.ts @@ -95,6 +95,7 @@ export function buildResendExtraFields(triggerId: string) { /** * Common fields present in all Resend email webhook payloads + * (see https://resend.com/docs/dashboard/webhooks/introduction — example `data` object). */ const commonEmailOutputs = { type: { @@ -103,12 +104,29 @@ const commonEmailOutputs = { }, created_at: { type: 'string', - description: 'Event creation timestamp (ISO 8601)', + description: 'Webhook event creation timestamp (ISO 8601), top-level `created_at`', + }, + data_created_at: { + type: 'string', + description: + 'Email record timestamp from payload `data.created_at` (ISO 8601), when present — distinct from top-level `created_at`', }, email_id: { type: 'string', description: 'Unique email identifier', }, + broadcast_id: { + type: 'string', + description: 'Broadcast ID associated with the email, when sent as part of a broadcast', + }, + template_id: { + type: 'string', + description: 'Template ID used to send the email, when applicable', + }, + tags: { + type: 'json', + description: 'Tag key/value metadata attached to the email (payload `data.tags`)', + }, from: { type: 'string', description: 'Sender email address', From 514893624390bfe5b3786185a6dbf0e7ff5f9c62 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:46:30 -0700 Subject: [PATCH 17/23] feat(webhooks): enrich Zoom and Gong trigger output schemas - Zoom: add formatInput passthrough, fix nested TriggerOutput shape (drop invalid `properties` wrappers), document host_email, join_url, agenda, status, meeting_type on recordings, participant duration, and alignment checker entry. - Gong: flatten topics/highlights from callData.content in formatInput, extend metaData and trigger outputs per API docs, tests and alignment keys updated. - Docs: add English webhook trigger sections for Zoom and Gong tools pages. --- apps/docs/content/docs/en/tools/gong.mdx | 2 + apps/docs/content/docs/en/tools/zoom.mdx | 3 + apps/sim/lib/webhooks/providers/gong.test.ts | 24 ++++ apps/sim/lib/webhooks/providers/gong.ts | 2 + apps/sim/lib/webhooks/providers/zoom.test.ts | 16 +++ apps/sim/lib/webhooks/providers/zoom.ts | 10 ++ apps/sim/scripts/check-trigger-alignment.ts | 22 +++ apps/sim/triggers/gong/utils.ts | 36 ++++- apps/sim/triggers/zoom/utils.ts | 140 ++++++++++++------- 9 files changed, 197 insertions(+), 58 deletions(-) diff --git a/apps/docs/content/docs/en/tools/gong.mdx b/apps/docs/content/docs/en/tools/gong.mdx index 34ef1563d6e..663bb161c4f 100644 --- a/apps/docs/content/docs/en/tools/gong.mdx +++ b/apps/docs/content/docs/en/tools/gong.mdx @@ -33,7 +33,9 @@ By combining these capabilities, you can automate sales coaching workflows, extr Integrate Gong into your workflow. Access call recordings, transcripts, user data, activity stats, scorecards, trackers, library content, coaching metrics, and more via the Gong API. +## Webhook triggers +[Gong automation rules](https://help.gong.io/docs/create-a-webhook-rule) can **Fire webhook** with a JSON body that includes `callData` (with `metaData`, `parties`, `context`, and nested `content`), plus `isTest` when sent from the Gong UI. Sim maps this to workflow input with a stable `eventType` of `gong.automation_rule`, a string `callId` from `metaData.id`, the full `callData` object, a flattened `metaData` object, and arrays at the top level for `parties`, `context`, `trackers` (from `callData.content.trackers`), `topics` (from `callData.content.topics`), and `highlights` (from `callData.content.highlights`). Metadata fields match the call objects documented under `gong_get_call` and extensive calls above (`workspaceId`, `meetingUrl`, `purpose`, `isPrivate`, `calendarEventId`, etc.) when Gong includes them. ## Tools diff --git a/apps/docs/content/docs/en/tools/zoom.mdx b/apps/docs/content/docs/en/tools/zoom.mdx index 4926fd24973..1b77909fb09 100644 --- a/apps/docs/content/docs/en/tools/zoom.mdx +++ b/apps/docs/content/docs/en/tools/zoom.mdx @@ -417,4 +417,7 @@ List participants from a past Zoom meeting | ↳ `totalRecords` | number | Total number of records | | ↳ `nextPageToken` | string | Token for next page of results | +## Webhook triggers + +Zoom **Meeting** and **Recording** webhook payloads use the same top-level envelope Sim exposes to workflows: `event` (string), `event_ts` (number, ms), and `payload` with `account_id` and an `object` whose fields depend on the event. Field names align with Zoom’s Meetings and cloud recording objects (for example meeting `id`, `uuid`, `topic`, `host_id`, `start_time`, `timezone`, `duration`, `join_url`, and recording `recording_files`). See [Zoom webhooks](https://developers.zoom.us/docs/api/webhooks/) and [Meeting webhooks](https://developers.zoom.us/docs/api/meetings/events/) for the authoritative event list; participant events nest a `participant` object alongside meeting fields. diff --git a/apps/sim/lib/webhooks/providers/gong.test.ts b/apps/sim/lib/webhooks/providers/gong.test.ts index 68d7b02d1be..d6841cf6194 100644 --- a/apps/sim/lib/webhooks/providers/gong.test.ts +++ b/apps/sim/lib/webhooks/providers/gong.test.ts @@ -40,6 +40,30 @@ describe('gongHandler formatInput', () => { expect((input as Record).callId).toBe('') }) + + it('exposes content topics and highlights alongside trackers', async () => { + const { input } = await gongHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { + callData: { + metaData: { id: '99' }, + content: { + trackers: [{ id: 't1', name: 'Competitor', count: 2 }], + topics: [{ name: 'Pricing', duration: 120 }], + highlights: [{ title: 'Action items' }], + }, + }, + }, + headers: {}, + requestId: 'gong-format-content', + }) + const rec = input as Record + expect(rec.callId).toBe('99') + expect(rec.trackers).toEqual([{ id: 't1', name: 'Competitor', count: 2 }]) + expect(rec.topics).toEqual([{ name: 'Pricing', duration: 120 }]) + expect(rec.highlights).toEqual([{ title: 'Action items' }]) + }) }) describe('gongHandler verifyAuth (JWT)', () => { diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts index fea4f8fb637..428747cc3e8 100644 --- a/apps/sim/lib/webhooks/providers/gong.ts +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -139,6 +139,8 @@ export const gongHandler: WebhookProviderHandler = { parties: (callData?.parties as unknown[]) || [], context: (callData?.context as unknown[]) || [], trackers: (content?.trackers as unknown[]) || [], + topics: (content?.topics as unknown[]) || [], + highlights: (content?.highlights as unknown[]) || [], eventType: 'gong.automation_rule', callId, }, diff --git a/apps/sim/lib/webhooks/providers/zoom.test.ts b/apps/sim/lib/webhooks/providers/zoom.test.ts index 3ac74a7f694..57ff95e8e36 100644 --- a/apps/sim/lib/webhooks/providers/zoom.test.ts +++ b/apps/sim/lib/webhooks/providers/zoom.test.ts @@ -52,6 +52,22 @@ describe('Zoom webhook provider', () => { expect(zid).toBe('zoom:meeting.participant_joined:123:participant-1') }) + it('formatInput passes through the Zoom webhook envelope', async () => { + const body = { + event: 'meeting.started', + event_ts: 1700000000000, + payload: { account_id: 'acct', object: { id: 1 } }, + } + const { input } = await zoomHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body, + headers: {}, + requestId: 'zoom-format', + }) + expect(input).toBe(body) + }) + it('matchEvent never executes endpoint validation payloads', async () => { const result = await zoomHandler.matchEvent!({ webhook: { id: 'w' }, diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index ba4ef01dc71..c78cf0c9386 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -9,6 +9,8 @@ import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import type { AuthContext, EventMatchContext, + FormatInputContext, + FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' @@ -93,6 +95,14 @@ async function resolveZoomChallengeSecrets( } export const zoomHandler: WebhookProviderHandler = { + /** + * Zoom delivers the standard app webhook envelope (`event`, `event_ts`, `payload`). + * Pass through unchanged so trigger outputs match runtime input. + */ + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secretToken = providerConfig.secretToken as string | undefined if (!secretToken) { diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts index a825e283277..c2d1f5625c1 100644 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -28,6 +28,28 @@ type CheckFn = () => Promise<{ }> const PROVIDER_CHECKS: Record = { + zoom: async () => { + const { buildMeetingOutputs } = await import('@/triggers/zoom/utils') + const { zoomHandler } = await import('@/lib/webhooks/providers/zoom') + const outputs = buildMeetingOutputs() as Record + const result = await zoomHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + event: 'meeting.started', + event_ts: 1700000000000, + payload: { account_id: 'acct_1', object: { id: 123456789, uuid: 'abc' } }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildMeetingOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, gong: async () => { const { buildCallOutputs } = await import('@/triggers/gong/utils') const { gongHandler } = await import('@/lib/webhooks/providers/gong') diff --git a/apps/sim/triggers/gong/utils.ts b/apps/sim/triggers/gong/utils.ts index a9b8bd3024b..f218eb86cf5 100644 --- a/apps/sim/triggers/gong/utils.ts +++ b/apps/sim/triggers/gong/utils.ts @@ -87,11 +87,28 @@ export function buildCallOutputs(): Record { started: { type: 'string', description: 'Actual start time (ISO 8601)' }, duration: { type: 'number', description: 'Call duration in seconds' }, primaryUserId: { type: 'string', description: 'Primary Gong user ID' }, - direction: { type: 'string', description: 'Call direction (Conference, Call, etc.)' }, - system: { type: 'string', description: 'Meeting system (Zoom, Teams, etc.)' }, - scope: { type: 'string', description: 'Call scope (External or Internal)' }, + workspaceId: { type: 'string', description: 'Gong workspace ID' }, + direction: { type: 'string', description: 'Call direction (Inbound, Outbound, etc.)' }, + system: { type: 'string', description: 'Communication platform used (e.g. Zoom, Teams)' }, + scope: { type: 'string', description: 'Call scope (Internal, External, or Unknown)' }, media: { type: 'string', description: 'Media type (Video or Audio)' }, - language: { type: 'string', description: 'Call language code' }, + language: { type: 'string', description: 'Language code (ISO-639-2B)' }, + sdrDisposition: { + type: 'string', + description: 'SDR disposition classification (when present)', + }, + clientUniqueId: { + type: 'string', + description: 'Call identifier from the origin recording system (when present)', + }, + customData: { + type: 'string', + description: 'Custom metadata from call creation (when present)', + }, + purpose: { type: 'string', description: 'Call purpose (when present)' }, + meetingUrl: { type: 'string', description: 'Web conference provider URL (when present)' }, + isPrivate: { type: 'boolean', description: 'Whether the call is private (when present)' }, + calendarEventId: { type: 'string', description: 'Calendar event identifier (when present)' }, }, parties: { type: 'array', @@ -103,7 +120,16 @@ export function buildCallOutputs(): Record { }, trackers: { type: 'array', - description: 'Array of tracked topics/keywords with counts', + description: + 'Keyword and smart trackers from call content (same shape as Gong extensive-calls `content.trackers`)', + }, + topics: { + type: 'array', + description: 'Topic segments with durations from call content (`content.topics`)', + }, + highlights: { + type: 'array', + description: 'AI-generated highlights from call content (`content.highlights`)', }, } as Record } diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts index fd6d850f4ea..1b95df47ace 100644 --- a/apps/sim/triggers/zoom/utils.ts +++ b/apps/sim/triggers/zoom/utils.ts @@ -128,23 +128,45 @@ export function buildMeetingOutputs(): Record { }, object: { type: 'object', - description: 'Meeting details', - properties: { - id: { type: 'number', description: 'Meeting ID' }, - uuid: { type: 'string', description: 'Meeting UUID' }, - topic: { type: 'string', description: 'Meeting topic' }, - meeting_type: { - type: 'number', - description: 'Meeting type (1=instant, 2=scheduled, etc.)', - }, - host_id: { type: 'string', description: 'Host user ID' }, - start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' }, - end_time: { - type: 'string', - description: 'Meeting end time (ISO 8601, present on meeting.ended)', - }, - timezone: { type: 'string', description: 'Meeting timezone' }, - duration: { type: 'number', description: 'Meeting duration in minutes' }, + description: 'Meeting details (shape aligns with Zoom Meetings webhook object fields)', + id: { type: 'number', description: 'Meeting ID' }, + uuid: { type: 'string', description: 'Meeting UUID' }, + topic: { type: 'string', description: 'Meeting topic' }, + meeting_type: { + type: 'number', + description: 'Meeting type (1=instant, 2=scheduled, etc.; maps to Zoom `type`)', + }, + host_id: { type: 'string', description: 'Host user ID' }, + host_email: { + type: 'string', + description: 'Host email address (when provided by Zoom)', + }, + start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' }, + end_time: { + type: 'string', + description: 'Meeting end time (ISO 8601, present on meeting.ended)', + }, + timezone: { type: 'string', description: 'Meeting timezone' }, + duration: { type: 'number', description: 'Meeting duration in minutes' }, + agenda: { + type: 'string', + description: 'Meeting agenda or description (when provided)', + }, + join_url: { + type: 'string', + description: 'URL for participants to join (when provided)', + }, + password: { + type: 'string', + description: 'Meeting password (when provided)', + }, + status: { + type: 'string', + description: 'Meeting status (e.g. waiting, started; when provided)', + }, + created_at: { + type: 'string', + description: 'Creation timestamp in ISO 8601 format (when provided)', }, }, }, @@ -171,26 +193,30 @@ export function buildParticipantOutputs(): Record { }, object: { type: 'object', - description: 'Meeting details', - properties: { - id: { type: 'number', description: 'Meeting ID' }, - uuid: { type: 'string', description: 'Meeting UUID' }, - topic: { type: 'string', description: 'Meeting topic' }, - host_id: { type: 'string', description: 'Host user ID' }, - participant: { - type: 'object', - description: 'Participant details', - properties: { - id: { type: 'string', description: 'Participant user ID' }, - user_id: { type: 'string', description: 'Participant user ID (16-digit)' }, - user_name: { type: 'string', description: 'Participant display name' }, - email: { type: 'string', description: 'Participant email' }, - join_time: { type: 'string', description: 'Time participant joined (ISO 8601)' }, - leave_time: { - type: 'string', - description: 'Time participant left (ISO 8601, present on participant_left)', - }, - }, + description: 'Meeting and participant details', + id: { type: 'number', description: 'Meeting ID' }, + uuid: { type: 'string', description: 'Meeting UUID' }, + topic: { type: 'string', description: 'Meeting topic' }, + host_id: { type: 'string', description: 'Host user ID' }, + join_url: { + type: 'string', + description: 'URL for participants to join (when provided)', + }, + participant: { + type: 'object', + description: 'Participant details', + id: { type: 'string', description: 'Participant identifier' }, + user_id: { type: 'string', description: 'Participant user ID (when a Zoom user)' }, + user_name: { type: 'string', description: 'Participant display name' }, + email: { type: 'string', description: 'Participant email (when available)' }, + join_time: { type: 'string', description: 'Time participant joined (ISO 8601)' }, + leave_time: { + type: 'string', + description: 'Time participant left (ISO 8601, present on participant_left)', + }, + duration: { + type: 'number', + description: 'Seconds the participant was in the meeting (when provided)', }, }, }, @@ -218,22 +244,30 @@ export function buildRecordingOutputs(): Record { }, object: { type: 'object', - description: 'Recording details', - properties: { - id: { type: 'number', description: 'Meeting ID' }, - uuid: { type: 'string', description: 'Meeting UUID' }, - topic: { type: 'string', description: 'Meeting topic' }, - host_id: { type: 'string', description: 'Host user ID' }, - host_email: { type: 'string', description: 'Host email' }, - start_time: { type: 'string', description: 'Recording start time (ISO 8601)' }, - duration: { type: 'number', description: 'Recording duration in minutes' }, - total_size: { type: 'number', description: 'Total recording size in bytes' }, - recording_count: { type: 'number', description: 'Number of recording files' }, - share_url: { type: 'string', description: 'URL to share the recording' }, - recording_files: { - type: 'json', - description: 'Array of recording file objects with download URLs', - }, + description: 'Cloud recording details (aligns with Zoom cloud recording objects)', + id: { type: 'number', description: 'Meeting ID' }, + uuid: { type: 'string', description: 'Meeting UUID' }, + topic: { type: 'string', description: 'Meeting topic' }, + meeting_type: { + type: 'number', + description: 'Meeting type (when provided; maps to Zoom `type`)', + }, + host_id: { type: 'string', description: 'Host user ID' }, + host_email: { type: 'string', description: 'Host email' }, + start_time: { type: 'string', description: 'Recording start time (ISO 8601)' }, + timezone: { type: 'string', description: 'Meeting timezone (when provided)' }, + agenda: { + type: 'string', + description: 'Meeting agenda (when provided)', + }, + duration: { type: 'number', description: 'Recording duration in minutes' }, + total_size: { type: 'number', description: 'Total recording size in bytes' }, + recording_count: { type: 'number', description: 'Number of recording files' }, + share_url: { type: 'string', description: 'URL to share the recording' }, + recording_files: { + type: 'json', + description: + 'Array of recording file objects (e.g. id, file_type, play_url, download_url) per Zoom cloud recording payloads', }, }, }, From 0600c90f1cdcc25d761b02d5cc1bb30adb13c656 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:46:30 -0700 Subject: [PATCH 18/23] feat(triggers): enrich Salesforce and Linear webhook output schemas Salesforce: expose simEventType alongside eventType; pass OwnerId and SystemModstamp on record lifecycle inputs; add AccountId/OwnerId for Opportunity and AccountId/ContactId/OwnerId for Case. Align trigger output docs with Flow JSON payloads and formatInput. Linear: document actor email and profile url per official webhook payload; add Comment data.edited from Linear's sample payload. Tests: extend Salesforce formatInput coverage for new fields. --- .../lib/webhooks/providers/salesforce.test.ts | 7 ++++ apps/sim/lib/webhooks/providers/salesforce.ts | 18 ++++++++++ apps/sim/triggers/linear/utils.ts | 16 +++++++-- apps/sim/triggers/salesforce/utils.ts | 34 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/salesforce.test.ts b/apps/sim/lib/webhooks/providers/salesforce.test.ts index 1be71dc5912..5865fdcb8e0 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.test.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.test.ts @@ -78,9 +78,12 @@ describe('Salesforce webhook provider', () => { const { input } = await salesforceHandler.formatInput!({ body: { eventType: 'created', + simEventType: 'after_insert', objectType: 'Lead', Id: '00Q1', Name: 'Test', + OwnerId: '005OWNER', + SystemModstamp: '2024-01-01T00:00:00.000Z', }, headers: {}, requestId: 't4', @@ -89,8 +92,12 @@ describe('Salesforce webhook provider', () => { }) const i = input as Record expect(i.eventType).toBe('created') + expect(i.simEventType).toBe('after_insert') expect(i.objectType).toBe('Lead') expect(i.recordId).toBe('00Q1') + const rec = i.record as Record + expect(rec.OwnerId).toBe('005OWNER') + expect(rec.SystemModstamp).toBe('2024-01-01T00:00:00.000Z') }) it('extractIdempotencyId includes record id', () => { diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 6f52de23277..6c3520d4e9a 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -96,6 +96,11 @@ function pickRecordId(body: Record, record: Record, key: string): string { + const v = record[key] + return typeof v === 'string' ? v : '' +} + export const salesforceHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { const secret = providerConfig.webhookSecret as string | undefined @@ -157,6 +162,7 @@ export const salesforceHandler: WebhookProviderHandler = { (typeof body.eventType === 'string' && body.eventType) || (typeof body.simEventType === 'string' && body.simEventType) || '' + const simEventTypeRaw = typeof body.simEventType === 'string' ? body.simEventType : '' if (id === 'salesforce_webhook') { return { @@ -165,6 +171,7 @@ export const salesforceHandler: WebhookProviderHandler = { objectType: objectType || '', recordId, timestamp, + simEventType: simEventTypeRaw, record: Object.keys(record).length > 0 ? record : body, payload: ctx.body, }, @@ -183,12 +190,15 @@ export const salesforceHandler: WebhookProviderHandler = { objectType: objectType || '', recordId, timestamp, + simEventType: simEventTypeRaw, record: { Id: typeof record.Id === 'string' ? record.Id : recordId, Name: typeof record.Name === 'string' ? record.Name : '', CreatedDate: typeof record.CreatedDate === 'string' ? record.CreatedDate : '', LastModifiedDate: typeof record.LastModifiedDate === 'string' ? record.LastModifiedDate : '', + OwnerId: pickStr(record, 'OwnerId'), + SystemModstamp: pickStr(record, 'SystemModstamp'), }, changedFields: changedFields !== undefined ? changedFields : null, payload: ctx.body, @@ -203,6 +213,7 @@ export const salesforceHandler: WebhookProviderHandler = { objectType: objectType || 'Opportunity', recordId, timestamp, + simEventType: simEventTypeRaw, record: { Id: typeof record.Id === 'string' ? record.Id : recordId, Name: typeof record.Name === 'string' ? record.Name : '', @@ -210,6 +221,8 @@ export const salesforceHandler: WebhookProviderHandler = { Amount: record.Amount !== undefined ? String(record.Amount) : '', CloseDate: typeof record.CloseDate === 'string' ? record.CloseDate : '', Probability: record.Probability !== undefined ? String(record.Probability) : '', + AccountId: pickStr(record, 'AccountId'), + OwnerId: pickStr(record, 'OwnerId'), }, previousStage: typeof body.previousStage === 'string' @@ -235,12 +248,16 @@ export const salesforceHandler: WebhookProviderHandler = { objectType: objectType || 'Case', recordId, timestamp, + simEventType: simEventTypeRaw, record: { Id: typeof record.Id === 'string' ? record.Id : recordId, Subject: typeof record.Subject === 'string' ? record.Subject : '', Status: typeof record.Status === 'string' ? record.Status : '', Priority: typeof record.Priority === 'string' ? record.Priority : '', CaseNumber: typeof record.CaseNumber === 'string' ? record.CaseNumber : '', + AccountId: pickStr(record, 'AccountId'), + ContactId: pickStr(record, 'ContactId'), + OwnerId: pickStr(record, 'OwnerId'), }, previousStatus: typeof body.previousStatus === 'string' @@ -265,6 +282,7 @@ export const salesforceHandler: WebhookProviderHandler = { objectType: objectType || '', recordId, timestamp, + simEventType: simEventTypeRaw, record: Object.keys(record).length > 0 ? record : body, payload: ctx.body, }, diff --git a/apps/sim/triggers/linear/utils.ts b/apps/sim/triggers/linear/utils.ts index a4e0df41383..de9f9122275 100644 --- a/apps/sim/triggers/linear/utils.ts +++ b/apps/sim/triggers/linear/utils.ts @@ -194,8 +194,8 @@ export function buildLinearV2SubBlocks(options: { } /** - * Shared user/actor output schema - * Note: Linear webhooks only include id, name, and type in actor objects + * Shared user/actor output schema (Linear data-change webhook `actor` object). + * @see https://linear.app/developers/webhooks — actor may be a User, OauthClient, or Integration; `type` is mapped to `actorType` (TriggerOutput reserves nested `type` for field kinds). */ export const userOutputs = { id: { @@ -211,6 +211,14 @@ export const userOutputs = { type: 'string', description: 'Actor type from Linear (e.g. user, OauthClient, Integration)', }, + email: { + type: 'string', + description: 'Actor email (present for user actors in Linear webhook payloads)', + }, + url: { + type: 'string', + description: 'Actor profile URL in Linear (distinct from the top-level subject entity `url`)', + }, } as const /** @@ -495,6 +503,10 @@ export function buildCommentOutputs(): Record { type: 'string', description: 'Comment body text', }, + edited: { + type: 'boolean', + description: 'Whether the comment body has been edited (Linear webhook payload field)', + }, url: { type: 'string', description: 'Comment URL', diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index e0c752a598e..f1bafa3b214 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -234,6 +234,12 @@ export function buildSalesforceRecordOutputs(): Record { type: 'string', description: 'The type of event (e.g., created, updated, deleted)', }, + /** Present when the Flow JSON body uses `simEventType` instead of or in addition to `eventType`. */ + simEventType: { + type: 'string', + description: + 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', + }, objectType: { type: 'string', description: 'Salesforce object type (e.g., Account, Contact, Lead)', @@ -245,6 +251,14 @@ export function buildSalesforceRecordOutputs(): Record { Name: { type: 'string', description: 'Record name' }, CreatedDate: { type: 'string', description: 'Record creation date' }, LastModifiedDate: { type: 'string', description: 'Last modification date' }, + OwnerId: { + type: 'string', + description: 'Record owner ID (standard field when sent in the Flow body)', + }, + SystemModstamp: { + type: 'string', + description: 'System modstamp from the record (ISO 8601) when included in the payload', + }, }, changedFields: { type: 'json', description: 'Fields that were changed (for update events)' }, payload: { type: 'json', description: 'Full webhook payload' }, @@ -257,6 +271,11 @@ export function buildSalesforceRecordOutputs(): Record { export function buildSalesforceOpportunityStageOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event' }, + simEventType: { + type: 'string', + description: + 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', + }, objectType: { type: 'string', description: 'Salesforce object type (Opportunity)' }, recordId: { type: 'string', description: 'Opportunity ID' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, @@ -267,6 +286,8 @@ export function buildSalesforceOpportunityStageOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event' }, + simEventType: { + type: 'string', + description: + 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', + }, objectType: { type: 'string', description: 'Salesforce object type (Case)' }, recordId: { type: 'string', description: 'Case ID' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, @@ -289,6 +315,9 @@ export function buildSalesforceCaseStatusOutputs(): Record { return { eventType: { type: 'string', description: 'The type of event' }, + simEventType: { + type: 'string', + description: + 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.', + }, objectType: { type: 'string', description: 'Salesforce object type' }, recordId: { type: 'string', description: 'ID of the affected record' }, timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, From 1cd27d8b89a442a44e28c156f0974f4925285217 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:52:47 -0700 Subject: [PATCH 19/23] remove from mdx --- apps/docs/components/ui/icon-mapping.ts | 2 +- apps/docs/content/docs/en/tools/gong.mdx | 2 - .../docs/content/docs/en/tools/greenhouse.mdx | 14 -- apps/docs/content/docs/en/tools/linear.mdx | 2 +- apps/docs/content/docs/en/tools/vercel.mdx | 15 -- apps/docs/content/docs/en/tools/zoom.mdx | 3 - .../integrations/data/icon-mapping.ts | 2 +- .../integrations/data/integrations.json | 193 ++++++++++++++---- apps/sim/scripts/check-trigger-alignment.ts | 60 ++++++ 9 files changed, 221 insertions(+), 72 deletions(-) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index b5097ba8eb3..c65d55ed9d4 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -284,7 +284,7 @@ export const blockTypeToIconMap: Record = { langsmith: LangsmithIcon, launchdarkly: LaunchDarklyIcon, lemlist: LemlistIcon, - linear: LinearIcon, + linear_v2: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, loops: LoopsIcon, diff --git a/apps/docs/content/docs/en/tools/gong.mdx b/apps/docs/content/docs/en/tools/gong.mdx index 663bb161c4f..34ef1563d6e 100644 --- a/apps/docs/content/docs/en/tools/gong.mdx +++ b/apps/docs/content/docs/en/tools/gong.mdx @@ -33,9 +33,7 @@ By combining these capabilities, you can automate sales coaching workflows, extr Integrate Gong into your workflow. Access call recordings, transcripts, user data, activity stats, scorecards, trackers, library content, coaching metrics, and more via the Gong API. -## Webhook triggers -[Gong automation rules](https://help.gong.io/docs/create-a-webhook-rule) can **Fire webhook** with a JSON body that includes `callData` (with `metaData`, `parties`, `context`, and nested `content`), plus `isTest` when sent from the Gong UI. Sim maps this to workflow input with a stable `eventType` of `gong.automation_rule`, a string `callId` from `metaData.id`, the full `callData` object, a flattened `metaData` object, and arrays at the top level for `parties`, `context`, `trackers` (from `callData.content.trackers`), `topics` (from `callData.content.topics`), and `highlights` (from `callData.content.highlights`). Metadata fields match the call objects documented under `gong_get_call` and extensive calls above (`workspaceId`, `meetingUrl`, `purpose`, `isPrivate`, `calendarEventId`, etc.) when Gong includes them. ## Tools diff --git a/apps/docs/content/docs/en/tools/greenhouse.mdx b/apps/docs/content/docs/en/tools/greenhouse.mdx index c1deb378011..a96605a0325 100644 --- a/apps/docs/content/docs/en/tools/greenhouse.mdx +++ b/apps/docs/content/docs/en/tools/greenhouse.mdx @@ -29,20 +29,6 @@ In Sim, the Greenhouse integration enables your agents to interact with your rec Integrate Greenhouse into the workflow. List and retrieve candidates, jobs, applications, users, departments, offices, and job stages from your Greenhouse ATS account. -### Triggers (webhooks) - -Sim accepts [Greenhouse webhooks](https://developers.greenhouse.io/webhooks.html) POSTed to your trigger URL. Each delivery is normalized to workflow inputs with: - -| Field | Description | -| ----- | ----------- | -| `action` | Webhook action string (e.g. `new_candidate_application`) | -| `applicationId` | `payload.application.id`, or `payload.application_id` on flat offer payloads | -| `candidateId` | `payload.application.candidate.id` when the nested candidate object is present | -| `jobId` | `payload.job.id` for job webhooks, or `payload.job_id` when provided on the payload (e.g. offers) | -| `payload` | Full JSON `payload` for the event | - -Nested objects such as `email_addresses[].type` remain inside `payload` as JSON so they do not collide with Sim’s trigger output schema rules. - ## Tools diff --git a/apps/docs/content/docs/en/tools/linear.mdx b/apps/docs/content/docs/en/tools/linear.mdx index 2e6c87677e7..9a23938192e 100644 --- a/apps/docs/content/docs/en/tools/linear.mdx +++ b/apps/docs/content/docs/en/tools/linear.mdx @@ -6,7 +6,7 @@ description: Interact with Linear issues, projects, and more import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/en/tools/vercel.mdx b/apps/docs/content/docs/en/tools/vercel.mdx index 3f4f8050e50..0baacc3f610 100644 --- a/apps/docs/content/docs/en/tools/vercel.mdx +++ b/apps/docs/content/docs/en/tools/vercel.mdx @@ -28,21 +28,6 @@ In Sim, the Vercel integration lets your agents programmatically manage deployme Integrate with Vercel to manage deployments, projects, domains, DNS records, environment variables, aliases, edge configs, teams, and more. -### Triggers (webhooks) - -Sim can subscribe to [Vercel webhooks](https://vercel.com/docs/webhooks/webhooks-api). Incoming events are normalized to workflow inputs that mirror the documented webhook body: - -| Field | Description | -| ----- | ----------- | -| `type`, `id`, `createdAt`, `region` | Top-level delivery metadata from the webhook JSON | -| `payload` | Full `payload` object for the event | -| `links.deployment`, `links.project` | Dashboard URLs when Vercel includes `payload.links` | -| `regions` | `payload.regions` list when present (e.g. deployment events) | -| `deployment` | `id`, `url`, `name`, and `meta` (metadata map) when `payload.deployment` exists | -| `project`, `team`, `user` | Normalized ids (and project name) from the payload when present | -| `target`, `plan` | From `payload.target` and `payload.plan` when present | -| `domain` | `name` and `delegated` when `payload.domain` exists (e.g. `domain.created`) | - ## Tools diff --git a/apps/docs/content/docs/en/tools/zoom.mdx b/apps/docs/content/docs/en/tools/zoom.mdx index 1b77909fb09..4926fd24973 100644 --- a/apps/docs/content/docs/en/tools/zoom.mdx +++ b/apps/docs/content/docs/en/tools/zoom.mdx @@ -417,7 +417,4 @@ List participants from a past Zoom meeting | ↳ `totalRecords` | number | Total number of records | | ↳ `nextPageToken` | string | Token for next page of results | -## Webhook triggers - -Zoom **Meeting** and **Recording** webhook payloads use the same top-level envelope Sim exposes to workflows: `event` (string), `event_ts` (number, ms), and `payload` with `account_id` and an `object` whose fields depend on the event. Field names align with Zoom’s Meetings and cloud recording objects (for example meeting `id`, `uuid`, `topic`, `host_id`, `start_time`, `timezone`, `duration`, `join_url`, and recording `recording_files`). See [Zoom webhooks](https://developers.zoom.us/docs/api/webhooks/) and [Meeting webhooks](https://developers.zoom.us/docs/api/meetings/events/) for the authoritative event list; participant events nest a `participant` object alongside meeting fields. diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 603fecd3633..503242d8c1e 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -284,7 +284,7 @@ export const blockTypeToIconMap: Record = { langsmith: LangsmithIcon, launchdarkly: LaunchDarklyIcon, lemlist: LemlistIcon, - linear: LinearIcon, + linear_v2: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, loops: LoopsIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 3115586f033..9a89658f1ff 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -5264,8 +5264,49 @@ } ], "operationCount": 11, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "greenhouse_candidate_hired", + "name": "Greenhouse Candidate Hired", + "description": "Trigger workflow when a candidate is hired" + }, + { + "id": "greenhouse_new_application", + "name": "Greenhouse New Application", + "description": "Trigger workflow when a new application is submitted" + }, + { + "id": "greenhouse_candidate_stage_change", + "name": "Greenhouse Candidate Stage Change", + "description": "Trigger workflow when a candidate changes interview stages" + }, + { + "id": "greenhouse_candidate_rejected", + "name": "Greenhouse Candidate Rejected", + "description": "Trigger workflow when a candidate is rejected" + }, + { + "id": "greenhouse_offer_created", + "name": "Greenhouse Offer Created", + "description": "Trigger workflow when a new offer is created" + }, + { + "id": "greenhouse_job_created", + "name": "Greenhouse Job Created", + "description": "Trigger workflow when a new job is created" + }, + { + "id": "greenhouse_job_updated", + "name": "Greenhouse Job Updated", + "description": "Trigger workflow when a job is updated" + }, + { + "id": "greenhouse_webhook", + "name": "Greenhouse Webhook (Endpoint Events)", + "description": "Trigger on whichever event types you select for this URL in Greenhouse. Sim does not filter deliveries for this trigger." + } + ], + "triggerCount": 8, "authType": "api-key", "category": "tools", "integrationType": "hr", @@ -6818,7 +6859,7 @@ "tags": ["sales-engagement", "email-marketing", "automation"] }, { - "type": "linear", + "type": "linear_v2", "slug": "linear", "name": "Linear", "description": "Interact with Linear issues, projects, and more", @@ -7143,79 +7184,79 @@ "operationCount": 78, "triggers": [ { - "id": "linear_issue_created", + "id": "linear_issue_created_v2", "name": "Linear Issue Created", "description": "Trigger workflow when a new issue is created in Linear" }, { - "id": "linear_issue_updated", + "id": "linear_issue_updated_v2", "name": "Linear Issue Updated", "description": "Trigger workflow when an issue is updated in Linear" }, { - "id": "linear_issue_removed", + "id": "linear_issue_removed_v2", "name": "Linear Issue Removed", "description": "Trigger workflow when an issue is removed/deleted in Linear" }, { - "id": "linear_comment_created", + "id": "linear_comment_created_v2", "name": "Linear Comment Created", "description": "Trigger workflow when a new comment is created in Linear" }, { - "id": "linear_comment_updated", + "id": "linear_comment_updated_v2", "name": "Linear Comment Updated", "description": "Trigger workflow when a comment is updated in Linear" }, { - "id": "linear_project_created", + "id": "linear_project_created_v2", "name": "Linear Project Created", "description": "Trigger workflow when a new project is created in Linear" }, { - "id": "linear_project_updated", + "id": "linear_project_updated_v2", "name": "Linear Project Updated", "description": "Trigger workflow when a project is updated in Linear" }, { - "id": "linear_cycle_created", + "id": "linear_cycle_created_v2", "name": "Linear Cycle Created", "description": "Trigger workflow when a new cycle is created in Linear" }, { - "id": "linear_cycle_updated", + "id": "linear_cycle_updated_v2", "name": "Linear Cycle Updated", "description": "Trigger workflow when a cycle is updated in Linear" }, { - "id": "linear_label_created", + "id": "linear_label_created_v2", "name": "Linear Label Created", "description": "Trigger workflow when a new label is created in Linear" }, { - "id": "linear_label_updated", + "id": "linear_label_updated_v2", "name": "Linear Label Updated", "description": "Trigger workflow when a label is updated in Linear" }, { - "id": "linear_project_update_created", + "id": "linear_project_update_created_v2", "name": "Linear Project Update Created", "description": "Trigger workflow when a new project update is posted in Linear" }, { - "id": "linear_customer_request_created", + "id": "linear_customer_request_created_v2", "name": "Linear Customer Request Created", "description": "Trigger workflow when a new customer request is created in Linear" }, { - "id": "linear_customer_request_updated", + "id": "linear_customer_request_updated_v2", "name": "Linear Customer Request Updated", "description": "Trigger workflow when a customer request is updated in Linear" }, { - "id": "linear_webhook", + "id": "linear_webhook_v2", "name": "Linear Webhook", - "description": "Trigger workflow from any Linear webhook event" + "description": "Trigger workflow from Linear data-change events included in this webhook subscription (Issues, Comments, Projects, etc.—not every Linear model)." } ], "triggerCount": 15, @@ -9561,8 +9602,49 @@ } ], "operationCount": 8, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "resend_email_sent", + "name": "Resend Email Sent", + "description": "Trigger workflow when an email is sent" + }, + { + "id": "resend_email_delivered", + "name": "Resend Email Delivered", + "description": "Trigger workflow when an email is delivered" + }, + { + "id": "resend_email_bounced", + "name": "Resend Email Bounced", + "description": "Trigger workflow when an email bounces" + }, + { + "id": "resend_email_complained", + "name": "Resend Email Complained", + "description": "Trigger workflow when an email is marked as spam" + }, + { + "id": "resend_email_opened", + "name": "Resend Email Opened", + "description": "Trigger workflow when an email is opened" + }, + { + "id": "resend_email_clicked", + "name": "Resend Email Clicked", + "description": "Trigger workflow when a link in an email is clicked" + }, + { + "id": "resend_email_failed", + "name": "Resend Email Failed", + "description": "Trigger workflow when an email fails to send" + }, + { + "id": "resend_webhook", + "name": "Resend Webhook (All Events)", + "description": "Trigger on Resend webhook events we subscribe to (email lifecycle, contacts, domains—see Resend docs). Flattened email fields may be null for non-email events; use data for the full payload." + } + ], + "triggerCount": 8, "authType": "none", "category": "tools", "integrationType": "email", @@ -12129,8 +12211,49 @@ } ], "operationCount": 50, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "vercel_deployment_created", + "name": "Vercel Deployment Created", + "description": "Trigger workflow when a new deployment is created" + }, + { + "id": "vercel_deployment_ready", + "name": "Vercel Deployment Ready", + "description": "Trigger workflow when a deployment is ready to serve traffic" + }, + { + "id": "vercel_deployment_error", + "name": "Vercel Deployment Error", + "description": "Trigger workflow when a deployment fails" + }, + { + "id": "vercel_deployment_canceled", + "name": "Vercel Deployment Canceled", + "description": "Trigger workflow when a deployment is canceled" + }, + { + "id": "vercel_project_created", + "name": "Vercel Project Created", + "description": "Trigger workflow when a new project is created" + }, + { + "id": "vercel_project_removed", + "name": "Vercel Project Removed", + "description": "Trigger workflow when a project is removed" + }, + { + "id": "vercel_domain_created", + "name": "Vercel Domain Created", + "description": "Trigger workflow when a domain is created" + }, + { + "id": "vercel_webhook", + "name": "Vercel Webhook (Common Events)", + "description": "Trigger on a curated set of common Vercel events (deployments, projects, domains, edge config). Pick a specific trigger to listen to one event type only." + } + ], + "triggerCount": 8, "authType": "api-key", "category": "tools", "integrationType": "developer-tools", @@ -12935,33 +13058,33 @@ "triggers": [ { "id": "zoom_meeting_started", - "name": "Meeting Started", - "description": "Triggered when a Zoom meeting starts" + "name": "Zoom Meeting Started", + "description": "Trigger workflow when a Zoom meeting starts" }, { "id": "zoom_meeting_ended", - "name": "Meeting Ended", - "description": "Triggered when a Zoom meeting ends" + "name": "Zoom Meeting Ended", + "description": "Trigger workflow when a Zoom meeting ends" }, { "id": "zoom_participant_joined", - "name": "Participant Joined", - "description": "Triggered when a participant joins a Zoom meeting" + "name": "Zoom Participant Joined", + "description": "Trigger workflow when a participant joins a Zoom meeting" }, { "id": "zoom_participant_left", - "name": "Participant Left", - "description": "Triggered when a participant leaves a Zoom meeting" + "name": "Zoom Participant Left", + "description": "Trigger workflow when a participant leaves a Zoom meeting" }, { "id": "zoom_recording_completed", - "name": "Recording Completed", - "description": "Triggered when a Zoom cloud recording is completed" + "name": "Zoom Recording Completed", + "description": "Trigger workflow when a Zoom cloud recording is completed" }, { "id": "zoom_webhook", - "name": "Generic Webhook", - "description": "Triggered on any Zoom webhook event" + "name": "Zoom Webhook (All Events)", + "description": "Trigger workflow on any Zoom webhook event" } ], "triggerCount": 6, diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts index c2d1f5625c1..f84ebf03a0a 100644 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -257,6 +257,66 @@ const PROVIDER_CHECKS: Record = { formatInputKeys: Object.keys(input).sort(), } }, + hubspot: async () => { + const { buildWebhookOutputs } = await import('@/triggers/hubspot/utils') + const { hubspotHandler } = await import('@/lib/webhooks/providers/hubspot') + const outputs = buildWebhookOutputs() as Record + const sampleBody = [ + { + objectId: 123, + subscriptionType: 'contact.creation', + portalId: 456, + occurredAt: 1700000000000, + attemptNumber: 0, + eventId: 789, + changeSource: 'CRM', + }, + ] + const result = await hubspotHandler.formatInput!({ + webhook: { providerConfig: { triggerId: 'hubspot_webhook' } }, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: sampleBody as unknown as Record, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildWebhookOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, + intercom: async () => { + const { buildIntercomGenericOutputs } = await import('@/triggers/intercom/utils') + const { intercomHandler } = await import('@/lib/webhooks/providers/intercom') + const outputs = buildIntercomGenericOutputs() as Record + const result = await intercomHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + topic: 'contact.created', + id: 'notif_123', + app_id: 'app_123', + created_at: 1700000000, + delivery_attempts: 1, + first_sent_at: 1700000000, + data: { + item: { + id: 'contact_1', + email: 'ada@example.com', + }, + }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'buildIntercomGenericOutputs()', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, } const provider = process.argv[2]?.trim() From e0580f7292056e2af9ed25067204bdfcd07f3c0f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 19:56:23 -0700 Subject: [PATCH 20/23] chore(webhooks): expand trigger alignment coverage Extend the trigger alignment checker to cover additional webhook providers so output contracts are verified across more of the recently added trigger surface. Made-with: Cursor --- apps/sim/scripts/check-trigger-alignment.ts | 170 ++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts index f84ebf03a0a..7efd6c53231 100644 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ b/apps/sim/scripts/check-trigger-alignment.ts @@ -317,6 +317,176 @@ const PROVIDER_CHECKS: Record = { formatInputKeys: Object.keys(input).sort(), } }, + github: async () => { + const { repositoryOutputs, userOutputs } = await import('@/triggers/github/utils') + const { githubHandler } = await import('@/lib/webhooks/providers/github') + + const outputs = { + action: { type: 'string', description: 'GitHub action' }, + event_type: { type: 'string', description: 'GitHub event type' }, + branch: { type: 'string', description: 'Branch name' }, + repository: repositoryOutputs, + sender: userOutputs, + } as unknown as Record + + const result = await githubHandler.formatInput!({ + webhook: {}, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + action: 'opened', + ref: 'refs/heads/main', + repository: { + id: 1, + node_id: 'node_1', + name: 'sim', + full_name: 'simstudioai/sim', + private: true, + html_url: 'https://github.com/simstudioai/sim', + description: 'Repo', + fork: false, + url: 'https://api.github.com/repos/simstudioai/sim', + homepage: null, + size: 123, + stargazers_count: 10, + watchers_count: 10, + language: 'TypeScript', + forks_count: 2, + open_issues_count: 3, + default_branch: 'main', + owner: { + login: 'simstudioai', + id: 1, + avatar_url: 'https://avatars.githubusercontent.com/u/1', + html_url: 'https://github.com/simstudioai', + }, + }, + sender: { + login: 'octocat', + id: 2, + node_id: 'node_2', + avatar_url: 'https://avatars.githubusercontent.com/u/2', + html_url: 'https://github.com/octocat', + type: 'User', + }, + }, + headers: { 'x-github-event': 'issues' }, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'github issue-shaped outputs', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, + slack: async () => { + const { slackHandler } = await import('@/lib/webhooks/providers/slack') + + const outputs = { + event: { + type: 'object', + description: 'Slack event data', + properties: { + event_type: { type: 'string', description: 'Slack event type' }, + channel: { type: 'string', description: 'Channel ID' }, + channel_name: { type: 'string', description: 'Channel name' }, + user: { type: 'string', description: 'User ID' }, + user_name: { type: 'string', description: 'Username' }, + text: { type: 'string', description: 'Message text' }, + timestamp: { type: 'string', description: 'Message timestamp' }, + thread_ts: { type: 'string', description: 'Thread timestamp' }, + team_id: { type: 'string', description: 'Workspace ID' }, + event_id: { type: 'string', description: 'Event ID' }, + reaction: { type: 'string', description: 'Reaction name' }, + item_user: { type: 'string', description: 'Original message author' }, + hasFiles: { type: 'boolean', description: 'Whether files are attached' }, + files: { type: 'file[]', description: 'Downloaded files' }, + }, + }, + } as Record + + const result = await slackHandler.formatInput!({ + webhook: { providerConfig: {} }, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + team_id: 'T123', + event_id: 'Ev123', + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: 'hello', + ts: '1700000000.0001', + thread_ts: '1700000000.0001', + }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'slackWebhookTrigger.outputs', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, + typeform: async () => { + const { typeformHandler } = await import('@/lib/webhooks/providers/typeform') + + const outputs = { + event_id: { type: 'string', description: 'Unique event identifier' }, + event_type: { type: 'string', description: 'Typeform event type' }, + form_id: { type: 'string', description: 'Form ID' }, + token: { type: 'string', description: 'Submission token' }, + submitted_at: { type: 'string', description: 'Submission timestamp' }, + landed_at: { type: 'string', description: 'Landing timestamp' }, + calculated: { + type: 'object', + description: 'Calculated values', + properties: { score: { type: 'number', description: 'Score' } }, + }, + variables: { type: 'array', description: 'Variables' }, + hidden: { type: 'object', description: 'Hidden fields' }, + answers: { type: 'array', description: 'Answers' }, + ending: { + type: 'object', + description: 'Ending', + properties: { + id: { type: 'string', description: 'Ending id' }, + ref: { type: 'string', description: 'Ending ref' }, + }, + }, + raw: { type: 'object', description: 'Raw payload' }, + } as Record + + const result = await typeformHandler.formatInput!({ + webhook: { providerConfig: { includeDefinition: false } }, + workflow: { id: 'check-alignment', userId: 'check-alignment' }, + body: { + event_id: 'evt_1', + event_type: 'form_response', + form_response: { + form_id: 'form_1', + token: 'token_1', + submitted_at: '2026-01-01T00:00:00Z', + landed_at: '2026-01-01T00:00:00Z', + calculated: { score: 5 }, + variables: [], + hidden: {}, + answers: [], + ending: { id: 'ending_1', ref: 'end_ref' }, + }, + }, + headers: {}, + requestId: 'check-trigger-alignment', + }) + const input = result.input as Record + return { + referenceLabel: 'typeformWebhookTrigger.outputs', + outputKeys: Object.keys(outputs).sort(), + formatInputKeys: Object.keys(input).sort(), + } + }, } const provider = process.argv[2]?.trim() From 3e7a0466e50e7cc0e5e979efdd812826ed274a43 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 20:12:03 -0700 Subject: [PATCH 21/23] updated skills --- .agents/skills/add-trigger/SKILL.md | 667 +++++------------- .agents/skills/validate-trigger/SKILL.md | 212 ++++++ .claude/commands/add-trigger.md | 723 ++++---------------- .claude/commands/validate-trigger.md | 212 ++++++ apps/sim/scripts/check-trigger-alignment.ts | 527 -------------- 5 files changed, 709 insertions(+), 1632 deletions(-) create mode 100644 .agents/skills/validate-trigger/SKILL.md create mode 100644 .claude/commands/validate-trigger.md delete mode 100644 apps/sim/scripts/check-trigger-alignment.ts diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index 26e828a74b5..dd93d11c20d 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -3,63 +3,56 @@ name: add-trigger description: Create or update Sim webhook triggers using the generic trigger builder, service-specific setup instructions, outputs, and registry wiring. Use when working in `apps/sim/triggers/{service}/` or adding webhook support to an integration. --- -# Add Trigger Skill +# Add Trigger You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. ## Your Task -When the user asks you to create triggers for a service: 1. Research what webhook events the service supports 2. Create the trigger files using the generic builder -3. Register triggers and connect them to the block +3. Create a provider handler if custom auth, formatting, or subscriptions are needed +4. Register triggers and connect them to the block ## Directory Structure ``` apps/sim/triggers/{service}/ ├── index.ts # Barrel exports -├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields) +├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs) ├── {event_a}.ts # Primary trigger (includes dropdown) ├── {event_b}.ts # Secondary trigger (no dropdown) -├── {event_c}.ts # Secondary trigger (no dropdown) └── webhook.ts # Generic webhook trigger (optional, for "all events") + +apps/sim/lib/webhooks/providers/ +├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) +├── types.ts # WebhookProviderHandler interface +├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) +└── registry.ts # Handler map + default handler ``` -## Step 1: Create utils.ts +## Step 1: Create `utils.ts` -This file contains service-specific helpers used by all triggers. +This file contains all service-specific helpers used by triggers. ```typescript import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' -/** - * Dropdown options for the trigger type selector. - * These appear in the primary trigger's dropdown. - */ export const {service}TriggerOptions = [ { label: 'Event A', id: '{service}_event_a' }, { label: 'Event B', id: '{service}_event_b' }, - { label: 'Event C', id: '{service}_event_c' }, - { label: 'Generic Webhook (All Events)', id: '{service}_webhook' }, ] -/** - * Generates HTML setup instructions for the trigger. - * Displayed to users to help them configure webhooks in the external service. - */ export function {service}SetupInstructions(eventType: string): string { const instructions = [ 'Copy the Webhook URL above', 'Go to {Service} Settings > Webhooks', - 'Click Add Webhook', - 'Paste the webhook URL', `Select the ${eventType} event type`, - 'Save the webhook configuration', + 'Paste the webhook URL and save', 'Click "Save" above to activate your trigger', ] - return instructions .map((instruction, index) => `
${index + 1}. ${instruction}
` @@ -67,10 +60,6 @@ export function {service}SetupInstructions(eventType: string): string { .join('') } -/** - * Service-specific extra fields to add to triggers. - * These are inserted between webhookUrl and triggerSave. - */ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { return [ { @@ -78,53 +67,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { title: 'Project ID (Optional)', type: 'short-input', placeholder: 'Leave empty for all projects', - description: 'Optionally filter to a specific project', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, ] } -/** - * Build outputs for this trigger type. - * Outputs define what data is available to downstream blocks. - */ export function build{Service}Outputs(): Record { return { - eventType: { type: 'string', description: 'The type of event that triggered this workflow' }, + eventType: { type: 'string', description: 'The type of event' }, resourceId: { type: 'string', description: 'ID of the affected resource' }, - timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, - // Nested outputs for complex data resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, - status: { type: 'string', description: 'Current status' }, }, - webhook: { type: 'json', description: 'Full webhook payload' }, } } ``` -## Step 2: Create the Primary Trigger +## Step 2: Create Trigger Files -The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types. +**Primary trigger** — MUST include `includeDropdown: true`: ```typescript import { {Service}Icon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' +import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' import type { TriggerConfig } from '@/triggers/types' -/** - * {Service} Event A Trigger - * - * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. - */ export const {service}EventATrigger: TriggerConfig = { id: '{service}_event_a', name: '{Service} Event A', @@ -132,496 +102,222 @@ export const {service}EventATrigger: TriggerConfig = { description: 'Trigger workflow when Event A occurs', version: '1.0.0', icon: {Service}Icon, - subBlocks: buildTriggerSubBlocks({ triggerId: '{service}_event_a', triggerOptions: {service}TriggerOptions, - includeDropdown: true, // PRIMARY TRIGGER - includes dropdown + includeDropdown: true, setupInstructions: {service}SetupInstructions('Event A'), extraFields: build{Service}ExtraFields('{service}_event_a'), }), - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, } ``` -## Step 3: Create Secondary Triggers - -Secondary triggers do NOT include the dropdown (it's already in the primary trigger). +**Secondary triggers** — NO `includeDropdown` (it's already in the primary): ```typescript -import { {Service}Icon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' -import type { TriggerConfig } from '@/triggers/types' - -/** - * {Service} Event B Trigger - */ export const {service}EventBTrigger: TriggerConfig = { - id: '{service}_event_b', - name: '{Service} Event B', - provider: '{service}', - description: 'Trigger workflow when Event B occurs', - version: '1.0.0', - icon: {Service}Icon, - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_b', - triggerOptions: {service}TriggerOptions, - // NO includeDropdown - secondary trigger - setupInstructions: {service}SetupInstructions('Event B'), - extraFields: build{Service}ExtraFields('{service}_event_b'), - }), - - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + // Same as above but: id: '{service}_event_b', no includeDropdown } ``` -## Step 4: Create index.ts Barrel Export +## Step 3: Register and Wire + +### `apps/sim/triggers/{service}/index.ts` ```typescript export { {service}EventATrigger } from './event_a' export { {service}EventBTrigger } from './event_b' -export { {service}EventCTrigger } from './event_c' -export { {service}WebhookTrigger } from './webhook' ``` -## Step 5: Register Triggers - -### Trigger Registry (`apps/sim/triggers/registry.ts`) +### `apps/sim/triggers/registry.ts` ```typescript -// Add import -import { - {service}EventATrigger, - {service}EventBTrigger, - {service}EventCTrigger, - {service}WebhookTrigger, -} from '@/triggers/{service}' - -// Add to TRIGGER_REGISTRY +import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' + export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing triggers ... + // ... existing ... {service}_event_a: {service}EventATrigger, {service}_event_b: {service}EventBTrigger, - {service}_event_c: {service}EventCTrigger, - {service}_webhook: {service}WebhookTrigger, } ``` -## Step 6: Connect Triggers to Block - -In the block file (`apps/sim/blocks/blocks/{service}.ts`): +### Block file (`apps/sim/blocks/blocks/{service}.ts`) ```typescript -import { {Service}Icon } from '@/components/icons' import { getTrigger } from '@/triggers' -import type { BlockConfig } from '@/blocks/types' export const {Service}Block: BlockConfig = { - type: '{service}', - name: '{Service}', - // ... other config ... - - // Enable triggers and list available trigger IDs + // ... triggers: { enabled: true, - available: [ - '{service}_event_a', - '{service}_event_b', - '{service}_event_c', - '{service}_webhook', - ], + available: ['{service}_event_a', '{service}_event_b'], }, - subBlocks: [ - // Regular tool subBlocks first - { id: 'operation', /* ... */ }, - { id: 'credential', /* ... */ }, - // ... other tool fields ... - - // Then spread ALL trigger subBlocks + // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, - ...getTrigger('{service}_event_c').subBlocks, - ...getTrigger('{service}_webhook').subBlocks, ], - - // ... tools config ... } ``` -## Automatic Webhook Registration (Preferred) - -If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. +## Provider Handler -### When to Use Automatic Registration +All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. -Check the service's API documentation for endpoints like: -- `POST /webhooks` or `POST /hooks` - Create webhook -- `DELETE /webhooks/{id}` - Delete webhook +### When to Create a Handler -Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc. +| Behavior | Method | Examples | +|---|---|---| +| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | +| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | +| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot | +| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams | -### Implementation Steps +If none apply, you don't need a handler. The default handler provides bearer token auth. -#### 1. Add API Key to Extra Fields - -Update your `build{Service}ExtraFields` function to include an API key field: +### Example Handler ```typescript -export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your {Service} API key', - description: 'Required to create the webhook in {Service}.', - password: true, - required: true, - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - // Other optional fields (e.g., campaign filter, project filter) - { - id: 'projectId', - title: 'Project ID (Optional)', - type: 'short-input', - placeholder: 'Leave empty for all projects', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) } -``` - -#### 2. Update Setup Instructions for Automatic Creation - -Change instructions to indicate automatic webhook creation: - -```typescript -export function {service}SetupInstructions(eventType: string): string { - const instructions = [ - 'Enter your {Service} API Key above.', - 'You can find your API key in {Service} at Settings > API.', - `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`, - 'The webhook will be automatically deleted when you remove this trigger.', - ] - return instructions - .map((instruction, index) => - `
${index + 1}. ${instruction}
` - ) - .join('') -} -``` - -#### 3. Add Webhook Creation to API Route - -In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: - -```typescript -// --- {Service} specific logic --- -if (savedWebhook && provider === '{service}') { - logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`) - try { - const result = await create{Service}WebhookSubscription( - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) - - if (result) { - // Update the webhook record with the external webhook ID - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: result.id, - } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created {Service} webhook`, { - externalHookId: result.id, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in {Service}', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } -} -// --- End {Service} specific logic --- -``` - -Then add the helper function at the end of the file: - -```typescript -async function create{Service}WebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, projectId } = providerConfig || {} - - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } - - // Map trigger IDs to service event types - const eventTypeMap: Record = { - {service}_event_a: 'eventA', - {service}_event_b: 'eventB', - {service}_webhook: undefined, // Generic - no filter - } - - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const requestBody: Record = { - url: notificationUrl, - } - - if (eventType) { - requestBody.eventType = eventType - } +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), - if (projectId) { - requestBody.projectId = projectId + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId && triggerId !== '{service}_webhook') { + const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') + if (!is{Service}EventMatch(triggerId, body as Record)) return false } + return true + }, - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + resource: b.data, }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await response.json() - - if (!response.ok) { - const errorMessage = responseBody.message || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' - - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) } + }, - return { id: responseBody.id } - } catch (error: any) { - logger.error(`Exception during {Service} webhook creation`, { error: error.message }) - throw error - } + extractIdempotencyId(body: unknown) { + const obj = body as Record + return obj.id && obj.type ? `${obj.type}:${obj.id}` : null + }, } ``` -#### 4. Add Webhook Deletion to Provider Subscriptions - -In `apps/sim/lib/webhooks/provider-subscriptions.ts`: +### Register the Handler -1. Add a logger: -```typescript -const {service}Logger = createLogger('{Service}Webhook') -``` - -2. Add the delete function: -```typescript -export async function delete{Service}Webhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey || !externalId) { - {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } - - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!response.ok && response.status !== 404) { - {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`) - } else { - {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`) - } - } catch (error) { - {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error) - } -} -``` +In `apps/sim/lib/webhooks/providers/registry.ts`: -3. Add to `cleanupExternalWebhook`: ```typescript -export async function cleanupExternalWebhook(...): Promise { - // ... existing providers ... - } else if (webhook.provider === '{service}') { - await delete{Service}Webhook(webhook, requestId) - } -} -``` - -### Key Points for Automatic Registration - -- **API Key visibility**: Always use `password: true` for API key fields -- **Error handling**: Roll back the database webhook if external creation fails -- **External ID storage**: Save the external webhook ID in `providerConfig.externalId` -- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging) -- **User-friendly errors**: Map HTTP status codes to helpful error messages +import { {service}Handler } from '@/lib/webhooks/providers/{service}' -## The buildTriggerSubBlocks Helper - -This is the generic helper from `@/triggers` that creates consistent trigger subBlocks. - -### Function Signature - -```typescript -interface BuildTriggerSubBlocksOptions { - triggerId: string // e.g., 'service_event_a' - triggerOptions: Array<{ label: string; id: string }> // Dropdown options - includeDropdown?: boolean // true only for primary trigger - setupInstructions: string // HTML instructions - extraFields?: SubBlockConfig[] // Service-specific fields - webhookPlaceholder?: string // Custom placeholder text +const PROVIDER_HANDLERS: Record = { + // ... existing (alphabetical) ... + {service}: {service}Handler, } - -function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] ``` -### What It Creates +## Output Alignment (Critical) -The helper creates this structure: -1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector -2. **Webhook URL** - Read-only field with copy button -3. **Extra Fields** - Your service-specific fields (filters, options, etc.) -4. **Save Button** - Activates the trigger -5. **Instructions** - Setup guide for users +There are two sources of truth that **MUST be aligned**: -All fields automatically have: -- `mode: 'trigger'` - Only shown in trigger mode -- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected +1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown) +2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data -## Trigger Outputs & Webhook Input Formatting +If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover. -### Important: Two Sources of Truth +**Rules for `formatInput`:** +- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution +- No wrapper objects or duplication +- Use `null` for missing optional data -There are two related but separate concerns: +## Automatic Webhook Registration -1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`. +If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. -**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ: -- Tag dropdown shows fields that don't exist (broken variable resolution) -- Or actual data has fields not shown in dropdown (users can't discover them) - -### When to Add a formatWebhookInput Handler +```typescript +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler. +export const {service}Handler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string + if (!apiKey) throw new Error('{Service} API Key is required.') -### Adding a Handler + const res = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }), + }) -In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block: + if (!res.ok) throw new Error(`{Service} error: ${res.status}`) + const { id } = (await res.json()) as { id: string } + return { providerConfigUpdates: { externalId: id } } + }, -```typescript -if (foundWebhook.provider === '{service}') { - // Transform raw webhook body to match trigger outputs - return { - eventType: body.type, - resourceId: body.data?.id || '', - timestamp: body.created_at, - resource: body.data, - } + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const { apiKey, externalId } = config as { apiKey?: string; externalId?: string } + if (!apiKey || !externalId) return + await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }).catch(() => {}) + }, } ``` -**Key rules:** -- Return fields that match your trigger `outputs` definition exactly -- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` -- No duplication (don't spread body AND add individual fields) -- Use `null` for missing optional data, not empty objects with empty strings +**Key points:** +- Throw from `createSubscription` — orchestration rolls back the DB webhook +- Never throw from `deleteSubscription` — log non-fatally +- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig` +- Add `apiKey` field to `build{Service}ExtraFields` with `password: true` -### Verify Alignment - -Run the alignment checker: -```bash -bun run apps/sim/scripts/check-trigger-alignment.ts {service} -``` - -## Trigger Outputs +## Trigger Outputs Schema Trigger outputs use the same schema as block outputs (NOT tool outputs). -**Supported:** -- `type` and `description` for simple fields -- Nested object structure for complex data - -**NOT Supported:** -- `optional: true` (tool outputs only) -- `items` property (tool outputs only) +**Supported:** `type` + `description` for leaf fields, nested objects for complex data. +**NOT supported:** `optional: true`, `items` (those are tool-output-only features). ```typescript export function buildOutputs(): Record { return { - // Simple fields eventType: { type: 'string', description: 'Event type' }, timestamp: { type: 'string', description: 'When it occurred' }, - - // Complex data - use type: 'json' payload: { type: 'json', description: 'Full event payload' }, - - // Nested structure resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, @@ -630,79 +326,32 @@ export function buildOutputs(): Record { } ``` -## Generic Webhook Trigger Pattern - -For services with many event types, create a generic webhook that accepts all events: - -```typescript -export const {service}WebhookTrigger: TriggerConfig = { - id: '{service}_webhook', - name: '{Service} Webhook (All Events)', - // ... - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_webhook', - triggerOptions: {service}TriggerOptions, - setupInstructions: {service}SetupInstructions('All Events'), - extraFields: [ - // Event type filter (optional) - { - id: 'eventTypes', - title: 'Event Types', - type: 'dropdown', - multiSelect: true, - options: [ - { label: 'Event A', id: 'event_a' }, - { label: 'Event B', id: 'event_b' }, - ], - placeholder: 'Leave empty for all events', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: '{service}_webhook' }, - }, - // Plus any other service-specific fields - ...build{Service}ExtraFields('{service}_webhook'), - ], - }), -} -``` - -## Checklist Before Finishing - -### Utils -- [ ] Created `{service}TriggerOptions` array with all trigger IDs -- [ ] Created `{service}SetupInstructions` function with clear steps -- [ ] Created `build{Service}ExtraFields` for service-specific fields -- [ ] Created output builders for each trigger type +## Checklist -### Triggers -- [ ] Primary trigger has `includeDropdown: true` -- [ ] Secondary triggers do NOT have `includeDropdown` +### Trigger Definition +- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders +- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT - [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] All triggers have proper outputs defined - [ ] Created `index.ts` barrel export ### Registration -- [ ] All triggers imported in `triggers/registry.ts` -- [ ] All triggers added to `TRIGGER_REGISTRY` -- [ ] Block has `triggers.enabled: true` -- [ ] Block has all trigger IDs in `triggers.available` +- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY` +- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` -### Automatic Webhook Registration (if supported) -- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` -- [ ] Updated setup instructions for automatic webhook creation -- [ ] Added `createSubscription` to `apps/sim/lib/webhooks/providers/{service}.ts` -- [ ] Added `deleteSubscription` to `apps/sim/lib/webhooks/providers/{service}.ts` -- [ ] Did not add provider-specific orchestration logic to shared route / deploy / provider-subscriptions files unless absolutely required +### Provider Handler (if needed) +- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Registered in `providers/registry.ts` (alphabetical) +- [ ] Signature validator is a private function inside the handler file +- [ ] `formatInput` output keys match trigger `outputs` exactly +- [ ] Event matching uses dynamic `await import()` for trigger utils -### Webhook Input Formatting -- [ ] Added provider-owned `formatInput` in `apps/sim/lib/webhooks/providers/{service}.ts` when custom formatting is needed -- [ ] Used `createHmacVerifier` for standard HMAC providers, or a custom `verifyAuth()` when the provider requires non-standard signature semantics / stricter secret handling -- [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify alignment +### Auto Registration (if supported) +- [ ] `createSubscription` and `deleteSubscription` on the handler +- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] API key field uses `password: true` ### Testing -- [ ] Run `bun run type-check` to verify no TypeScript errors -- [ ] Restart dev server to pick up new triggers -- [ ] Test trigger UI shows correctly in the block -- [ ] Test automatic webhook creation works (if applicable) +- [ ] `bun run type-check` passes +- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Trigger UI shows correctly in the block diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md new file mode 100644 index 00000000000..ab081c951ee --- /dev/null +++ b/.agents/skills/validate-trigger/SKILL.md @@ -0,0 +1,212 @@ +--- +name: validate-trigger +description: Audit an existing Sim webhook trigger against the service's webhook API docs and repository conventions, then report and fix issues across trigger definitions, provider handler, output alignment, registration, and security. Use when validating or repairing a trigger under `apps/sim/triggers/{service}/` or `apps/sim/lib/webhooks/providers/{service}.ts`. +--- + +# Validate Trigger + +You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers. + +## Your Task + +1. Read the service's webhook/API documentation (via WebFetch) +2. Read every trigger file, provider handler, and registry entry +3. Cross-reference against the API docs and Sim conventions +4. Report all issues grouped by severity (critical, warning, suggestion) +5. Fix all issues after reporting them + +## Step 1: Gather All Files + +Read **every** file for the trigger — do not skip any: + +``` +apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts +apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists) +apps/sim/lib/webhooks/providers/registry.ts # Handler registry +apps/sim/triggers/registry.ts # Trigger registry +apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring) +``` + +Also read for reference: +``` +apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface +apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.) +apps/sim/lib/webhooks/providers/subscription-utils.ts # Subscription helpers +apps/sim/lib/webhooks/processor.ts # Central webhook processor +``` + +## Step 2: Pull API Documentation + +Fetch the service's official webhook documentation. This is the **source of truth** for: +- Webhook event types and payload shapes +- Signature/auth verification method (HMAC algorithm, header names, secret format) +- Challenge/verification handshake requirements +- Webhook subscription API (create/delete endpoints, if applicable) +- Retry behavior and delivery guarantees + +## Step 3: Validate Trigger Definitions + +### utils.ts +- [ ] `{service}TriggerOptions` lists all trigger IDs accurately +- [ ] `{service}SetupInstructions` provides clear, correct steps for the service +- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition` +- [ ] Output builders expose all meaningful fields from the webhook payload +- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features) +- [ ] Nested output objects correctly model the payload structure + +### Trigger Files +- [ ] Exactly one primary trigger has `includeDropdown: true` +- [ ] All secondary triggers do NOT have `includeDropdown` +- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks) +- [ ] Every trigger's `id` matches the convention `{service}_{event_name}` +- [ ] Every trigger's `provider` matches the service name used in the handler registry +- [ ] `index.ts` barrel exports all triggers + +### Trigger ↔ Provider Alignment (CRITICAL) +- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions` +- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types +- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs + +## Step 4: Validate Provider Handler + +### Auth Verification +- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation +- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512) +- [ ] Signature header name matches the API docs exactly +- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.) +- [ ] Uses `safeCompare` for timing-safe comparison (no `===`) +- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed) +- [ ] Signature is computed over raw body (not parsed JSON) + +### Event Matching +- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values) +- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`) +- [ ] When `triggerId` is a generic webhook ID, all events pass through +- [ ] When `triggerId` is specific, only matching events pass +- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps) + +### formatInput (CRITICAL) +- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema +- [ ] Every key in the trigger `outputs` schema is populated by `formatInput` +- [ ] No extra undeclared keys that users can't discover in the UI +- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`) +- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`) +- [ ] `null` is used for missing optional fields (not empty strings or empty objects) +- [ ] Returns `{ input: { ... } }` — not a bare object + +### Idempotency +- [ ] `extractIdempotencyId` returns a stable, unique key per delivery +- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`) +- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists +- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries) + +### Challenge Handling (if applicable) +- [ ] `handleChallenge` correctly implements the service's URL verification handshake +- [ ] Returns the expected response format per the API docs +- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed + +## Step 5: Validate Automatic Subscription Lifecycle + +If the service supports programmatic webhook creation: + +### createSubscription +- [ ] Calls the correct API endpoint to create a webhook +- [ ] Sends the correct event types/filters +- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)` +- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID +- [ ] Throws on failure (orchestration handles rollback) +- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.) + +### deleteSubscription +- [ ] Calls the correct API endpoint to delete the webhook +- [ ] Handles 404 gracefully (webhook already deleted) +- [ ] Never throws — catches errors and logs non-fatally +- [ ] Skips gracefully when `apiKey` or `externalId` is missing + +### Orchestration Isolation +- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`) + +## Step 6: Validate Registration and Block Wiring + +### Trigger Registry (`triggers/registry.ts`) +- [ ] All triggers are imported and registered +- [ ] Registry keys match trigger IDs exactly +- [ ] No orphaned entries (triggers that don't exist) + +### Provider Handler Registry (`providers/registry.ts`) +- [ ] Handler is imported and registered (if handler exists) +- [ ] Registry key matches the `provider` field on the trigger configs +- [ ] Entries are in alphabetical order + +### Block Wiring (`blocks/blocks/{service}.ts`) +- [ ] Block has `triggers.enabled: true` +- [ ] `triggers.available` lists all trigger IDs +- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks` +- [ ] No trigger IDs in `triggers.available` that aren't in the registry +- [ ] No trigger subBlocks spread that aren't in `triggers.available` + +## Step 7: Validate Security + +- [ ] Webhook secrets are never logged (not even at debug level) +- [ ] Auth verification runs before any event processing +- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`) +- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security) +- [ ] Raw body is used for signature verification (not re-serialized JSON) + +## Step 8: Report and Fix + +### Report Format + +Group findings by severity: + +**Critical** (runtime errors, security issues, or data loss): +- Wrong HMAC algorithm or header name +- `formatInput` keys don't match trigger `outputs` +- Missing `verifyAuth` when the service sends signed webhooks +- `matchEvent` returns non-boolean values +- Provider-specific logic leaking into shared orchestration files +- Trigger IDs mismatch between trigger files, registry, and block +- `createSubscription` calling wrong API endpoint +- Auth comparison using `===` instead of `safeCompare` + +**Warning** (convention violations or usability issues): +- Missing `extractIdempotencyId` when the service provides delivery IDs +- Timestamps in idempotency keys (breaks dedup on retries) +- Missing challenge handling when the service requires URL verification +- Output schema missing fields that `formatInput` returns (undiscoverable data) +- Overly tight timestamp skew window that rejects legitimate retries +- `matchEvent` not filtering challenge/verification events +- Setup instructions missing important steps + +**Suggestion** (minor improvements): +- More specific output field descriptions +- Additional output fields that could be exposed +- Better error messages in `createSubscription` +- Logging improvements + +### Fix All Issues + +After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity. + +### Validation Output + +After fixing, confirm: +1. `bun run type-check` passes +2. Re-read all modified files to verify fixes are correct +3. Provider handler tests pass (if they exist): `bun test {service}` + +## Checklist Summary + +- [ ] Read all trigger files, provider handler, types, registries, and block +- [ ] Pulled and read official webhook/API documentation +- [ ] Validated trigger definitions: options, instructions, extra fields, outputs +- [ ] Validated primary/secondary trigger distinction (`includeDropdown`) +- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency +- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key +- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits +- [ ] Validated registration: trigger registry, handler registry, block wiring +- [ ] Validated security: safe comparison, no secret logging, replay protection +- [ ] Reported all issues grouped by severity +- [ ] Fixed all critical and warning issues +- [ ] `bun run type-check` passes after fixes diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index de7f1355d9d..46978a5207b 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -3,63 +3,56 @@ description: Create webhook triggers for a Sim integration using the generic tri argument-hint: --- -# Add Trigger Skill +# Add Trigger You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. ## Your Task -When the user asks you to create triggers for a service: 1. Research what webhook events the service supports 2. Create the trigger files using the generic builder -3. Register triggers and connect them to the block +3. Create a provider handler if custom auth, formatting, or subscriptions are needed +4. Register triggers and connect them to the block ## Directory Structure ``` apps/sim/triggers/{service}/ ├── index.ts # Barrel exports -├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields) +├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs) ├── {event_a}.ts # Primary trigger (includes dropdown) ├── {event_b}.ts # Secondary trigger (no dropdown) -├── {event_c}.ts # Secondary trigger (no dropdown) └── webhook.ts # Generic webhook trigger (optional, for "all events") + +apps/sim/lib/webhooks/providers/ +├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) +├── types.ts # WebhookProviderHandler interface +├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) +└── registry.ts # Handler map + default handler ``` -## Step 1: Create utils.ts +## Step 1: Create `utils.ts` -This file contains service-specific helpers used by all triggers. +This file contains all service-specific helpers used by triggers. ```typescript import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' -/** - * Dropdown options for the trigger type selector. - * These appear in the primary trigger's dropdown. - */ export const {service}TriggerOptions = [ { label: 'Event A', id: '{service}_event_a' }, { label: 'Event B', id: '{service}_event_b' }, - { label: 'Event C', id: '{service}_event_c' }, - { label: 'Generic Webhook (All Events)', id: '{service}_webhook' }, ] -/** - * Generates HTML setup instructions for the trigger. - * Displayed to users to help them configure webhooks in the external service. - */ export function {service}SetupInstructions(eventType: string): string { const instructions = [ 'Copy the Webhook URL above', 'Go to {Service} Settings > Webhooks', - 'Click Add Webhook', - 'Paste the webhook URL', `Select the ${eventType} event type`, - 'Save the webhook configuration', + 'Paste the webhook URL and save', 'Click "Save" above to activate your trigger', ] - return instructions .map((instruction, index) => `
${index + 1}. ${instruction}
` @@ -67,10 +60,6 @@ export function {service}SetupInstructions(eventType: string): string { .join('') } -/** - * Service-specific extra fields to add to triggers. - * These are inserted between webhookUrl and triggerSave. - */ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { return [ { @@ -78,53 +67,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { title: 'Project ID (Optional)', type: 'short-input', placeholder: 'Leave empty for all projects', - description: 'Optionally filter to a specific project', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, ] } -/** - * Build outputs for this trigger type. - * Outputs define what data is available to downstream blocks. - */ export function build{Service}Outputs(): Record { return { - eventType: { type: 'string', description: 'The type of event that triggered this workflow' }, + eventType: { type: 'string', description: 'The type of event' }, resourceId: { type: 'string', description: 'ID of the affected resource' }, - timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, - // Nested outputs for complex data resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, - status: { type: 'string', description: 'Current status' }, }, - webhook: { type: 'json', description: 'Full webhook payload' }, } } ``` -## Step 2: Create the Primary Trigger +## Step 2: Create Trigger Files -The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types. +**Primary trigger** — MUST include `includeDropdown: true`: ```typescript import { {Service}Icon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' +import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' import type { TriggerConfig } from '@/triggers/types' -/** - * {Service} Event A Trigger - * - * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. - */ export const {service}EventATrigger: TriggerConfig = { id: '{service}_event_a', name: '{Service} Event A', @@ -132,476 +102,101 @@ export const {service}EventATrigger: TriggerConfig = { description: 'Trigger workflow when Event A occurs', version: '1.0.0', icon: {Service}Icon, - subBlocks: buildTriggerSubBlocks({ triggerId: '{service}_event_a', triggerOptions: {service}TriggerOptions, - includeDropdown: true, // PRIMARY TRIGGER - includes dropdown + includeDropdown: true, setupInstructions: {service}SetupInstructions('Event A'), extraFields: build{Service}ExtraFields('{service}_event_a'), }), - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, } ``` -## Step 3: Create Secondary Triggers - -Secondary triggers do NOT include the dropdown (it's already in the primary trigger). +**Secondary triggers** — NO `includeDropdown` (it's already in the primary): ```typescript -import { {Service}Icon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' -import type { TriggerConfig } from '@/triggers/types' - -/** - * {Service} Event B Trigger - */ export const {service}EventBTrigger: TriggerConfig = { - id: '{service}_event_b', - name: '{Service} Event B', - provider: '{service}', - description: 'Trigger workflow when Event B occurs', - version: '1.0.0', - icon: {Service}Icon, - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_b', - triggerOptions: {service}TriggerOptions, - // NO includeDropdown - secondary trigger - setupInstructions: {service}SetupInstructions('Event B'), - extraFields: build{Service}ExtraFields('{service}_event_b'), - }), - - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + // Same as above but: id: '{service}_event_b', no includeDropdown } ``` -## Step 4: Create index.ts Barrel Export +## Step 3: Register and Wire + +### `apps/sim/triggers/{service}/index.ts` ```typescript export { {service}EventATrigger } from './event_a' export { {service}EventBTrigger } from './event_b' -export { {service}EventCTrigger } from './event_c' -export { {service}WebhookTrigger } from './webhook' ``` -## Step 5: Register Triggers - -### Trigger Registry (`apps/sim/triggers/registry.ts`) +### `apps/sim/triggers/registry.ts` ```typescript -// Add import -import { - {service}EventATrigger, - {service}EventBTrigger, - {service}EventCTrigger, - {service}WebhookTrigger, -} from '@/triggers/{service}' - -// Add to TRIGGER_REGISTRY +import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' + export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing triggers ... + // ... existing ... {service}_event_a: {service}EventATrigger, {service}_event_b: {service}EventBTrigger, - {service}_event_c: {service}EventCTrigger, - {service}_webhook: {service}WebhookTrigger, } ``` -## Step 6: Connect Triggers to Block - -In the block file (`apps/sim/blocks/blocks/{service}.ts`): +### Block file (`apps/sim/blocks/blocks/{service}.ts`) ```typescript -import { {Service}Icon } from '@/components/icons' import { getTrigger } from '@/triggers' -import type { BlockConfig } from '@/blocks/types' export const {Service}Block: BlockConfig = { - type: '{service}', - name: '{Service}', - // ... other config ... - - // Enable triggers and list available trigger IDs + // ... triggers: { enabled: true, - available: [ - '{service}_event_a', - '{service}_event_b', - '{service}_event_c', - '{service}_webhook', - ], + available: ['{service}_event_a', '{service}_event_b'], }, - subBlocks: [ - // Regular tool subBlocks first - { id: 'operation', /* ... */ }, - { id: 'credential', /* ... */ }, - // ... other tool fields ... - - // Then spread ALL trigger subBlocks + // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, - ...getTrigger('{service}_event_c').subBlocks, - ...getTrigger('{service}_webhook').subBlocks, ], - - // ... tools config ... -} -``` - -## Automatic Webhook Registration (Preferred) - -If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. - -All subscription lifecycle logic lives on the provider handler — **no code touches `route.ts` or `provider-subscriptions.ts`**. - -### When to Use Automatic Registration - -Check the service's API documentation for endpoints like: -- `POST /webhooks` or `POST /hooks` - Create webhook -- `DELETE /webhooks/{id}` - Delete webhook - -Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, Ashby, Attio, etc. - -### Implementation Steps - -#### 1. Add API Key to Extra Fields - -Update your `build{Service}ExtraFields` function to include an API key field: - -```typescript -export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your {Service} API key', - description: 'Required to create the webhook in {Service}.', - password: true, - required: true, - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - // Other optional fields (e.g., campaign filter, project filter) - { - id: 'projectId', - title: 'Project ID (Optional)', - type: 'short-input', - placeholder: 'Leave empty for all projects', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] -} -``` - -#### 2. Update Setup Instructions for Automatic Creation - -Change instructions to indicate automatic webhook creation: - -```typescript -export function {service}SetupInstructions(eventType: string): string { - const instructions = [ - 'Enter your {Service} API Key above.', - 'You can find your API key in {Service} at Settings > API.', - `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`, - 'The webhook will be automatically deleted when you remove this trigger.', - ] - - return instructions - .map((instruction, index) => - `
${index + 1}. ${instruction}
` - ) - .join('') -} -``` - -#### 3. Add `createSubscription` and `deleteSubscription` to the Provider Handler - -In `apps/sim/lib/webhooks/providers/{service}.ts`, add both lifecycle methods to your handler. The orchestration layer (`provider-subscriptions.ts`, `deploy.ts`, `route.ts`) calls these automatically — you never touch those files. - -```typescript -import { createLogger } from '@sim/logger' -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' -import type { - DeleteSubscriptionContext, - SubscriptionContext, - SubscriptionResult, - WebhookProviderHandler, -} from '@/lib/webhooks/providers/types' - -const logger = createLogger('WebhookProvider:{Service}') - -export const {service}Handler: WebhookProviderHandler = { - // ... other methods (verifyAuth, formatInput, etc.) ... - - async createSubscription(ctx: SubscriptionContext): Promise { - try { - const providerConfig = getProviderConfig(ctx.webhook) - const apiKey = providerConfig.apiKey as string | undefined - const triggerId = providerConfig.triggerId as string | undefined - - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } - - // Map trigger IDs to service event types - const eventTypeMap: Record = { - {service}_event_a: 'eventA', - {service}_event_b: 'eventB', - {service}_webhook: undefined, // Generic - no filter - } - - const eventType = eventTypeMap[triggerId ?? ''] - const notificationUrl = getNotificationUrl(ctx.webhook) - - const requestBody: Record = { - url: notificationUrl, - } - if (eventType) { - requestBody.eventType = eventType - } - - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = (await response.json()) as Record - - if (!response.ok) { - const errorMessage = (responseBody.message as string) || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` - } - throw new Error(userFriendlyMessage) - } - - const externalId = responseBody.id as string | undefined - if (!externalId) { - throw new Error('{Service} webhook created but no ID was returned.') - } - - logger.info(`[${ctx.requestId}] Created {Service} webhook ${externalId}`) - return { providerConfigUpdates: { externalId } } - } catch (error: unknown) { - const err = error as Error - logger.error(`[${ctx.requestId}] {Service} webhook creation failed`, { - message: err.message, - }) - throw error - } - }, - - async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { - try { - const config = getProviderConfig(ctx.webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey || !externalId) { - logger.warn(`[${ctx.requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } - - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${apiKey}` }, - }) - - if (!response.ok && response.status !== 404) { - logger.warn( - `[${ctx.requestId}] Failed to delete {Service} webhook (non-fatal): ${response.status}` - ) - } else { - logger.info(`[${ctx.requestId}] Successfully deleted {Service} webhook ${externalId}`) - } - } catch (error) { - logger.warn(`[${ctx.requestId}] Error deleting {Service} webhook (non-fatal)`, error) - } - }, } ``` -#### How It Works +## Provider Handler -The orchestration layer handles everything automatically: - -1. **Creation**: `provider-subscriptions.ts` → `createExternalWebhookSubscription()` calls `handler.createSubscription()` → merges `providerConfigUpdates` into the saved webhook record. -2. **Deletion**: `provider-subscriptions.ts` → `cleanupExternalWebhook()` calls `handler.deleteSubscription()` → errors are caught and logged non-fatally. -3. **Polling config**: `deploy.ts` → `configurePollingIfNeeded()` calls `handler.configurePolling()` for credential-based providers (Gmail, Outlook, RSS, IMAP). - -You do NOT need to modify any orchestration files. Just implement the methods on your handler. - -#### Shared Utilities for Subscriptions - -Import from `@/lib/webhooks/providers/subscription-utils`: - -- `getProviderConfig(webhook)` — safely extract `providerConfig` as `Record` -- `getNotificationUrl(webhook)` — build the full callback URL: `{baseUrl}/api/webhooks/trigger/{path}` -- `getCredentialOwner(credentialId, requestId)` — resolve OAuth credential to `{ userId, accountId }` (for OAuth-based providers like Airtable, Attio) - -### Key Points for Automatic Registration - -- **API Key visibility**: Always use `password: true` for API key fields -- **Error handling**: Throw from `createSubscription` — the orchestration layer catches it, rolls back the DB webhook, and returns a 500 -- **External ID storage**: Return `{ providerConfigUpdates: { externalId } }` — the orchestration layer merges it into `providerConfig` -- **Graceful cleanup**: In `deleteSubscription`, catch errors and log non-fatally (never throw) -- **User-friendly errors**: Map HTTP status codes to helpful error messages in `createSubscription` - -## The buildTriggerSubBlocks Helper - -This is the generic helper from `@/triggers` that creates consistent trigger subBlocks. - -### Function Signature - -```typescript -interface BuildTriggerSubBlocksOptions { - triggerId: string // e.g., 'service_event_a' - triggerOptions: Array<{ label: string; id: string }> // Dropdown options - includeDropdown?: boolean // true only for primary trigger - setupInstructions: string // HTML instructions - extraFields?: SubBlockConfig[] // Service-specific fields - webhookPlaceholder?: string // Custom placeholder text -} - -function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] -``` - -### What It Creates - -The helper creates this structure: -1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector -2. **Webhook URL** - Read-only field with copy button -3. **Extra Fields** - Your service-specific fields (filters, options, etc.) -4. **Save Button** - Activates the trigger -5. **Instructions** - Setup guide for users - -All fields automatically have: -- `mode: 'trigger'` - Only shown in trigger mode -- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected - -## Webhook Provider Handler (Optional) - -If the service requires **custom webhook auth** (HMAC signatures, token validation), **event matching** (filtering by trigger type), **idempotency dedup**, **custom input formatting**, or **subscription lifecycle** — all of this lives in a single provider handler file. - -### Directory - -``` -apps/sim/lib/webhooks/providers/ -├── types.ts # WebhookProviderHandler interface (16 optional methods) -├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) -├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl, getCredentialOwner) -├── registry.ts # Handler map + default handler -├── index.ts # Barrel export -└── {service}.ts # Your provider handler (ALL provider-specific logic here) -``` +All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. ### When to Create a Handler -| Behavior | Method to implement | Example providers | +| Behavior | Method | Examples | |---|---|---| | HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | | Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | -| Event type filtering | `matchEvent` | GitHub, Jira, Confluence, Attio, HubSpot | -| Event skip by type list | `shouldSkipEvent` via `skipByEventTypes` | Stripe, Grain | +| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot | | Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | -| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Microsoft Teams | -| Custom error format | `formatErrorResponse` | Microsoft Teams | -| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby, Gmail, Outlook | -| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | -| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | -| Polling setup | `configurePolling` | Gmail, Outlook, RSS, IMAP | -| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Microsoft Teams | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams | -If none of these apply, you do NOT need a handler file. The default handler provides bearer token auth for providers that set `providerConfig.token`. +If none apply, you don't need a handler. The default handler provides bearer token auth. -### Simple Example: HMAC Auth Only - -Signature validators are defined as private functions **inside the handler file** (not in a shared utils file): +### Example Handler ```typescript import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:{Service}') function validate{Service}Signature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) return false - if (!signature.startsWith('sha256=')) return false - const provided = signature.substring(7) - const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - return safeCompare(computed, provided) - } catch (error) { - logger.error('Error validating {Service} signature:', error) - return false - } -} - -export const {service}Handler: WebhookProviderHandler = { - verifyAuth: createHmacVerifier({ - configKey: 'webhookSecret', - headerName: 'X-{Service}-Signature', - validateFn: validate{Service}Signature, - providerLabel: '{Service}', - }), -} -``` - -### Example: Auth + Event Matching + Idempotency - -```typescript -import crypto from 'crypto' -import { createLogger } from '@sim/logger' -import { safeCompare } from '@/lib/core/security/encryption' -import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' - -const logger = createLogger('WebhookProvider:{Service}') - -function validate{Service}Signature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) return false - const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - return safeCompare(computed, signature) - } catch (error) { - logger.error('Error validating {Service} signature:', error) - return false - } + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) } export const {service}Handler: WebhookProviderHandler = { @@ -612,35 +207,34 @@ export const {service}Handler: WebhookProviderHandler = { providerLabel: '{Service}', }), - async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined - const obj = body as Record - if (triggerId && triggerId !== '{service}_webhook') { const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') - if (!is{Service}EventMatch(triggerId, obj)) { - logger.debug( - `[${requestId}] {Service} event mismatch for trigger ${triggerId}. Skipping.`, - { webhookId: webhook.id, workflowId: workflow.id, triggerId } - ) - return false - } + if (!is{Service}EventMatch(triggerId, body as Record)) return false } - return true }, + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + resource: b.data, + }, + } + }, + extractIdempotencyId(body: unknown) { const obj = body as Record - if (obj.id && obj.type) { - return `${obj.type}:${obj.id}` - } - return null + return obj.id && obj.type ? `${obj.type}:${obj.id}` : null }, } ``` -### Registering the Handler +### Register the Handler In `apps/sim/lib/webhooks/providers/registry.ts`: @@ -648,94 +242,82 @@ In `apps/sim/lib/webhooks/providers/registry.ts`: import { {service}Handler } from '@/lib/webhooks/providers/{service}' const PROVIDER_HANDLERS: Record = { - // ... existing providers (alphabetical) ... + // ... existing (alphabetical) ... {service}: {service}Handler, } ``` -## Trigger Outputs & Webhook Input Formatting - -### Important: Two Sources of Truth +## Output Alignment (Critical) -There are two related but separate concerns: +There are two sources of truth that **MUST be aligned**: -1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatInput` on the handler** - Implementation that transforms raw webhook payload into actual data. Defined in `apps/sim/lib/webhooks/providers/{service}.ts`. +1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown) +2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data -**These MUST be aligned.** The fields returned by `formatInput` should match what's defined in trigger `outputs`. If they differ: -- Tag dropdown shows fields that don't exist (broken variable resolution) -- Or actual data has fields not shown in dropdown (users can't discover them) +If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover. -### When to Add `formatInput` +**Rules for `formatInput`:** +- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution +- No wrapper objects or duplication +- Use `null` for missing optional data -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need it. The fallback passes through the raw body directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add `formatInput` to your handler. +## Automatic Webhook Registration -### Adding `formatInput` to Your Handler - -In `apps/sim/lib/webhooks/providers/{service}.ts`: +If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. ```typescript -import type { - FormatInputContext, - FormatInputResult, - WebhookProviderHandler, -} from '@/lib/webhooks/providers/types' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' export const {service}Handler: WebhookProviderHandler = { - // ... other methods ... + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string + if (!apiKey) throw new Error('{Service} API Key is required.') + + const res = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }), + }) + + if (!res.ok) throw new Error(`{Service} error: ${res.status}`) + const { id } = (await res.json()) as { id: string } + return { providerConfigUpdates: { externalId: id } } + }, - async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record - return { - input: { - eventType: b.type, - resourceId: (b.data as Record)?.id || '', - timestamp: b.created_at, - resource: b.data, - }, - } + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const { apiKey, externalId } = config as { apiKey?: string; externalId?: string } + if (!apiKey || !externalId) return + await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }).catch(() => {}) }, } ``` -**Key rules:** -- Return `{ input: { ... } }` where the inner object matches your trigger `outputs` definition exactly -- Return `{ input: ..., skip: { message: '...' } }` to skip execution for this event -- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` -- No duplication (don't spread body AND add individual fields) -- Use `null` for missing optional data, not empty objects with empty strings - -### Verify Alignment +**Key points:** +- Throw from `createSubscription` — orchestration rolls back the DB webhook +- Never throw from `deleteSubscription` — log non-fatally +- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig` +- Add `apiKey` field to `build{Service}ExtraFields` with `password: true` -Run the alignment checker (from the `sim` git root). Supported providers have a check in `apps/sim/scripts/check-trigger-alignment.ts` (`PROVIDER_CHECKS`); others exit 0 with a note to add a handler-only entry or verify manually. -```bash -bun run apps/sim/scripts/check-trigger-alignment.ts {service} -``` - -## Trigger Outputs +## Trigger Outputs Schema Trigger outputs use the same schema as block outputs (NOT tool outputs). -**Supported:** -- `type` and `description` for simple fields -- Nested object structure for complex data - -**NOT Supported:** -- `optional: true` (tool outputs only) -- `items` property (tool outputs only) +**Supported:** `type` + `description` for leaf fields, nested objects for complex data. +**NOT supported:** `optional: true`, `items` (those are tool-output-only features). ```typescript export function buildOutputs(): Record { return { - // Simple fields eventType: { type: 'string', description: 'Event type' }, timestamp: { type: 'string', description: 'When it occurred' }, - - // Complex data - use type: 'json' payload: { type: 'json', description: 'Full event payload' }, - - // Nested structure resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, @@ -744,83 +326,32 @@ export function buildOutputs(): Record { } ``` -## Generic Webhook Trigger Pattern - -For services with many event types, create a generic webhook that accepts all events: - -```typescript -export const {service}WebhookTrigger: TriggerConfig = { - id: '{service}_webhook', - name: '{Service} Webhook (All Events)', - // ... - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_webhook', - triggerOptions: {service}TriggerOptions, - setupInstructions: {service}SetupInstructions('All Events'), - extraFields: [ - // Event type filter (optional) - { - id: 'eventTypes', - title: 'Event Types', - type: 'dropdown', - multiSelect: true, - options: [ - { label: 'Event A', id: 'event_a' }, - { label: 'Event B', id: 'event_b' }, - ], - placeholder: 'Leave empty for all events', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: '{service}_webhook' }, - }, - // Plus any other service-specific fields - ...build{Service}ExtraFields('{service}_webhook'), - ], - }), -} -``` - -## Checklist Before Finishing - -### Utils -- [ ] Created `{service}TriggerOptions` array with all trigger IDs -- [ ] Created `{service}SetupInstructions` function with clear steps -- [ ] Created `build{Service}ExtraFields` for service-specific fields -- [ ] Created output builders for each trigger type +## Checklist -### Triggers -- [ ] Primary trigger has `includeDropdown: true` -- [ ] Secondary triggers do NOT have `includeDropdown` +### Trigger Definition +- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders +- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT - [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] All triggers have proper outputs defined - [ ] Created `index.ts` barrel export ### Registration -- [ ] All triggers imported in `triggers/registry.ts` -- [ ] All triggers added to `TRIGGER_REGISTRY` -- [ ] Block has `triggers.enabled: true` -- [ ] Block has all trigger IDs in `triggers.available` +- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY` +- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` -### Webhook Provider Handler (`providers/{service}.ts`) -- [ ] Created handler file in `apps/sim/lib/webhooks/providers/{service}.ts` -- [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical) -- [ ] Signature validator defined as private function inside handler file (not in a shared file) -- [ ] Used `createHmacVerifier` from `providers/utils` for standard HMAC auth, or a provider-specific `verifyAuth()` when the provider requires custom signature semantics / stricter secret handling -- [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth +### Provider Handler (if needed) +- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Registered in `providers/registry.ts` (alphabetical) +- [ ] Signature validator is a private function inside the handler file +- [ ] `formatInput` output keys match trigger `outputs` exactly - [ ] Event matching uses dynamic `await import()` for trigger utils -- [ ] Added `formatInput` if webhook payload needs transformation (returns `{ input: ... }`) -### Automatic Webhook Registration (if supported) -- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` -- [ ] Updated setup instructions for automatic webhook creation -- [ ] Added `createSubscription` method to handler (uses `getNotificationUrl`, `getProviderConfig` from `subscription-utils`) -- [ ] Added `deleteSubscription` method to handler (catches errors, logs non-fatally) -- [ ] NO changes needed to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +### Auto Registration (if supported) +- [ ] `createSubscription` and `deleteSubscription` on the handler +- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] API key field uses `password: true` ### Testing -- [ ] Run `bun run type-check` to verify no TypeScript errors -- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify output alignment -- [ ] Restart dev server to pick up new triggers -- [ ] Test trigger UI shows correctly in the block -- [ ] Test automatic webhook creation works (if applicable) +- [ ] `bun run type-check` passes +- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Trigger UI shows correctly in the block diff --git a/.claude/commands/validate-trigger.md b/.claude/commands/validate-trigger.md new file mode 100644 index 00000000000..77332446157 --- /dev/null +++ b/.claude/commands/validate-trigger.md @@ -0,0 +1,212 @@ +--- +description: Validate an existing Sim webhook trigger against provider API docs and repository conventions +argument-hint: [api-docs-url] +--- + +# Validate Trigger + +You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers. + +## Your Task + +1. Read the service's webhook/API documentation (via WebFetch) +2. Read every trigger file, provider handler, and registry entry +3. Cross-reference against the API docs and Sim conventions +4. Report all issues grouped by severity (critical, warning, suggestion) +5. Fix all issues after reporting them + +## Step 1: Gather All Files + +Read **every** file for the trigger — do not skip any: + +``` +apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts +apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists) +apps/sim/lib/webhooks/providers/registry.ts # Handler registry +apps/sim/triggers/registry.ts # Trigger registry +apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring) +``` + +Also read for reference: +``` +apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface +apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.) +apps/sim/lib/webhooks/providers/subscription-utils.ts # Subscription helpers +apps/sim/lib/webhooks/processor.ts # Central webhook processor +``` + +## Step 2: Pull API Documentation + +Fetch the service's official webhook documentation. This is the **source of truth** for: +- Webhook event types and payload shapes +- Signature/auth verification method (HMAC algorithm, header names, secret format) +- Challenge/verification handshake requirements +- Webhook subscription API (create/delete endpoints, if applicable) +- Retry behavior and delivery guarantees + +## Step 3: Validate Trigger Definitions + +### utils.ts +- [ ] `{service}TriggerOptions` lists all trigger IDs accurately +- [ ] `{service}SetupInstructions` provides clear, correct steps for the service +- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition` +- [ ] Output builders expose all meaningful fields from the webhook payload +- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features) +- [ ] Nested output objects correctly model the payload structure + +### Trigger Files +- [ ] Exactly one primary trigger has `includeDropdown: true` +- [ ] All secondary triggers do NOT have `includeDropdown` +- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks) +- [ ] Every trigger's `id` matches the convention `{service}_{event_name}` +- [ ] Every trigger's `provider` matches the service name used in the handler registry +- [ ] `index.ts` barrel exports all triggers + +### Trigger ↔ Provider Alignment (CRITICAL) +- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions` +- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types +- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs + +## Step 4: Validate Provider Handler + +### Auth Verification +- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation +- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512) +- [ ] Signature header name matches the API docs exactly +- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.) +- [ ] Uses `safeCompare` for timing-safe comparison (no `===`) +- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed) +- [ ] Signature is computed over raw body (not parsed JSON) + +### Event Matching +- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values) +- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`) +- [ ] When `triggerId` is a generic webhook ID, all events pass through +- [ ] When `triggerId` is specific, only matching events pass +- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps) + +### formatInput (CRITICAL) +- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema +- [ ] Every key in the trigger `outputs` schema is populated by `formatInput` +- [ ] No extra undeclared keys that users can't discover in the UI +- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`) +- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`) +- [ ] `null` is used for missing optional fields (not empty strings or empty objects) +- [ ] Returns `{ input: { ... } }` — not a bare object + +### Idempotency +- [ ] `extractIdempotencyId` returns a stable, unique key per delivery +- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`) +- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists +- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries) + +### Challenge Handling (if applicable) +- [ ] `handleChallenge` correctly implements the service's URL verification handshake +- [ ] Returns the expected response format per the API docs +- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed + +## Step 5: Validate Automatic Subscription Lifecycle + +If the service supports programmatic webhook creation: + +### createSubscription +- [ ] Calls the correct API endpoint to create a webhook +- [ ] Sends the correct event types/filters +- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)` +- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID +- [ ] Throws on failure (orchestration handles rollback) +- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.) + +### deleteSubscription +- [ ] Calls the correct API endpoint to delete the webhook +- [ ] Handles 404 gracefully (webhook already deleted) +- [ ] Never throws — catches errors and logs non-fatally +- [ ] Skips gracefully when `apiKey` or `externalId` is missing + +### Orchestration Isolation +- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`) + +## Step 6: Validate Registration and Block Wiring + +### Trigger Registry (`triggers/registry.ts`) +- [ ] All triggers are imported and registered +- [ ] Registry keys match trigger IDs exactly +- [ ] No orphaned entries (triggers that don't exist) + +### Provider Handler Registry (`providers/registry.ts`) +- [ ] Handler is imported and registered (if handler exists) +- [ ] Registry key matches the `provider` field on the trigger configs +- [ ] Entries are in alphabetical order + +### Block Wiring (`blocks/blocks/{service}.ts`) +- [ ] Block has `triggers.enabled: true` +- [ ] `triggers.available` lists all trigger IDs +- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks` +- [ ] No trigger IDs in `triggers.available` that aren't in the registry +- [ ] No trigger subBlocks spread that aren't in `triggers.available` + +## Step 7: Validate Security + +- [ ] Webhook secrets are never logged (not even at debug level) +- [ ] Auth verification runs before any event processing +- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`) +- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security) +- [ ] Raw body is used for signature verification (not re-serialized JSON) + +## Step 8: Report and Fix + +### Report Format + +Group findings by severity: + +**Critical** (runtime errors, security issues, or data loss): +- Wrong HMAC algorithm or header name +- `formatInput` keys don't match trigger `outputs` +- Missing `verifyAuth` when the service sends signed webhooks +- `matchEvent` returns non-boolean values +- Provider-specific logic leaking into shared orchestration files +- Trigger IDs mismatch between trigger files, registry, and block +- `createSubscription` calling wrong API endpoint +- Auth comparison using `===` instead of `safeCompare` + +**Warning** (convention violations or usability issues): +- Missing `extractIdempotencyId` when the service provides delivery IDs +- Timestamps in idempotency keys (breaks dedup on retries) +- Missing challenge handling when the service requires URL verification +- Output schema missing fields that `formatInput` returns (undiscoverable data) +- Overly tight timestamp skew window that rejects legitimate retries +- `matchEvent` not filtering challenge/verification events +- Setup instructions missing important steps + +**Suggestion** (minor improvements): +- More specific output field descriptions +- Additional output fields that could be exposed +- Better error messages in `createSubscription` +- Logging improvements + +### Fix All Issues + +After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity. + +### Validation Output + +After fixing, confirm: +1. `bun run type-check` passes +2. Re-read all modified files to verify fixes are correct +3. Provider handler tests pass (if they exist): `bun test {service}` + +## Checklist Summary + +- [ ] Read all trigger files, provider handler, types, registries, and block +- [ ] Pulled and read official webhook/API documentation +- [ ] Validated trigger definitions: options, instructions, extra fields, outputs +- [ ] Validated primary/secondary trigger distinction (`includeDropdown`) +- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency +- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key +- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits +- [ ] Validated registration: trigger registry, handler registry, block wiring +- [ ] Validated security: safe comparison, no secret logging, replay protection +- [ ] Reported all issues grouped by severity +- [ ] Fixed all critical and warning issues +- [ ] `bun run type-check` passes after fixes diff --git a/apps/sim/scripts/check-trigger-alignment.ts b/apps/sim/scripts/check-trigger-alignment.ts deleted file mode 100644 index 7efd6c53231..00000000000 --- a/apps/sim/scripts/check-trigger-alignment.ts +++ /dev/null @@ -1,527 +0,0 @@ -#!/usr/bin/env bun - -/** - * Compares top-level trigger output keys with keys returned from the provider's formatInput. - * - * Many trigger files import `buildTriggerSubBlocks` from `@/triggers`, which pulls the full - * registry and is unsafe to load from a standalone script. This runner uses **per-provider - * entry points** (utils + handler only) where implemented. - * - * Usage (from repo root): - * bun run apps/sim/scripts/check-trigger-alignment.ts - * - * Or from apps/sim: - * bun run scripts/check-trigger-alignment.ts - */ - -if (!process.env.DATABASE_URL) { - process.env.DATABASE_URL = - 'postgresql://127.0.0.1:5432/__sim_trigger_alignment_check_placeholder__' -} - -import type { TriggerOutput } from '@/triggers/types' - -type CheckFn = () => Promise<{ - referenceLabel: string - outputKeys: string[] - formatInputKeys: string[] -}> - -const PROVIDER_CHECKS: Record = { - zoom: async () => { - const { buildMeetingOutputs } = await import('@/triggers/zoom/utils') - const { zoomHandler } = await import('@/lib/webhooks/providers/zoom') - const outputs = buildMeetingOutputs() as Record - const result = await zoomHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - event: 'meeting.started', - event_ts: 1700000000000, - payload: { account_id: 'acct_1', object: { id: 123456789, uuid: 'abc' } }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildMeetingOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - gong: async () => { - const { buildCallOutputs } = await import('@/triggers/gong/utils') - const { gongHandler } = await import('@/lib/webhooks/providers/gong') - const outputs = buildCallOutputs() as Record - const result = await gongHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: {}, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildCallOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - linear: async () => { - const { buildIssueOutputs } = await import('@/triggers/linear/utils') - const { linearHandler } = await import('@/lib/webhooks/providers/linear') - const outputs = buildIssueOutputs() as Record - const result = await linearHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - action: 'create', - type: 'Issue', - webhookId: 'wh_123', - webhookTimestamp: Date.now(), - organizationId: 'org_123', - createdAt: new Date().toISOString(), - url: 'https://linear.app', - actor: { id: 'user_1', type: 'user', name: 'Test User' }, - data: {}, - updatedFrom: null, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildIssueOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - resend: async () => { - const { buildResendOutputs } = await import('@/triggers/resend/utils') - const { resendHandler } = await import('@/lib/webhooks/providers/resend') - const outputs = buildResendOutputs() as Record - const result = await resendHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - type: 'email.bounced', - created_at: '2024-11-22T23:41:12.126Z', - data: { - broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216', - created_at: '2024-11-22T23:41:11.894719+00:00', - email_id: '56761188-7520-42d8-8898-ff6fc54ce618', - from: 'Acme ', - to: ['delivered@resend.dev'], - subject: 'Sending this example', - template_id: '43f68331-0622-4e15-8202-246a0388854b', - bounce: { - message: - "The recipient's email address is on the suppression list because it has a recent history of producing hard bounces.", - subType: 'Suppressed', - type: 'Permanent', - }, - tags: { category: 'confirm_email' }, - }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildResendOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - notion: async () => { - const { buildPageEventOutputs } = await import('@/triggers/notion/utils') - const { notionHandler } = await import('@/lib/webhooks/providers/notion') - const outputs = buildPageEventOutputs() as Record - const result = await notionHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - id: 'evt_123', - type: 'page.created', - timestamp: new Date().toISOString(), - workspace_id: 'workspace_1', - workspace_name: 'Workspace', - subscription_id: 'sub_1', - integration_id: 'int_1', - attempt_number: 1, - authors: [], - accessible_by: [], - entity: { id: 'page_1', type: 'page' }, - data: { parent: { id: 'parent_1', type: 'page' } }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildPageEventOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - salesforce: async () => { - const { buildSalesforceWebhookOutputs } = await import('@/triggers/salesforce/utils') - const { salesforceHandler } = await import('@/lib/webhooks/providers/salesforce') - const outputs = buildSalesforceWebhookOutputs() as Record - const result = await salesforceHandler.formatInput!({ - webhook: { providerConfig: { triggerId: 'salesforce_webhook' } }, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - eventType: 'record_created', - objectType: 'Account', - Id: '001', - Name: 'Acme', - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildSalesforceWebhookOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - vercel: async () => { - const { buildVercelOutputs } = await import('@/triggers/vercel/utils') - const { vercelHandler } = await import('@/lib/webhooks/providers/vercel') - const outputs = buildVercelOutputs() as Record - const result = await vercelHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - type: 'deployment.created', - id: 'evt_123', - createdAt: Date.now(), - region: 'iad1', - payload: { - deployment: { - id: 'dep_1', - url: 'example.vercel.app', - name: 'preview', - meta: { githubCommitSha: 'abc123' }, - }, - project: { id: 'prj_1', name: 'project' }, - team: { id: 'team_1' }, - user: { id: 'user_1' }, - target: 'preview', - plan: 'pro', - links: { - deployment: 'https://vercel.com/acme/project/dep', - project: 'https://vercel.com/acme/project', - }, - regions: ['iad1'], - domain: { name: 'example.com', delegated: false }, - }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildVercelOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - greenhouse: async () => { - const { buildWebhookOutputs } = await import('@/triggers/greenhouse/utils') - const { greenhouseHandler } = await import('@/lib/webhooks/providers/greenhouse') - const outputs = buildWebhookOutputs() as Record - const result = await greenhouseHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - action: 'new_candidate_application', - payload: { - application: { - id: 71980812, - candidate: { id: 60304594 }, - jobs: [{ id: 274075, name: 'Engineer' }], - }, - }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildWebhookOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - hubspot: async () => { - const { buildWebhookOutputs } = await import('@/triggers/hubspot/utils') - const { hubspotHandler } = await import('@/lib/webhooks/providers/hubspot') - const outputs = buildWebhookOutputs() as Record - const sampleBody = [ - { - objectId: 123, - subscriptionType: 'contact.creation', - portalId: 456, - occurredAt: 1700000000000, - attemptNumber: 0, - eventId: 789, - changeSource: 'CRM', - }, - ] - const result = await hubspotHandler.formatInput!({ - webhook: { providerConfig: { triggerId: 'hubspot_webhook' } }, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: sampleBody as unknown as Record, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildWebhookOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - intercom: async () => { - const { buildIntercomGenericOutputs } = await import('@/triggers/intercom/utils') - const { intercomHandler } = await import('@/lib/webhooks/providers/intercom') - const outputs = buildIntercomGenericOutputs() as Record - const result = await intercomHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - topic: 'contact.created', - id: 'notif_123', - app_id: 'app_123', - created_at: 1700000000, - delivery_attempts: 1, - first_sent_at: 1700000000, - data: { - item: { - id: 'contact_1', - email: 'ada@example.com', - }, - }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'buildIntercomGenericOutputs()', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - github: async () => { - const { repositoryOutputs, userOutputs } = await import('@/triggers/github/utils') - const { githubHandler } = await import('@/lib/webhooks/providers/github') - - const outputs = { - action: { type: 'string', description: 'GitHub action' }, - event_type: { type: 'string', description: 'GitHub event type' }, - branch: { type: 'string', description: 'Branch name' }, - repository: repositoryOutputs, - sender: userOutputs, - } as unknown as Record - - const result = await githubHandler.formatInput!({ - webhook: {}, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - action: 'opened', - ref: 'refs/heads/main', - repository: { - id: 1, - node_id: 'node_1', - name: 'sim', - full_name: 'simstudioai/sim', - private: true, - html_url: 'https://github.com/simstudioai/sim', - description: 'Repo', - fork: false, - url: 'https://api.github.com/repos/simstudioai/sim', - homepage: null, - size: 123, - stargazers_count: 10, - watchers_count: 10, - language: 'TypeScript', - forks_count: 2, - open_issues_count: 3, - default_branch: 'main', - owner: { - login: 'simstudioai', - id: 1, - avatar_url: 'https://avatars.githubusercontent.com/u/1', - html_url: 'https://github.com/simstudioai', - }, - }, - sender: { - login: 'octocat', - id: 2, - node_id: 'node_2', - avatar_url: 'https://avatars.githubusercontent.com/u/2', - html_url: 'https://github.com/octocat', - type: 'User', - }, - }, - headers: { 'x-github-event': 'issues' }, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'github issue-shaped outputs', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - slack: async () => { - const { slackHandler } = await import('@/lib/webhooks/providers/slack') - - const outputs = { - event: { - type: 'object', - description: 'Slack event data', - properties: { - event_type: { type: 'string', description: 'Slack event type' }, - channel: { type: 'string', description: 'Channel ID' }, - channel_name: { type: 'string', description: 'Channel name' }, - user: { type: 'string', description: 'User ID' }, - user_name: { type: 'string', description: 'Username' }, - text: { type: 'string', description: 'Message text' }, - timestamp: { type: 'string', description: 'Message timestamp' }, - thread_ts: { type: 'string', description: 'Thread timestamp' }, - team_id: { type: 'string', description: 'Workspace ID' }, - event_id: { type: 'string', description: 'Event ID' }, - reaction: { type: 'string', description: 'Reaction name' }, - item_user: { type: 'string', description: 'Original message author' }, - hasFiles: { type: 'boolean', description: 'Whether files are attached' }, - files: { type: 'file[]', description: 'Downloaded files' }, - }, - }, - } as Record - - const result = await slackHandler.formatInput!({ - webhook: { providerConfig: {} }, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - team_id: 'T123', - event_id: 'Ev123', - event: { - type: 'app_mention', - channel: 'C123', - user: 'U123', - text: 'hello', - ts: '1700000000.0001', - thread_ts: '1700000000.0001', - }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'slackWebhookTrigger.outputs', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, - typeform: async () => { - const { typeformHandler } = await import('@/lib/webhooks/providers/typeform') - - const outputs = { - event_id: { type: 'string', description: 'Unique event identifier' }, - event_type: { type: 'string', description: 'Typeform event type' }, - form_id: { type: 'string', description: 'Form ID' }, - token: { type: 'string', description: 'Submission token' }, - submitted_at: { type: 'string', description: 'Submission timestamp' }, - landed_at: { type: 'string', description: 'Landing timestamp' }, - calculated: { - type: 'object', - description: 'Calculated values', - properties: { score: { type: 'number', description: 'Score' } }, - }, - variables: { type: 'array', description: 'Variables' }, - hidden: { type: 'object', description: 'Hidden fields' }, - answers: { type: 'array', description: 'Answers' }, - ending: { - type: 'object', - description: 'Ending', - properties: { - id: { type: 'string', description: 'Ending id' }, - ref: { type: 'string', description: 'Ending ref' }, - }, - }, - raw: { type: 'object', description: 'Raw payload' }, - } as Record - - const result = await typeformHandler.formatInput!({ - webhook: { providerConfig: { includeDefinition: false } }, - workflow: { id: 'check-alignment', userId: 'check-alignment' }, - body: { - event_id: 'evt_1', - event_type: 'form_response', - form_response: { - form_id: 'form_1', - token: 'token_1', - submitted_at: '2026-01-01T00:00:00Z', - landed_at: '2026-01-01T00:00:00Z', - calculated: { score: 5 }, - variables: [], - hidden: {}, - answers: [], - ending: { id: 'ending_1', ref: 'end_ref' }, - }, - }, - headers: {}, - requestId: 'check-trigger-alignment', - }) - const input = result.input as Record - return { - referenceLabel: 'typeformWebhookTrigger.outputs', - outputKeys: Object.keys(outputs).sort(), - formatInputKeys: Object.keys(input).sort(), - } - }, -} - -const provider = process.argv[2]?.trim() -if (!provider) { - console.error('Usage: bun run apps/sim/scripts/check-trigger-alignment.ts ') - process.exit(1) -} - -const run = PROVIDER_CHECKS[provider] -if (!run) { - console.log( - `[${provider}] No bundled alignment check yet. Add an entry to PROVIDER_CHECKS in apps/sim/scripts/check-trigger-alignment.ts (import utils + handler only, not @/triggers/registry), or compare output keys manually.` - ) - process.exit(0) -} - -const { referenceLabel, outputKeys, formatInputKeys } = await run() -const missingInInput = outputKeys.filter((k) => !formatInputKeys.includes(k)) -const extraInInput = formatInputKeys.filter((k) => !outputKeys.includes(k)) - -console.log(`Provider: ${provider}`) -console.log(`Reference: ${referenceLabel}`) -console.log('outputs (top-level):', outputKeys.join(', ') || '(none)') -console.log('formatInput keys:', formatInputKeys.join(', ') || '(none)') - -if (missingInInput.length > 0) { - console.error('MISSING in formatInput:', missingInInput.join(', ')) -} -if (extraInInput.length > 0) { - console.warn('EXTRA in formatInput (not in outputs):', extraInInput.join(', ')) -} - -if (missingInInput.length > 0) { - process.exit(1) -} - -console.log(`\n[${provider}] Alignment check passed.`) -process.exit(0) From d3fcf04958a02f04a5356a426dc2a7281df007d1 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 20:21:13 -0700 Subject: [PATCH 22/23] updated file naming semantics --- .agents/skills/add-trigger/SKILL.md | 15 +++++---- .agents/skills/validate-trigger/SKILL.md | 2 +- .claude/commands/add-trigger.md | 15 +++++---- .claude/commands/validate-trigger.md | 2 +- apps/sim/lib/webhooks/providers/salesforce.ts | 32 ++++++++++++++++++- apps/sim/lib/webhooks/providers/types.ts | 4 +-- .../lib/webhooks/salesforce-payload-utils.ts | 30 ----------------- apps/sim/triggers/salesforce/utils.ts | 2 +- 8 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 apps/sim/lib/webhooks/salesforce-payload-utils.ts diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index dd93d11c20d..fd6df46e505 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -24,12 +24,13 @@ apps/sim/triggers/{service}/ ├── {event_b}.ts # Secondary trigger (no dropdown) └── webhook.ts # Generic webhook trigger (optional, for "all events") -apps/sim/lib/webhooks/providers/ -├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) -├── types.ts # WebhookProviderHandler interface -├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) -├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) -└── registry.ts # Handler map + default handler +apps/sim/lib/webhooks/ +├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) +├── providers/ +│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) +│ ├── types.ts # WebhookProviderHandler interface +│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +│ └── registry.ts # Handler map + default handler ``` ## Step 1: Create `utils.ts` @@ -267,7 +268,7 @@ If they differ: the tag dropdown shows fields that don't exist, or actual data h If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. ```typescript -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' export const {service}Handler: WebhookProviderHandler = { diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md index ab081c951ee..ff1eb775b44 100644 --- a/.agents/skills/validate-trigger/SKILL.md +++ b/.agents/skills/validate-trigger/SKILL.md @@ -31,7 +31,7 @@ Also read for reference: ``` apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.) -apps/sim/lib/webhooks/providers/subscription-utils.ts # Subscription helpers +apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers apps/sim/lib/webhooks/processor.ts # Central webhook processor ``` diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index 46978a5207b..9cbeca68a3e 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -24,12 +24,13 @@ apps/sim/triggers/{service}/ ├── {event_b}.ts # Secondary trigger (no dropdown) └── webhook.ts # Generic webhook trigger (optional, for "all events") -apps/sim/lib/webhooks/providers/ -├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) -├── types.ts # WebhookProviderHandler interface -├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) -├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) -└── registry.ts # Handler map + default handler +apps/sim/lib/webhooks/ +├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) +├── providers/ +│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) +│ ├── types.ts # WebhookProviderHandler interface +│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +│ └── registry.ts # Handler map + default handler ``` ## Step 1: Create `utils.ts` @@ -267,7 +268,7 @@ If they differ: the tag dropdown shows fields that don't exist, or actual data h If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. ```typescript -import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' export const {service}Handler: WebhookProviderHandler = { diff --git a/.claude/commands/validate-trigger.md b/.claude/commands/validate-trigger.md index 77332446157..04bdc63c397 100644 --- a/.claude/commands/validate-trigger.md +++ b/.claude/commands/validate-trigger.md @@ -31,7 +31,7 @@ Also read for reference: ``` apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.) -apps/sim/lib/webhooks/providers/subscription-utils.ts # Subscription helpers +apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers apps/sim/lib/webhooks/processor.ts # Central webhook processor ``` diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 6c3520d4e9a..ddd4c7f96c2 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -9,7 +9,37 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' -import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/salesforce-payload-utils' + +export function extractSalesforceObjectTypeFromPayload( + body: Record +): string | undefined { + const direct = + (typeof body.objectType === 'string' && body.objectType) || + (typeof body.sobjectType === 'string' && body.sobjectType) || + undefined + if (direct) { + return direct + } + + const attrs = body.attributes as Record | undefined + if (typeof attrs?.type === 'string') { + return attrs.type + } + + const record = body.record + if (record && typeof record === 'object' && !Array.isArray(record)) { + const r = record as Record + if (typeof r.sobjectType === 'string') { + return r.sobjectType + } + const ra = r.attributes as Record | undefined + if (typeof ra?.type === 'string') { + return ra.type + } + } + + return undefined +} const logger = createLogger('WebhookProvider:Salesforce') diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index 2698f4ce8fa..dee3e8aca19 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -115,10 +115,10 @@ export interface WebhookProviderHandler { /** Custom error response when queuing fails. Return null for default 500. */ formatQueueErrorResponse?(): NextResponse | null - /** Custom input preparation. Replaces the standard `formatWebhookInput` call when defined. */ + /** Custom input preparation. When defined, replaces the default pass-through of the raw body. */ formatInput?(ctx: FormatInputContext): Promise - /** Called when standard `formatWebhookInput` returns null. Return skip message or null to proceed. */ + /** Called when input is null after formatting. Return skip message or null to proceed. */ handleEmptyInput?(requestId: string): { message: string } | null /** Post-process input to handle file uploads before execution. */ diff --git a/apps/sim/lib/webhooks/salesforce-payload-utils.ts b/apps/sim/lib/webhooks/salesforce-payload-utils.ts deleted file mode 100644 index a88014608d4..00000000000 --- a/apps/sim/lib/webhooks/salesforce-payload-utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -export function extractSalesforceObjectTypeFromPayload( - body: Record -): string | undefined { - const direct = - (typeof body.objectType === 'string' && body.objectType) || - (typeof body.sobjectType === 'string' && body.sobjectType) || - undefined - if (direct) { - return direct - } - - const attrs = body.attributes as Record | undefined - if (typeof attrs?.type === 'string') { - return attrs.type - } - - const record = body.record - if (record && typeof record === 'object' && !Array.isArray(record)) { - const r = record as Record - if (typeof r.sobjectType === 'string') { - return r.sobjectType - } - const ra = r.attributes as Record | undefined - if (typeof ra?.type === 'string') { - return ra.type - } - } - - return undefined -} diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index f1bafa3b214..233274bc702 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -1,4 +1,4 @@ -import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/salesforce-payload-utils' +import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/providers/salesforce' import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' From 2897ce1c9b316c7bacd0ad50834989b2fa7200f8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Apr 2026 20:29:59 -0700 Subject: [PATCH 23/23] rename file --- .../triggers/linear/{utils.match-event.test.ts => utils.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/sim/triggers/linear/{utils.match-event.test.ts => utils.test.ts} (100%) diff --git a/apps/sim/triggers/linear/utils.match-event.test.ts b/apps/sim/triggers/linear/utils.test.ts similarity index 100% rename from apps/sim/triggers/linear/utils.match-event.test.ts rename to apps/sim/triggers/linear/utils.test.ts