From 05de93c5f851a9f62f9844058eecace7ca5e7a54 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:09:09 -0700 Subject: [PATCH 01/23] fix(slack): guard webhook handlers against null/non-object bodies handleSlackChallenge, extractIdempotencyId, and formatInput cast the webhook body to Record without checking for null first. handleProviderChallenges runs Slack's handleChallenge unconditionally on every webhook path before the webhook row is looked up, so a POST with a literal JSON `null` body crashed with a TypeError instead of degrading gracefully, unlike sibling providers (e.g. monday.ts) that already guard this case. Switch all three to isRecordLike, matching the pattern used by other providers. --- apps/sim/lib/webhooks/providers/slack.test.ts | 34 ++++++++++++++++++- apps/sim/lib/webhooks/providers/slack.ts | 31 ++++++++++------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 1c261ba3ec3..fa00c981223 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { slackHandler } from '@/lib/webhooks/providers/slack' +import { handleSlackChallenge, slackHandler } from '@/lib/webhooks/providers/slack' const ctx = (body: unknown) => ({ webhook: {}, @@ -207,4 +207,36 @@ describe('slackHandler extractIdempotencyId', () => { it('returns null when no identifier is present', () => { expect(slackHandler.extractIdempotencyId!({})).toBeNull() }) + + it('degrades gracefully instead of throwing for a null or non-object body', () => { + expect(slackHandler.extractIdempotencyId!(null)).toBeNull() + expect(slackHandler.extractIdempotencyId!(undefined)).toBeNull() + expect(slackHandler.extractIdempotencyId!('not-an-object')).toBeNull() + }) +}) + +describe('handleSlackChallenge', () => { + it('echoes the challenge for a url_verification payload', () => { + const response = handleSlackChallenge({ type: 'url_verification', challenge: 'abc123' }) + expect(response).not.toBeNull() + }) + + it('returns null for non-challenge payloads', () => { + expect(handleSlackChallenge({ type: 'event_callback' })).toBeNull() + }) + + it('degrades gracefully instead of throwing for a null or non-object body', () => { + expect(handleSlackChallenge(null)).toBeNull() + expect(handleSlackChallenge(undefined)).toBeNull() + expect(handleSlackChallenge('not-an-object')).toBeNull() + expect(handleSlackChallenge([1, 2, 3])).toBeNull() + }) +}) + +describe('slackHandler formatInput - null/non-object body', () => { + it('degrades gracefully instead of throwing', async () => { + const { input } = await slackHandler.formatInput!(ctx(null)) + const event = eventOf(input) + expect(event.event_type).toBe('unknown') + }) }) diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 2d83b196dd1..81bd1f5c036 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import { secureFetchWithPinnedIP, @@ -440,9 +441,12 @@ function validateSlackSignature( * Handle Slack verification challenges */ export function handleSlackChallenge(body: unknown): NextResponse | null { - const obj = body as Record - if (obj.type === 'url_verification' && obj.challenge) { - return NextResponse.json({ challenge: obj.challenge }) + if (!isRecordLike(body)) { + return null + } + + if (body.type === 'url_verification' && body.challenge) { + return NextResponse.json({ challenge: body.challenge }) } return null @@ -492,20 +496,23 @@ export const slackHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const obj = body as Record - if (obj.event_id) { - return String(obj.event_id) + if (!isRecordLike(body)) { + return null + } + + if (body.event_id) { + return String(body.event_id) } - const event = obj.event as Record | undefined - if (event?.ts && obj.team_id) { - return `${obj.team_id}:${event.ts}` + const event = isRecordLike(body.event) ? body.event : undefined + if (event?.ts && body.team_id) { + return `${body.team_id}:${event.ts}` } // Interactivity and slash-command payloads carry a unique `trigger_id` // per interaction, which Slack reuses across retries of the same payload. - if (obj.trigger_id) { - return String(obj.trigger_id) + if (body.trigger_id) { + return String(body.trigger_id) } return null @@ -520,7 +527,7 @@ export const slackHandler: WebhookProviderHandler = { }, async formatInput({ body, webhook }: FormatInputContext): Promise { - const b = body as Record + const b = isRecordLike(body) ? body : {} const providerConfig = (webhook.providerConfig as Record) || {} const botToken = providerConfig.botToken as string | undefined const includeFiles = Boolean(providerConfig.includeFiles) From 2a42636595649ec577ce3cfc2da190ff44281f15 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:08:36 -0700 Subject: [PATCH 02/23] fix(stripe): guard extractIdempotencyId against non-object bodies Bring the Stripe webhook provider in line with the isRecordLike convention used by other providers (linear, sendblue, instantly) so a null/non-object body degrades gracefully instead of throwing on property access. In practice the only caller already guards against non-object bodies, so this is defense-in-depth, not a live crash fix. Adds a colocated stripe.test.ts covering signature verification (valid/invalid/wrong-secret), event-type filtering, formatInput pass-through, and extractIdempotencyId including the null-body case. --- .../sim/lib/webhooks/providers/stripe.test.ts | 151 ++++++++++++++++++ apps/sim/lib/webhooks/providers/stripe.ts | 14 +- 2 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/stripe.test.ts diff --git a/apps/sim/lib/webhooks/providers/stripe.test.ts b/apps/sim/lib/webhooks/providers/stripe.test.ts new file mode 100644 index 00000000000..4a8384720a9 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/stripe.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import Stripe from 'stripe' +import { describe, expect, it } from 'vitest' +import { stripeHandler } from '@/lib/webhooks/providers/stripe' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +const WEBHOOK_SECRET = 'whsec_test_secret' + +function signedRequest(rawBody: string, secret = WEBHOOK_SECRET) { + const header = Stripe.webhooks.generateTestHeaderString({ + payload: rawBody, + secret, + }) + return reqWithHeaders({ 'stripe-signature': header }) +} + +describe('Stripe webhook provider', () => { + it('verifyAuth rejects when webhookSecret is not configured', async () => { + const res = await stripeHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when the Stripe-Signature header is missing', async () => { + const res = await stripeHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: WEBHOOK_SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects an invalid signature', async () => { + const rawBody = JSON.stringify({ id: 'evt_1', object: 'event', type: 'customer.created' }) + const res = await stripeHandler.verifyAuth!({ + request: reqWithHeaders({ 'stripe-signature': 't=1,v1=bad' }), + rawBody, + requestId: 't3', + providerConfig: { webhookSecret: WEBHOOK_SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts a validly signed payload', async () => { + const rawBody = JSON.stringify({ id: 'evt_1', object: 'event', type: 'customer.created' }) + const res = await stripeHandler.verifyAuth!({ + request: signedRequest(rawBody), + rawBody, + requestId: 't4', + providerConfig: { webhookSecret: WEBHOOK_SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('verifyAuth rejects a signature signed with the wrong secret', async () => { + const rawBody = JSON.stringify({ id: 'evt_1', object: 'event', type: 'customer.created' }) + const res = await stripeHandler.verifyAuth!({ + request: signedRequest(rawBody, 'whsec_other_secret'), + rawBody, + requestId: 't5', + providerConfig: { webhookSecret: WEBHOOK_SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('shouldSkipEvent filters events outside the configured eventTypes allowlist', () => { + const skip = stripeHandler.shouldSkipEvent!({ + webhook: { id: 'w1' }, + body: { type: 'invoice.paid' }, + requestId: 't6', + providerConfig: { eventTypes: ['customer.created'] }, + }) + expect(skip).toBe(true) + }) + + it('shouldSkipEvent passes through matching events', () => { + const skip = stripeHandler.shouldSkipEvent!({ + webhook: { id: 'w1' }, + body: { type: 'customer.created' }, + requestId: 't7', + providerConfig: { eventTypes: ['customer.created'] }, + }) + expect(skip).toBe(false) + }) + + it('shouldSkipEvent passes through everything when no allowlist is configured', () => { + const skip = stripeHandler.shouldSkipEvent!({ + webhook: { id: 'w1' }, + body: { type: 'invoice.paid' }, + requestId: 't8', + providerConfig: {}, + }) + expect(skip).toBe(false) + }) + + it('formatInput passes the raw Stripe event body through unchanged', async () => { + const body = { id: 'evt_1', object: 'event', type: 'customer.created', data: { object: {} } } + const { input } = await stripeHandler.formatInput!({ + body, + headers: {}, + requestId: 't9', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + expect(input).toEqual(body) + }) + + it('extractIdempotencyId returns the Stripe event id for event objects', () => { + const id = stripeHandler.extractIdempotencyId!({ id: 'evt_123', object: 'event' }) + expect(id).toBe('evt_123') + }) + + it('extractIdempotencyId is stable across retried deliveries of the same event', () => { + const body = { id: 'evt_123', object: 'event', type: 'customer.created' } + const first = stripeHandler.extractIdempotencyId!(body) + const second = stripeHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + + it('extractIdempotencyId returns null for non-event objects', () => { + expect(stripeHandler.extractIdempotencyId!({ id: 'obj_1', object: 'customer' })).toBeNull() + }) + + it('extractIdempotencyId degrades gracefully for null or non-object bodies', () => { + expect(stripeHandler.extractIdempotencyId!(null)).toBeNull() + expect(stripeHandler.extractIdempotencyId!(undefined)).toBeNull() + expect(stripeHandler.extractIdempotencyId!('not-an-object')).toBeNull() + expect(stripeHandler.extractIdempotencyId!(['array'])).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/stripe.ts b/apps/sim/lib/webhooks/providers/stripe.ts index ee35e7df12a..fb488a23999 100644 --- a/apps/sim/lib/webhooks/providers/stripe.ts +++ b/apps/sim/lib/webhooks/providers/stripe.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import Stripe from 'stripe' import type { @@ -49,10 +50,15 @@ export const stripeHandler: WebhookProviderHandler = { return skipByEventTypes(ctx, 'Stripe', logger) }, - extractIdempotencyId(body: unknown) { - const obj = body as Record - if (obj.id && obj.object === 'event') { - return String(obj.id) + /** + * Stripe event ids (evt_...) are globally unique and stable across retries — + * Stripe resends the same event id on delivery retries, so keying on it + * directly is sufficient without a content-derived fallback. + */ + extractIdempotencyId(body: unknown): string | null { + if (!isRecordLike(body)) return null + if (body.id && body.object === 'event') { + return String(body.id) } return null }, From 7de8dfea9e5e388be4de250493dba602c9b9f894 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:14:04 -0700 Subject: [PATCH 03/23] fix(github): fix workflow_run event matching and formatInput/output-schema mismatch isGitHubEventMatch's eventMap was missing an entry for github_workflow_run, so unknown-trigger fallthrough caused that trigger to fire on every GitHub event type, not just workflow_run. The provider's formatInput did a raw passthrough of the webhook body, but the trigger output schemas rename GitHub's reserved `type` field (a TriggerOutput meta-key) to `user_type`/`owner_type`, and `description` to `repo_description` for the repository object. Since formatInput never performed those renames, the declared output fields never matched real delivered data. Added alias renaming (keeping the raw keys for back-compat, matching the existing GitLab work_item_type precedent) plus null-body guards in formatInput/matchEvent and a content-based extractIdempotencyId fallback. --- .../sim/lib/webhooks/providers/github.test.ts | 224 ++++++++++++++++++ apps/sim/lib/webhooks/providers/github.ts | 104 +++++++- apps/sim/triggers/github/utils.ts | 23 +- 3 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/github.test.ts diff --git a/apps/sim/lib/webhooks/providers/github.test.ts b/apps/sim/lib/webhooks/providers/github.test.ts new file mode 100644 index 00000000000..3fb2730bf68 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/github.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { githubHandler } from '@/lib/webhooks/providers/github' +import { isGitHubEventMatch } from '@/triggers/github/utils' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('GitHub webhook provider', () => { + it('verifyAuth allows unsigned requests when no webhookSecret is configured', () => { + const res = githubHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('verifyAuth rejects when the signature header is missing but a secret is configured', () => { + const res = githubHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects an invalid X-Hub-Signature-256', () => { + const res = githubHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Hub-Signature-256': 'sha256=deadbeef' }), + rawBody: '{}', + requestId: 't3', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts a valid X-Hub-Signature-256', async () => { + const crypto = await import('crypto') + const body = '{"action":"opened"}' + const secret = 'my-secret' + const signature = `sha256=${crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')}` + const res = githubHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Hub-Signature-256': signature }), + rawBody: body, + requestId: 't4', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('isGitHubEventMatch matches workflow_run events to the workflow_run trigger only', () => { + expect(isGitHubEventMatch('github_workflow_run', 'workflow_run')).toBe(true) + expect(isGitHubEventMatch('github_workflow_run', 'push')).toBe(false) + expect(isGitHubEventMatch('github_workflow_run', 'issues')).toBe(false) + }) + + it('isGitHubEventMatch distinguishes issue comments from PR comments', () => { + expect( + isGitHubEventMatch('github_issue_comment', 'issue_comment', undefined, { issue: {} }) + ).toBe(true) + expect( + isGitHubEventMatch('github_issue_comment', 'issue_comment', undefined, { + issue: { pull_request: { url: 'x' } }, + }) + ).toBe(false) + expect( + isGitHubEventMatch('github_pr_comment', 'issue_comment', undefined, { + issue: { pull_request: { url: 'x' } }, + }) + ).toBe(true) + }) + + it('matchEvent passes through all events for the generic webhook trigger', async () => { + const result = await githubHandler.matchEvent!({ + body: { action: 'opened' }, + requestId: 't5', + providerConfig: { triggerId: 'github_webhook' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({ 'x-github-event': 'push' }), + }) + expect(result).toBe(true) + }) + + it('matchEvent filters events that do not match the configured trigger', async () => { + const result = await githubHandler.matchEvent!({ + body: { action: 'opened' }, + requestId: 't6', + providerConfig: { triggerId: 'github_workflow_run' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({ 'x-github-event': 'push' }), + }) + expect(result).toBe(false) + }) + + it('matchEvent does not throw when the body is null', async () => { + await expect( + githubHandler.matchEvent!({ + body: null, + requestId: 't7', + providerConfig: { triggerId: 'github_pr_comment' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({ 'x-github-event': 'issue_comment' }), + }) + ).resolves.toBe(false) + }) + + it('formatInput does not throw when the body is null', async () => { + const { input } = await githubHandler.formatInput!({ + body: null, + headers: { 'x-github-event': 'push' }, + requestId: 't8', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.event_type).toBe('push') + expect(i.action).toBe('') + }) + + it('formatInput exposes user.type as user_type, keeping the raw type key too', async () => { + const { input } = await githubHandler.formatInput!({ + body: { + action: 'opened', + issue: { id: 1, user: { login: 'octocat', type: 'User' } }, + }, + headers: { 'x-github-event': 'issues' }, + requestId: 't9', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + const issue = i.issue as Record + const user = issue.user as Record + expect(user.user_type).toBe('User') + expect(user.type).toBe('User') + }) + + it('formatInput exposes repository.owner.type as owner_type', async () => { + const { input } = await githubHandler.formatInput!({ + body: { + action: 'opened', + repository: { full_name: 'octocat/hello', owner: { login: 'octocat', type: 'User' } }, + }, + headers: { 'x-github-event': 'issues' }, + requestId: 't10', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + const repository = i.repository as Record + const owner = repository.owner as Record + expect(owner.owner_type).toBe('User') + expect(owner.user_type).toBe('User') + }) + + it('formatInput exposes repository.description as repo_description, keeping the raw description key too', async () => { + const { input } = await githubHandler.formatInput!({ + body: { + action: 'opened', + repository: { full_name: 'octocat/hello', description: 'A test repo' }, + }, + headers: { 'x-github-event': 'issues' }, + requestId: 't11', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + const repository = i.repository as Record + expect(repository.repo_description).toBe('A test repo') + expect(repository.description).toBe('A test repo') + }) + + it('formatInput derives branch from ref', async () => { + const { input } = await githubHandler.formatInput!({ + body: { ref: 'refs/heads/main', action: '' }, + headers: { 'x-github-event': 'push' }, + requestId: 't12', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.branch).toBe('main') + }) + + it('extractIdempotencyId derives a stable key from the most specific nested entity', () => { + const body = { action: 'created', comment: { id: 5, updated_at: '2026-01-01T00:00:00Z' } } + const first = githubHandler.extractIdempotencyId!(body) + const second = githubHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + expect(first).toContain('5') + }) + + it('extractIdempotencyId falls back to ref+after for push events', () => { + const id = githubHandler.extractIdempotencyId!({ + ref: 'refs/heads/main', + before: 'a', + after: 'b', + }) + expect(id).toBe('github:push:refs/heads/main:b') + }) + + it('extractIdempotencyId returns null when there is no stable identifier', () => { + expect(githubHandler.extractIdempotencyId!({})).toBeNull() + expect(githubHandler.extractIdempotencyId!(null)).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts index 28c0a782e16..4a94d395bed 100644 --- a/apps/sim/lib/webhooks/providers/github.ts +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import type { AuthContext, @@ -12,6 +13,49 @@ import type { const logger = createLogger('WebhookProvider:GitHub') +/** + * GitHub's "simple user" shape (issue.user, pull_request.merged_by, + * repository.owner, sender, ...) always carries `login` alongside `type`. + */ +function isGitHubUserLike(value: unknown): value is Record & { type: string } { + return isRecordLike(value) && typeof value.login === 'string' && typeof value.type === 'string' +} + +/** + * GitHub embeds a `type` field (User/Bot/Organization) on every user-like + * object. `type` is a reserved TriggerOutput meta-key, so the trigger output + * schemas expose it under `user_type` (or `owner_type` for repository.owner) + * instead. This walks the payload adding both aliases next to the raw `type` + * key, so the delivered data matches whichever name a given trigger's output + * schema declares. The raw `type` key is kept alongside the aliases (this is + * plain passthrough data, not schema-constrained) so a workflow already + * referencing the undocumented raw path keeps working. + */ +function withGitHubUserTypeAliases(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(withGitHubUserTypeAliases) + } + if (!isRecordLike(value)) { + return value + } + + const result: Record = {} + for (const [key, nested] of Object.entries(value)) { + result[key] = withGitHubUserTypeAliases(nested) + } + if (isGitHubUserLike(value)) { + result.user_type = value.type + result.owner_type = value.type + } + return result +} + +/** + * Not built on the shared `createHmacVerifier` factory: GitHub supports two + * signature headers (`X-Hub-Signature-256` primary, legacy `X-Hub-Signature` + * sha1 fallback) and picks the algorithm from the header value itself, which + * the single-header/single-algorithm factory doesn't model. + */ function validateGitHubSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { @@ -69,12 +113,27 @@ export const githubHandler: WebhookProviderHandler = { }, async formatInput({ body, headers }: FormatInputContext): Promise { - const b = body as Record const eventType = headers['x-github-event'] || 'unknown' - const ref = (b?.ref as string) || '' + if (!isRecordLike(body)) { + return { input: { event_type: eventType, action: '', branch: '' } } + } + + const ref = typeof body.ref === 'string' ? body.ref : '' const branch = ref.replace('refs/heads/', '') + const aliased = withGitHubUserTypeAliases(body) as Record + + const repository = aliased.repository + if (isRecordLike(repository) && typeof repository.description === 'string') { + aliased.repository = { ...repository, repo_description: repository.description } + } + return { - input: { ...b, event_type: eventType, action: (b?.action || '') as string, branch }, + input: { + ...aliased, + event_type: eventType, + action: typeof body.action === 'string' ? body.action : '', + branch, + }, } }, @@ -87,11 +146,11 @@ export const githubHandler: WebhookProviderHandler = { providerConfig, }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined - const obj = body as Record + const obj = isRecordLike(body) ? body : {} if (triggerId && triggerId !== 'github_webhook') { const eventType = request.headers.get('x-github-event') - const action = obj.action as string | undefined + const action = typeof obj.action === 'string' ? obj.action : undefined const { isGitHubEventMatch } = await import('@/triggers/github/utils') if (!isGitHubEventMatch(triggerId, eventType || '', action, obj)) { @@ -111,4 +170,39 @@ export const githubHandler: WebhookProviderHandler = { return true }, + + /** + * GitHub always sends `X-GitHub-Delivery`, which is already checked ahead + * of this method by the shared idempotency header allowlist. This is a + * content-derived fallback for the rare case that header is stripped in + * transit (e.g. by an intermediary proxy). Prefers the most specific + * nested entity so distinct sub-resources (a comment vs. its parent issue) + * on the same delivery don't collide, and includes `updated_at` where + * available so re-deliveries of the same entity version dedupe while a + * later edit of that same entity is treated as a new key. + */ + extractIdempotencyId(body: unknown): string | null { + if (!isRecordLike(body)) return null + + const action = typeof body.action === 'string' ? body.action : '' + const entity = + (isRecordLike(body.comment) && body.comment) || + (isRecordLike(body.review) && body.review) || + (isRecordLike(body.pull_request) && body.pull_request) || + (isRecordLike(body.issue) && body.issue) || + (isRecordLike(body.release) && body.release) || + (isRecordLike(body.workflow_run) && body.workflow_run) || + null + + if (entity && entity.id != null) { + const version = typeof entity.updated_at === 'string' ? `:${entity.updated_at}` : '' + return `github:${action}:${entity.id}${version}` + } + + if (typeof body.ref === 'string' && typeof body.after === 'string') { + return `github:push:${body.ref}:${body.after}` + } + + return null + }, } diff --git a/apps/sim/triggers/github/utils.ts b/apps/sim/triggers/github/utils.ts index c7b31e8f6bf..41518f762bb 100644 --- a/apps/sim/triggers/github/utils.ts +++ b/apps/sim/triggers/github/utils.ts @@ -1,3 +1,5 @@ +import { isRecordLike } from '@sim/utils/object' + /** * Shared repository output schema */ @@ -128,36 +130,41 @@ export function isGitHubEventMatch( triggerId: string, eventType: string, action?: string, - payload?: any + payload?: unknown ): boolean { const eventMap: Record< string, - { event: string; actions?: string[]; validator?: (payload: any) => boolean } + { + event: string + actions?: string[] + validator?: (payload: Record) => boolean + } > = { github_issue_opened: { event: 'issues', actions: ['opened'] }, github_issue_closed: { event: 'issues', actions: ['closed'] }, github_issue_comment: { event: 'issue_comment', - validator: (p) => !p.issue?.pull_request, // Only issues, not PRs + validator: (p) => !isRecordLike(p.issue) || !p.issue.pull_request, // Only issues, not PRs }, github_pr_opened: { event: 'pull_request', actions: ['opened'] }, github_pr_closed: { event: 'pull_request', actions: ['closed'], - validator: (p) => p.pull_request?.merged === false, // Not merged + validator: (p) => isRecordLike(p.pull_request) && p.pull_request.merged === false, // Not merged }, github_pr_merged: { event: 'pull_request', actions: ['closed'], - validator: (p) => p.pull_request?.merged === true, // Merged + validator: (p) => isRecordLike(p.pull_request) && p.pull_request.merged === true, // Merged }, github_pr_comment: { event: 'issue_comment', - validator: (p) => !!p.issue?.pull_request, // Only PRs, not issues + validator: (p) => isRecordLike(p.issue) && !!p.issue.pull_request, // Only PRs, not issues }, github_pr_reviewed: { event: 'pull_request_review', actions: ['submitted'] }, github_push: { event: 'push' }, github_release_published: { event: 'release', actions: ['published'] }, + github_workflow_run: { event: 'workflow_run' }, } const config = eventMap[triggerId] @@ -176,8 +183,8 @@ export function isGitHubEventMatch( } // Run custom validator if provided - if (config.validator && payload) { - return config.validator(payload) + if (config.validator) { + return config.validator(isRecordLike(payload) ? payload : {}) } return true From a7fb3288320423851e1b187eef451bce40436126 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:16:28 -0700 Subject: [PATCH 04/23] fix(jira): dedupe trigger-type dropdown, guard against null webhook bodies - All 15 Jira trigger files hand-rolled their own selectedTriggerId dropdown subBlock instead of using the shared buildTriggerSubBlocks helper. Since blocks/blocks/jira.ts merges every trigger's subBlocks into one array, the Jira block ended up with 15 duplicate, unconditional selectedTriggerId dropdowns. Refactored every trigger to use buildTriggerSubBlocks, with includeDropdown only on the primary jira_issue_created trigger (matches the jsm sibling module's pattern). - Removed the dead, unused fieldFilters subBlock on issue_updated (never read by matchEvent/formatInput and has no analog in Jira's own webhook admin UI, unlike jqlFilter). - Guarded all `body as Record` casts in the provider handler (matchEvent, formatInput, extractIdempotencyId) and in triggers/jira/utils.ts's extract* helpers with isRecordLike, so a malformed/scalar JSON body (e.g. literal `null`) degrades gracefully instead of throwing. - jira_webhook (generic, all-events) trigger's output schema was missing sprint/project/version keys that its formatInput branch already returns, and duplicated comment/worklog shapes that drifted from the shared builders (e.g. comment.body typed as plain string instead of ADF json). Now composed from the same buildXOutputs() helpers used by the dedicated triggers. Verified against live Atlassian webhook docs: X-Hub-Signature HMAC signing and the X-Atlassian-Webhook-Identifier header (stable across retries) are both real and already correctly implemented/allowlisted; no changes needed there. --- apps/sim/lib/webhooks/providers/jira.test.ts | 139 +++++++++++++++++ apps/sim/lib/webhooks/providers/jira.ts | 7 +- apps/sim/triggers/jira/comment_deleted.ts | 72 ++------- apps/sim/triggers/jira/comment_updated.ts | 72 ++------- apps/sim/triggers/jira/issue_commented.ts | 72 ++------- apps/sim/triggers/jira/issue_created.ts | 84 +++-------- apps/sim/triggers/jira/issue_deleted.ts | 72 ++------- apps/sim/triggers/jira/issue_updated.ts | 86 ++--------- apps/sim/triggers/jira/project_created.ts | 56 ++----- apps/sim/triggers/jira/sprint_closed.ts | 56 ++----- apps/sim/triggers/jira/sprint_created.ts | 56 ++----- apps/sim/triggers/jira/sprint_started.ts | 56 ++----- apps/sim/triggers/jira/utils.ts | 56 ++++++- apps/sim/triggers/jira/version_released.ts | 56 ++----- apps/sim/triggers/jira/webhook.ts | 149 ++++--------------- apps/sim/triggers/jira/worklog_created.ts | 72 ++------- apps/sim/triggers/jira/worklog_deleted.ts | 72 ++------- apps/sim/triggers/jira/worklog_updated.ts | 72 ++------- 18 files changed, 431 insertions(+), 874 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/jira.test.ts diff --git a/apps/sim/lib/webhooks/providers/jira.test.ts b/apps/sim/lib/webhooks/providers/jira.test.ts new file mode 100644 index 00000000000..ea92a508f3e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jira.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { jiraHandler } from '@/lib/webhooks/providers/jira' +import { isJiraEventMatch } from '@/triggers/jira/utils' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('Jira webhook provider', () => { + it('verifyAuth skips verification when no webhookSecret is configured (optional secret)', async () => { + const res = await jiraHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('verifyAuth rejects when a secret is configured but X-Hub-Signature is missing', async () => { + const res = await jiraHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when the signature does not match', async () => { + const res = await jiraHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Hub-Signature': 'sha256=wrong' }), + rawBody: '{"a":1}', + requestId: 't3', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('isJiraEventMatch maps trigger ids to their real webhookEvent values', () => { + expect(isJiraEventMatch('jira_issue_created', 'jira:issue_created')).toBe(true) + expect(isJiraEventMatch('jira_issue_created', 'comment_created')).toBe(false) + expect(isJiraEventMatch('jira_issue_commented', 'comment_created')).toBe(true) + expect(isJiraEventMatch('jira_webhook', 'anything')).toBe(true) + }) + + it('matchEvent filters events that do not match the configured trigger', async () => { + const result = await jiraHandler.matchEvent!({ + body: { webhookEvent: 'comment_created' }, + requestId: 't4', + providerConfig: { triggerId: 'jira_issue_created' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + + it('matchEvent passes through all events for the generic webhook trigger', async () => { + const result = await jiraHandler.matchEvent!({ + body: { webhookEvent: 'anything' }, + requestId: 't5', + providerConfig: { triggerId: 'jira_webhook' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + + it('matchEvent degrades gracefully instead of throwing when body is null', async () => { + const result = await jiraHandler.matchEvent!({ + body: null, + requestId: 't6', + providerConfig: { triggerId: 'jira_issue_created' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + + it('formatInput extracts issue data for the issue_created trigger', async () => { + const { input } = await jiraHandler.formatInput!({ + body: { + webhookEvent: 'jira:issue_created', + timestamp: 123, + issue: { id: '10001', key: 'PROJ-1' }, + }, + headers: {}, + requestId: 't7', + webhook: { providerConfig: { triggerId: 'jira_issue_created' } }, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.webhookEvent).toBe('jira:issue_created') + const issue = i.issue as Record + expect(issue.key).toBe('PROJ-1') + }) + + it('formatInput does not throw and degrades gracefully when body is null', async () => { + const { input } = await jiraHandler.formatInput!({ + body: null, + headers: {}, + requestId: 't8', + webhook: { providerConfig: { triggerId: 'jira_webhook' } }, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.webhookEvent).toBeUndefined() + expect(i.issue).toEqual({}) + }) + + it('extractIdempotencyId derives a stable key from webhookEvent + entity id', () => { + const body = { webhookEvent: 'jira:issue_created', timestamp: 123, issue: { id: '10001' } } + const first = jiraHandler.extractIdempotencyId!(body) + const second = jiraHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + expect(first).toContain('10001') + }) + + it('extractIdempotencyId returns null when there is no stable identifier', () => { + expect(jiraHandler.extractIdempotencyId!({ webhookEvent: 'jira:issue_created' })).toBeNull() + }) + + it('extractIdempotencyId does not throw when body is null', () => { + expect(jiraHandler.extractIdempotencyId!(null)).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts index 0feeecb5a5f..58b472d18fc 100644 --- a/apps/sim/lib/webhooks/providers/jira.ts +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' +import { isRecordLike } from '@sim/utils/object' import type { EventMatchContext, FormatInputContext, @@ -89,7 +90,7 @@ export const jiraHandler: WebhookProviderHandler = { } if (!triggerId || triggerId === 'jira_webhook') { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { input: { webhookEvent: obj.webhookEvent, @@ -112,7 +113,7 @@ export const jiraHandler: WebhookProviderHandler = { async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined - const obj = body as Record + const obj = isRecordLike(body) ? body : {} if (triggerId && triggerId !== 'jira_webhook') { const webhookEvent = obj.webhookEvent as string | undefined @@ -137,7 +138,7 @@ export const jiraHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} const issue = obj.issue as Record | undefined const comment = obj.comment as Record | undefined const worklog = obj.worklog as Record | undefined diff --git a/apps/sim/triggers/jira/comment_deleted.ts b/apps/sim/triggers/jira/comment_deleted.ts index 4988edfbfbc..824b9c76e72 100644 --- a/apps/sim/triggers/jira/comment_deleted.ts +++ b/apps/sim/triggers/jira/comment_deleted.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildCommentOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCommentOutputs, + buildJiraExtraFields, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraCommentDeletedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_comment_deleted', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_comment_deleted', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ AND issuetype = Bug', - description: 'Filter which comment deletions trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_comment_deleted', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('comment_deleted'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_comment_deleted', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_comment_deleted', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('comment_deleted'), + extraFields: buildJiraExtraFields( + 'jira_comment_deleted', + 'Filter which comment deletions trigger this workflow using JQL' + ), + }), outputs: buildCommentOutputs(), diff --git a/apps/sim/triggers/jira/comment_updated.ts b/apps/sim/triggers/jira/comment_updated.ts index 0d5ffa279c4..e89e8c09487 100644 --- a/apps/sim/triggers/jira/comment_updated.ts +++ b/apps/sim/triggers/jira/comment_updated.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildCommentOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCommentOutputs, + buildJiraExtraFields, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraCommentUpdatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_comment_updated', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_comment_updated', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ AND issuetype = Bug', - description: 'Filter which comment updates trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_comment_updated', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('comment_updated'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_comment_updated', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_comment_updated', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('comment_updated'), + extraFields: buildJiraExtraFields( + 'jira_comment_updated', + 'Filter which comment updates trigger this workflow using JQL' + ), + }), outputs: buildCommentOutputs(), diff --git a/apps/sim/triggers/jira/issue_commented.ts b/apps/sim/triggers/jira/issue_commented.ts index ad84ade7b29..ae1a78bbb31 100644 --- a/apps/sim/triggers/jira/issue_commented.ts +++ b/apps/sim/triggers/jira/issue_commented.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildCommentOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCommentOutputs, + buildJiraExtraFields, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraIssueCommentedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_issue_commented', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_commented', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ AND issuetype = Bug', - description: 'Filter which issue comments trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_commented', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('comment_created'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_commented', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_issue_commented', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('comment_created'), + extraFields: buildJiraExtraFields( + 'jira_issue_commented', + 'Filter which issue comments trigger this workflow using JQL' + ), + }), outputs: buildCommentOutputs(), diff --git a/apps/sim/triggers/jira/issue_created.ts b/apps/sim/triggers/jira/issue_created.ts index ed9dd77cb2a..2743fe3b5c5 100644 --- a/apps/sim/triggers/jira/issue_created.ts +++ b/apps/sim/triggers/jira/issue_created.ts @@ -1,10 +1,18 @@ import { JiraIcon } from '@/components/icons' -import { buildIssueOutputs, jiraSetupInstructions, jiraTriggerOptions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIssueOutputs, + buildJiraExtraFields, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** * Jira Issue Created Trigger * Triggers when a new issue is created in Jira + * + * Primary trigger — includes the dropdown for selecting trigger type. */ export const jiraIssueCreatedTrigger: TriggerConfig = { id: 'jira_issue_created', @@ -14,70 +22,16 @@ export const jiraIssueCreatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - subBlocks: [ - { - id: 'selectedTriggerId', - title: 'Trigger Type', - type: 'dropdown', - mode: 'trigger', - options: jiraTriggerOptions, - value: () => 'jira_issue_created', - 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: 'jira_issue_created', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_created', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ AND issuetype = Bug', - description: 'Filter which issues trigger this workflow using JQL (Jira Query Language)', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_created', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('jira:issue_created'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_created', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_issue_created', + triggerOptions: jiraTriggerOptions, + includeDropdown: true, + setupInstructions: jiraSetupInstructions('jira:issue_created'), + extraFields: buildJiraExtraFields( + 'jira_issue_created', + 'Filter which issues trigger this workflow using JQL (Jira Query Language)' + ), + }), outputs: buildIssueOutputs(), diff --git a/apps/sim/triggers/jira/issue_deleted.ts b/apps/sim/triggers/jira/issue_deleted.ts index 21ee8ce5c89..c8f6ce2aa38 100644 --- a/apps/sim/triggers/jira/issue_deleted.ts +++ b/apps/sim/triggers/jira/issue_deleted.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildIssueOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIssueOutputs, + buildJiraExtraFields, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraIssueDeletedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_issue_deleted', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_deleted', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ', - description: 'Filter which issue deletions trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_deleted', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('jira:issue_deleted'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_deleted', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_issue_deleted', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('jira:issue_deleted'), + extraFields: buildJiraExtraFields( + 'jira_issue_deleted', + 'Filter which issue deletions trigger this workflow using JQL' + ), + }), outputs: buildIssueOutputs(), diff --git a/apps/sim/triggers/jira/issue_updated.ts b/apps/sim/triggers/jira/issue_updated.ts index 3c70ce424c0..3da48a15fd1 100644 --- a/apps/sim/triggers/jira/issue_updated.ts +++ b/apps/sim/triggers/jira/issue_updated.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildIssueUpdatedOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIssueUpdatedOutputs, + buildJiraExtraFields, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,75 +20,15 @@ export const jiraIssueUpdatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_issue_updated', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_updated', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ AND status changed to "In Progress"', - description: 'Filter which issue updates trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_updated', - }, - }, - { - id: 'fieldFilters', - title: 'Field Filters', - type: 'long-input', - placeholder: 'status, assignee, priority', - description: - 'Comma-separated list of fields to monitor. Only trigger when these fields change.', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_updated', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('jira:issue_updated'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_issue_updated', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_issue_updated', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('jira:issue_updated'), + extraFields: buildJiraExtraFields( + 'jira_issue_updated', + 'Filter which issue updates trigger this workflow using JQL' + ), + }), outputs: buildIssueUpdatedOutputs(), diff --git a/apps/sim/triggers/jira/project_created.ts b/apps/sim/triggers/jira/project_created.ts index 634adc2b3e6..da864aade25 100644 --- a/apps/sim/triggers/jira/project_created.ts +++ b/apps/sim/triggers/jira/project_created.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildProjectCreatedOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildProjectCreatedOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,48 +20,12 @@ export const jiraProjectCreatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_project_created', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_project_created', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('project_created'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_project_created', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_project_created', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('project_created'), + extraFields: buildJiraExtraFields('jira_project_created'), + }), outputs: buildProjectCreatedOutputs(), diff --git a/apps/sim/triggers/jira/sprint_closed.ts b/apps/sim/triggers/jira/sprint_closed.ts index 45a25680891..ac2b0438845 100644 --- a/apps/sim/triggers/jira/sprint_closed.ts +++ b/apps/sim/triggers/jira/sprint_closed.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildSprintOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildSprintOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,48 +20,12 @@ export const jiraSprintClosedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_sprint_closed', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_sprint_closed', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('sprint_closed'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_sprint_closed', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_sprint_closed', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('sprint_closed'), + extraFields: buildJiraExtraFields('jira_sprint_closed'), + }), outputs: buildSprintOutputs(), diff --git a/apps/sim/triggers/jira/sprint_created.ts b/apps/sim/triggers/jira/sprint_created.ts index c46d0db477d..56705295926 100644 --- a/apps/sim/triggers/jira/sprint_created.ts +++ b/apps/sim/triggers/jira/sprint_created.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildSprintOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildSprintOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,48 +20,12 @@ export const jiraSprintCreatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_sprint_created', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_sprint_created', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('sprint_created'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_sprint_created', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_sprint_created', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('sprint_created'), + extraFields: buildJiraExtraFields('jira_sprint_created'), + }), outputs: buildSprintOutputs(), diff --git a/apps/sim/triggers/jira/sprint_started.ts b/apps/sim/triggers/jira/sprint_started.ts index a12f83e78d0..0b86fdc7407 100644 --- a/apps/sim/triggers/jira/sprint_started.ts +++ b/apps/sim/triggers/jira/sprint_started.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildSprintOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildSprintOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,48 +20,12 @@ export const jiraSprintStartedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_sprint_started', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_sprint_started', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('sprint_started'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_sprint_started', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_sprint_started', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('sprint_started'), + extraFields: buildJiraExtraFields('jira_sprint_started'), + }), outputs: buildSprintOutputs(), diff --git a/apps/sim/triggers/jira/utils.ts b/apps/sim/triggers/jira/utils.ts index 3cdcd1ef87e..1dbcb5fbae8 100644 --- a/apps/sim/triggers/jira/utils.ts +++ b/apps/sim/triggers/jira/utils.ts @@ -1,3 +1,5 @@ +import { isRecordLike } from '@sim/utils/object' +import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' /** @@ -47,6 +49,48 @@ export function jiraSetupInstructions(eventType: string, additionalNotes?: strin .join('') } +function jiraWebhookSecretField(triggerId: string): SubBlockConfig { + return { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', + password: true, + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + } +} + +function jiraJqlFilterField(triggerId: string, description: string): SubBlockConfig { + return { + id: 'jqlFilter', + title: 'JQL Filter', + type: 'long-input', + placeholder: 'project = PROJ AND issuetype = Bug', + description, + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + } +} + +/** + * Extra fields for Jira triggers (webhook secret, plus an optional JQL filter + * for issue-scoped events — project/sprint/version events have no JQL analog). + */ +export function buildJiraExtraFields( + triggerId: string, + jqlFilterDescription?: string +): SubBlockConfig[] { + const fields: SubBlockConfig[] = [jiraWebhookSecretField(triggerId)] + if (jqlFilterDescription) { + fields.push(jiraJqlFilterField(triggerId, jqlFilterDescription)) + } + return fields +} + function buildBaseWebhookOutputs(): Record { return { webhookEvent: { @@ -448,7 +492,7 @@ export function isJiraEventMatch( } export function extractIssueData(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { webhookEvent: obj.webhookEvent, timestamp: obj.timestamp, @@ -460,7 +504,7 @@ export function extractIssueData(body: unknown) { } export function extractCommentData(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { webhookEvent: obj.webhookEvent, timestamp: obj.timestamp, @@ -471,7 +515,7 @@ export function extractCommentData(body: unknown) { } export function extractWorklogData(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { webhookEvent: obj.webhookEvent, timestamp: obj.timestamp, @@ -683,7 +727,7 @@ export function buildVersionReleasedOutputs(): Record { * Extracts sprint data from a Jira webhook payload */ export function extractSprintData(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { webhookEvent: obj.webhookEvent, timestamp: obj.timestamp, @@ -696,7 +740,7 @@ export function extractSprintData(body: unknown) { * Extracts project data from a Jira webhook payload */ export function extractProjectData(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { webhookEvent: obj.webhookEvent, timestamp: obj.timestamp, @@ -709,7 +753,7 @@ export function extractProjectData(body: unknown) { * Extracts version data from a Jira webhook payload */ export function extractVersionData(body: unknown) { - const obj = body as Record + const obj = isRecordLike(body) ? body : {} return { webhookEvent: obj.webhookEvent, timestamp: obj.timestamp, diff --git a/apps/sim/triggers/jira/version_released.ts b/apps/sim/triggers/jira/version_released.ts index 8085bb8e2ff..844fbe283aa 100644 --- a/apps/sim/triggers/jira/version_released.ts +++ b/apps/sim/triggers/jira/version_released.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildVersionReleasedOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildVersionReleasedOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,48 +20,12 @@ export const jiraVersionReleasedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_version_released', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_version_released', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('jira:version_released'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_version_released', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_version_released', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('jira:version_released'), + extraFields: buildJiraExtraFields('jira_version_released'), + }), outputs: buildVersionReleasedOutputs(), diff --git a/apps/sim/triggers/jira/webhook.ts b/apps/sim/triggers/jira/webhook.ts index 57c94751935..12c34f5fb24 100644 --- a/apps/sim/triggers/jira/webhook.ts +++ b/apps/sim/triggers/jira/webhook.ts @@ -1,5 +1,16 @@ import { JiraIcon } from '@/components/icons' -import { buildIssueOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCommentOutputs, + buildIssueUpdatedOutputs, + buildJiraExtraFields, + buildProjectCreatedOutputs, + buildSprintOutputs, + buildVersionReleasedOutputs, + buildWorklogOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,130 +25,22 @@ export const jiraWebhookTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_webhook', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_webhook', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('All Events'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_webhook', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_webhook', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('All Events'), + extraFields: buildJiraExtraFields('jira_webhook'), + }), + // Superset of every event-specific output shape, since this trigger passes through + // whichever entity (issue/comment/worklog/sprint/project/version) the event carries. outputs: { - ...buildIssueOutputs(), - changelog: { - id: { - type: 'string', - description: 'Changelog ID', - }, - items: { - type: 'array', - description: - 'Array of changed items. Each item contains field, fieldtype, from, fromString, to, toString', - }, - }, - comment: { - id: { - type: 'string', - description: 'Comment ID', - }, - body: { - type: 'string', - description: 'Comment text/body', - }, - author: { - displayName: { - type: 'string', - description: 'Comment author display name', - }, - accountId: { - type: 'string', - description: 'Comment author account ID', - }, - emailAddress: { - type: 'string', - description: 'Comment author email address', - }, - }, - created: { - type: 'string', - description: 'Comment creation date (ISO format)', - }, - updated: { - type: 'string', - description: 'Comment last updated date (ISO format)', - }, - }, - worklog: { - id: { - type: 'string', - description: 'Worklog entry ID', - }, - author: { - displayName: { - type: 'string', - description: 'Worklog author display name', - }, - accountId: { - type: 'string', - description: 'Worklog author account ID', - }, - emailAddress: { - type: 'string', - description: 'Worklog author email address', - }, - }, - timeSpent: { - type: 'string', - description: 'Time spent (e.g., "2h 30m")', - }, - timeSpentSeconds: { - type: 'number', - description: 'Time spent in seconds', - }, - comment: { - type: 'string', - description: 'Worklog comment/description', - }, - started: { - type: 'string', - description: 'When the work was started (ISO format)', - }, - }, + ...buildIssueUpdatedOutputs(), + comment: buildCommentOutputs().comment, + worklog: buildWorklogOutputs().worklog, + sprint: buildSprintOutputs().sprint, + project: buildProjectCreatedOutputs().project, + version: buildVersionReleasedOutputs().version, }, webhook: { diff --git a/apps/sim/triggers/jira/worklog_created.ts b/apps/sim/triggers/jira/worklog_created.ts index 94f5e76ff82..e502be28f6f 100644 --- a/apps/sim/triggers/jira/worklog_created.ts +++ b/apps/sim/triggers/jira/worklog_created.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildWorklogOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildWorklogOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraWorklogCreatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_worklog_created', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_created', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ', - description: 'Filter which worklog entries trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_created', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('worklog_created'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_created', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_worklog_created', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('worklog_created'), + extraFields: buildJiraExtraFields( + 'jira_worklog_created', + 'Filter which worklog entries trigger this workflow using JQL' + ), + }), outputs: buildWorklogOutputs(), diff --git a/apps/sim/triggers/jira/worklog_deleted.ts b/apps/sim/triggers/jira/worklog_deleted.ts index d2a1c17d14c..0e51b5a52dc 100644 --- a/apps/sim/triggers/jira/worklog_deleted.ts +++ b/apps/sim/triggers/jira/worklog_deleted.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildWorklogOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildWorklogOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraWorklogDeletedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_worklog_deleted', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_deleted', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ', - description: 'Filter which worklog deletions trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_deleted', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('worklog_deleted'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_deleted', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_worklog_deleted', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('worklog_deleted'), + extraFields: buildJiraExtraFields( + 'jira_worklog_deleted', + 'Filter which worklog deletions trigger this workflow using JQL' + ), + }), outputs: buildWorklogOutputs(), diff --git a/apps/sim/triggers/jira/worklog_updated.ts b/apps/sim/triggers/jira/worklog_updated.ts index 5a26c7f7e33..208f0d404af 100644 --- a/apps/sim/triggers/jira/worklog_updated.ts +++ b/apps/sim/triggers/jira/worklog_updated.ts @@ -1,5 +1,11 @@ import { JiraIcon } from '@/components/icons' -import { buildWorklogOutputs, jiraSetupInstructions } from '@/triggers/jira/utils' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildJiraExtraFields, + buildWorklogOutputs, + jiraSetupInstructions, + jiraTriggerOptions, +} from '@/triggers/jira/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -14,61 +20,15 @@ export const jiraWorklogUpdatedTrigger: TriggerConfig = { version: '1.0.0', icon: JiraIcon, - 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: 'jira_worklog_updated', - }, - }, - { - id: 'webhookSecret', - title: 'Webhook Secret', - type: 'short-input', - placeholder: 'Enter a strong secret', - description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature', - password: true, - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_updated', - }, - }, - { - id: 'jqlFilter', - title: 'JQL Filter', - type: 'long-input', - placeholder: 'project = PROJ', - description: 'Filter which worklog updates trigger this workflow using JQL', - required: false, - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_updated', - }, - }, - { - id: 'triggerInstructions', - title: 'Setup Instructions', - hideFromPreview: true, - type: 'text', - defaultValue: jiraSetupInstructions('worklog_updated'), - mode: 'trigger', - condition: { - field: 'selectedTriggerId', - value: 'jira_worklog_updated', - }, - }, - ], + subBlocks: buildTriggerSubBlocks({ + triggerId: 'jira_worklog_updated', + triggerOptions: jiraTriggerOptions, + setupInstructions: jiraSetupInstructions('worklog_updated'), + extraFields: buildJiraExtraFields( + 'jira_worklog_updated', + 'Filter which worklog updates trigger this workflow using JQL' + ), + }), outputs: buildWorklogOutputs(), From 6ac263e330b1b79c1b25094a3f7e722df4cb9dcc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:10:39 -0700 Subject: [PATCH 05/23] fix(salesforce): correct setup instructions for Flow HTTP Callout auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salesforce Flow's HTTP Callout action requires a Named Credential (and an External Credential for auth headers) pointing at the target URL — it cannot call an arbitrary URL with inline headers as the previous instructions implied. Update both the generic and per-event setup instructions to walk through creating the External/Named Credential first, and drop the inaccurate "connectivity checks" claim. --- apps/sim/triggers/salesforce/utils.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index 99a1fc10230..5b8e26e28e1 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -153,20 +153,20 @@ export function salesforceSetupInstructions(eventType: string): string { const instructions = isGeneric ? [ '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.', + "In Salesforce, go to Setup → Named Credentials. Create an External Credential with a custom Authentication Parameter holding your secret, then a Named Credential whose URL is the Webhook URL above, with a custom header Authorization set to {!'Bearer ' & $Credential.YourExternalCredential.YourParameter} (or a header named X-Sim-Webhook-Secret with the raw secret). Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.", + 'Go to Setup → Flows and click New Flow.', 'Select Record-Triggered Flow and choose the object(s) you want to monitor.', - 'Add an Action that performs an HTTP Callout — method POST, Content-Type: application/json, and paste the webhook URL.', + 'Add an Action that performs an HTTP Callout using the Named Credential from step 2 — method POST, Content-Type: application/json, path / (the base URL already points at your webhook).', '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.', + 'Save this trigger in Sim first so the URL is registered before you build the Named Credential and Flow.', 'Click "Save" above to activate your trigger.', ] : [ - '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.', + 'Copy the Webhook URL above and set a Webhook Secret. In Salesforce, create an External Credential (holding the secret) and a Named Credential whose URL is this Webhook URL, with a custom header sending the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: …. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.', + 'Go to Setup → Flows and click New Flow.', `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`, - 'Add an HTTP CalloutPOST, JSON body, URL = webhook URL.', + 'Add an HTTP Callout using the Named Credential from step 1 — POST, JSON body, path /.', `Include eventType in the JSON body using a value this trigger accepts (e.g. for Record Created use record_created, created, or after_insert).`, '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.', From 94c80a575c50287466b2f4193e348eb24bd338eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:14:44 -0700 Subject: [PATCH 06/23] fix(hubspot): cap advanced filters to stay within HubSpot's per-group limit HubSpot's Search API rejects any filterGroup with more than 6 filters. buildUserFilters combines pipeline/stage/owner shortcuts with user-supplied JSON filters and spread them into both filter groups uncapped; Group B reserves 2 slots for the timestamp/id tie-break, so as few as 3 shortcuts plus 2 advanced filters silently broke every poll with an opaque 400 from HubSpot. Cap combined filters at 4 and warn when truncating. Added hubspot.test.ts covering buildUserFilters (shortcuts, JSON parsing, invalid-operator drop, malformed JSON, and the new cap) since the file had no prior test coverage. --- apps/sim/lib/webhooks/polling/hubspot.test.ts | 88 +++++++++++++++++++ apps/sim/lib/webhooks/polling/hubspot.ts | 17 +++- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/webhooks/polling/hubspot.test.ts diff --git a/apps/sim/lib/webhooks/polling/hubspot.test.ts b/apps/sim/lib/webhooks/polling/hubspot.test.ts new file mode 100644 index 00000000000..3c7ac92da21 --- /dev/null +++ b/apps/sim/lib/webhooks/polling/hubspot.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildUserFilters } from '@/lib/webhooks/polling/hubspot' + +describe('buildUserFilters', () => { + it('translates pipeline/stage/owner shortcuts into EQ filters', () => { + const filters = buildUserFilters({ + objectType: 'deal', + pipelineId: 'pipeline-1', + stageId: 'stage-1', + ownerId: 'owner-1', + }) + + expect(filters).toEqual([ + { propertyName: 'pipeline', operator: 'EQ', value: 'pipeline-1' }, + { propertyName: 'dealstage', operator: 'EQ', value: 'stage-1' }, + { propertyName: 'hubspot_owner_id', operator: 'EQ', value: 'owner-1' }, + ]) + }) + + it('uses ticket-specific pipeline/stage property names', () => { + const filters = buildUserFilters({ + objectType: 'ticket', + pipelineId: 'pipeline-1', + stageId: 'stage-1', + }) + + expect(filters).toEqual([ + { propertyName: 'hs_pipeline', operator: 'EQ', value: 'pipeline-1' }, + { propertyName: 'hs_pipeline_stage', operator: 'EQ', value: 'stage-1' }, + ]) + }) + + it('parses advanced JSON filters and preserves values arrays', () => { + const filters = buildUserFilters({ + filters: JSON.stringify([ + { propertyName: 'lifecyclestage', operator: 'EQ', value: 'customer' }, + { propertyName: 'dealstage', operator: 'IN', values: ['a', 'b'] }, + ]), + }) + + expect(filters).toEqual([ + { propertyName: 'lifecyclestage', operator: 'EQ', value: 'customer' }, + { propertyName: 'dealstage', operator: 'IN', values: ['a', 'b'] }, + ]) + }) + + it('drops filter entries with an unrecognized operator', () => { + const filters = buildUserFilters({ + filters: JSON.stringify([ + { propertyName: 'amount', operator: 'STARTS_WITH', value: '1' }, + { propertyName: 'amount', operator: 'GT', value: '1' }, + ]), + }) + + expect(filters).toEqual([{ propertyName: 'amount', operator: 'GT', value: '1' }]) + }) + + it('ignores malformed JSON filters without throwing', () => { + expect(() => buildUserFilters({ filters: 'not json' })).not.toThrow() + expect(buildUserFilters({ filters: 'not json' })).toEqual([]) + }) + + it('caps combined shortcut + advanced filters at the HubSpot per-group limit', () => { + const filters = buildUserFilters({ + objectType: 'deal', + pipelineId: 'pipeline-1', + stageId: 'stage-1', + ownerId: 'owner-1', + filters: JSON.stringify([ + { propertyName: 'amount', operator: 'GT', value: '1000' }, + { propertyName: 'lifecyclestage', operator: 'EQ', value: 'customer' }, + ]), + }) + + // 3 shortcuts + 2 advanced = 5 raw filters, capped to 4 so Group B + // (2 reserved slots + user filters) never exceeds HubSpot's 6-filter-per-group max. + expect(filters).toHaveLength(4) + expect(filters).toEqual([ + { propertyName: 'pipeline', operator: 'EQ', value: 'pipeline-1' }, + { propertyName: 'dealstage', operator: 'EQ', value: 'stage-1' }, + { propertyName: 'hubspot_owner_id', operator: 'EQ', value: 'owner-1' }, + { propertyName: 'amount', operator: 'GT', value: '1000' }, + ]) + }) +}) diff --git a/apps/sim/lib/webhooks/polling/hubspot.ts b/apps/sim/lib/webhooks/polling/hubspot.ts index 65c64ecffa7..03b89d90605 100644 --- a/apps/sim/lib/webhooks/polling/hubspot.ts +++ b/apps/sim/lib/webhooks/polling/hubspot.ts @@ -101,6 +101,14 @@ const MAX_MAX_RECORDS = 1000 const MAX_PAGES_PER_POLL = 10 /** Cap on property-change snapshot size to bound providerConfig payload. */ const MAX_SNAPSHOT_SIZE = 1000 +/** + * HubSpot Search API caps each filterGroup at 6 filters. `buildBody` reserves 2 slots in + * Group B (filterProperty EQ + hs_object_id GT), so user-supplied filters (pipeline/stage/ + * owner shortcuts plus advanced JSON filters) must leave room for those — cap at 4 so Group + * B never exceeds 6. Excess filters are dropped rather than sent, which would otherwise fail + * every poll with an opaque 400 from HubSpot. + */ +const MAX_USER_FILTERS = 4 const BUILT_IN_PATH: Record = { contact: 'contacts', @@ -530,7 +538,7 @@ function resolveRequestedProperties( return [...requested] } -function buildUserFilters( +export function buildUserFilters( config: HubSpotWebhookConfig, logger?: Logger, requestId?: string @@ -577,6 +585,13 @@ function buildUserFilters( } } + if (filters.length > MAX_USER_FILTERS) { + logger?.warn( + `[${requestId ?? ''}] HubSpot filters exceed the ${MAX_USER_FILTERS}-filter limit (pipeline/stage/owner shortcuts + advanced filters combined); dropping the last ${filters.length - MAX_USER_FILTERS}` + ) + return filters.slice(0, MAX_USER_FILTERS) + } + return filters } From 06dd7ea847cbaab28f5b5d5c47b840bc024bb603 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:11:41 -0700 Subject: [PATCH 07/23] test(zendesk): add handler tests for webhook trigger signature/idempotency/format-input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the Zendesk webhook trigger (signature verification, event matching, output-schema mapping, idempotency) against live Zendesk webhook docs and the repo's trigger conventions. No bugs found — the existing implementation already correctly uses base64 HMAC-SHA256 over timestamp+body (not hex), safeCompare, fail-closed auth, the native event-subscription payload shape (not the admin-configurable Trigger/Automation payload), and null-safe body handling. Added colocated tests to lock in this behavior, matching the gitlab/linear test pattern. --- .../lib/webhooks/providers/zendesk.test.ts | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 apps/sim/lib/webhooks/providers/zendesk.test.ts diff --git a/apps/sim/lib/webhooks/providers/zendesk.test.ts b/apps/sim/lib/webhooks/providers/zendesk.test.ts new file mode 100644 index 00000000000..7c88f4a6723 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/zendesk.test.ts @@ -0,0 +1,226 @@ +/** + * @vitest-environment node + */ +import crypto from 'crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { zendeskHandler } from '@/lib/webhooks/providers/zendesk' +import { isZendeskEventMatch } from '@/triggers/zendesk/utils' + +const SECRET = 'my-signing-secret' + +function sign(secret: string, timestamp: string, body: string): string { + return crypto + .createHmac('sha256', secret) + .update(timestamp + body, 'utf8') + .digest('base64') +} + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('Zendesk webhook provider', () => { + describe('verifyAuth', () => { + it('rejects when webhookSecret is missing', async () => { + const res = await zendeskHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects when signature headers are missing', async () => { + const res = await zendeskHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects a stale timestamp outside the allowed skew window', async () => { + const body = '{}' + const timestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const signature = sign(SECRET, timestamp, body) + const res = await zendeskHandler.verifyAuth!({ + request: reqWithHeaders({ + 'X-Zendesk-Webhook-Signature': signature, + 'X-Zendesk-Webhook-Signature-Timestamp': timestamp, + }), + rawBody: body, + requestId: 't3', + providerConfig: { webhookSecret: SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects an invalid signature', async () => { + const body = '{}' + const timestamp = new Date().toISOString() + const res = await zendeskHandler.verifyAuth!({ + request: reqWithHeaders({ + 'X-Zendesk-Webhook-Signature': 'not-a-real-signature', + 'X-Zendesk-Webhook-Signature-Timestamp': timestamp, + }), + rawBody: body, + requestId: 't4', + providerConfig: { webhookSecret: SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('accepts a valid base64 HMAC-SHA256 signature over timestamp + body', async () => { + const body = JSON.stringify({ id: 'evt-1', type: 'zen:event-type:ticket.created' }) + const timestamp = new Date().toISOString() + const signature = sign(SECRET, timestamp, body) + const res = await zendeskHandler.verifyAuth!({ + request: reqWithHeaders({ + 'X-Zendesk-Webhook-Signature': signature, + 'X-Zendesk-Webhook-Signature-Timestamp': timestamp, + }), + rawBody: body, + requestId: 't5', + providerConfig: { webhookSecret: SECRET }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + }) + + describe('isZendeskEventMatch', () => { + it('matches the configured trigger to its native event type', () => { + expect(isZendeskEventMatch('zendesk_ticket_created', 'zen:event-type:ticket.created')).toBe( + true + ) + expect( + isZendeskEventMatch('zendesk_ticket_created', 'zen:event-type:ticket.status_changed') + ).toBe(false) + expect(isZendeskEventMatch('zendesk_webhook', 'zen:event-type:ticket.created')).toBe(true) + }) + }) + + describe('matchEvent', () => { + it('passes through all events for the all-events trigger', async () => { + const result = await zendeskHandler.matchEvent!({ + body: { type: 'zen:event-type:ticket.status_changed' }, + requestId: 't6', + providerConfig: { triggerId: 'zendesk_webhook' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + + it('filters events that do not match the configured trigger', async () => { + const result = await zendeskHandler.matchEvent!({ + body: { type: 'zen:event-type:ticket.status_changed' }, + requestId: 't7', + providerConfig: { triggerId: 'zendesk_ticket_created' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + + it('does not throw when body is null', async () => { + const result = await zendeskHandler.matchEvent!({ + body: null, + requestId: 't8', + providerConfig: { triggerId: 'zendesk_ticket_created' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + }) + + describe('formatInput', () => { + it('maps the event-subscription envelope to the declared output schema', async () => { + const { input } = await zendeskHandler.formatInput!({ + body: { + id: 'evt-1', + type: 'zen:event-type:ticket.created', + time: '2026-01-01T00:00:00Z', + account_id: 123, + detail: { + id: '456', + subject: 'Help', + status: 'new', + priority: 'high', + type: 'incident', + description: 'desc', + requester_id: '1', + assignee_id: '2', + group_id: '3', + organization_id: '4', + tags: ['a', 'b'], + via: { channel: 'web' }, + is_public: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + event: { current: 'high', previous: 'normal' }, + }, + headers: {}, + requestId: 't9', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.event_id).toBe('evt-1') + expect(i.event_type).toBe('zen:event-type:ticket.created') + expect(i.account_id).toBe(123) + const ticket = i.ticket as Record + expect(ticket.id).toBe('456') + expect(ticket.ticket_type).toBe('incident') + expect(ticket.via_channel).toBe('web') + expect(ticket.tags).toEqual(['a', 'b']) + expect(i.event).toEqual({ current: 'high', previous: 'normal' }) + }) + + it('does not throw and degrades gracefully when body is null', async () => { + const { input } = await zendeskHandler.formatInput!({ + body: null, + headers: {}, + requestId: 't10', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.event_id).toBeUndefined() + const ticket = i.ticket as Record + expect(ticket.id).toBeUndefined() + expect(ticket.tags).toEqual([]) + }) + }) + + describe('extractIdempotencyId', () => { + it('returns the stable event id', () => { + expect(zendeskHandler.extractIdempotencyId!({ id: 'evt-1' })).toBe('evt-1') + }) + + it('returns null when there is no id', () => { + expect(zendeskHandler.extractIdempotencyId!({})).toBeNull() + }) + + it('does not throw when body is null', () => { + expect(zendeskHandler.extractIdempotencyId!(null)).toBeNull() + }) + }) +}) From ef5607be7904561e0a4051769855aa0fccfd99e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:13:48 -0700 Subject: [PATCH 08/23] fix(microsoft-teams): persist subscription expiration, close auth/idempotency gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createSubscription never wrote subscriptionExpiration into providerConfig, so the renewal cron's `if (!expirationStr) continue` guard permanently skipped every Teams chat subscription — they silently expired after ~3 days (Graph's chatMessage max lifetime) and were never renewed. Persist it on both initial creation and the existing-subscription reuse path. - verifyAuth only checked HMAC when providerConfig.hmacSecret happened to be present, silently accepting unauthenticated requests for outgoing webhook triggers if it was ever missing. Fail closed instead. - extractIdempotencyId/enrichHeaders only handled Graph notification payloads (the `value` array shape), so outgoing webhook channel messages never got a stable idempotency key and fell back to a random one on every delivery. Extend to key off the Bot Framework Activity `id`, and guard the parsing with isRecordLike instead of an unchecked cast. - formatInput declared channelData.teamsTeamId/teamsChannelId in the trigger's output schema but never populated them (Teams doesn't send those as literal wire keys). Compute them from channelData.team.id / channelData.channel.id. - The block's triggers.available list only listed microsoftteams_webhook, hiding the chat subscription trigger from Copilot's block metadata tool and other consumers of that list. Add it, matching the Jira/Linear pattern for multi-trigger blocks. --- apps/sim/blocks/blocks/microsoft_teams.ts | 2 +- .../providers/microsoft-teams.test.ts | 106 +++++++++++++++++- .../lib/webhooks/providers/microsoft-teams.ts | 92 ++++++++++----- 3 files changed, 172 insertions(+), 28 deletions(-) diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 81e6462cb8d..1b4b093b817 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -532,7 +532,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { }, triggers: { enabled: true, - available: ['microsoftteams_webhook'], + available: ['microsoftteams_webhook', 'microsoftteams_chat_subscription'], }, } diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts index bb3e2c141e3..d209d33695a 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { hmacSha256Base64 } from '@sim/security/hmac' import { authOAuthUtilsMock, inputValidationMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -112,9 +113,110 @@ describe('microsoftTeamsHandler verifyAuth (chat subscription clientState)', () }) expect(res?.status).toBe(401) }) +}) + +describe('microsoftTeamsHandler verifyAuth (outgoing webhook HMAC)', () => { + const secretBase64 = Buffer.from('super-secret').toString('base64') + const outgoingWebhookConfig = { triggerId: 'microsoftteams_webhook', hmacSecret: secretBase64 } + + beforeEach(() => { + vi.clearAllMocks() + }) - it('does not require clientState for non-subscription trigger types', async () => { - const res = await runVerifyAuth(JSON.stringify({ type: 'message', text: 'hi' }), {}) + function signedRequest(rawBody: string): NextRequest { + const signature = hmacSha256Base64(rawBody, Buffer.from(secretBase64, 'base64')) + return new NextRequest('https://app.example.com/api/webhooks/trigger/abc', { + method: 'POST', + body: rawBody, + headers: { 'Content-Type': 'application/json', Authorization: `HMAC ${signature}` }, + }) + } + + it('accepts a request with a valid HMAC signature', async () => { + const rawBody = JSON.stringify({ type: 'message', text: 'hi' }) + const res = await microsoftTeamsHandler.verifyAuth!({ + webhook: { id: WEBHOOK_ID }, + workflow: {}, + request: signedRequest(rawBody), + rawBody, + requestId: 'test-req', + providerConfig: outgoingWebhookConfig, + }) expect(res).toBeNull() }) + + it('rejects a request with an invalid HMAC signature', async () => { + const rawBody = JSON.stringify({ type: 'message', text: 'hi' }) + const res = await microsoftTeamsHandler.verifyAuth!({ + webhook: { id: WEBHOOK_ID }, + workflow: {}, + request: makeRequest(rawBody), + rawBody, + requestId: 'test-req', + providerConfig: { ...outgoingWebhookConfig, hmacSecret: undefined }, + }) + expect(res?.status).toBe(401) + }) + + it('fails closed when no HMAC secret is configured for an outgoing webhook trigger', async () => { + const rawBody = JSON.stringify({ type: 'message', text: 'hi' }) + const res = await runVerifyAuth(rawBody, { triggerId: 'microsoftteams_webhook' }) + expect(res?.status).toBe(401) + }) +}) + +describe('microsoftTeamsHandler extractIdempotencyId', () => { + it('derives a key from subscriptionId + messageId for Graph change notifications', () => { + const body = JSON.parse(makeNotificationBody(WEBHOOK_ID)) as unknown + expect(microsoftTeamsHandler.extractIdempotencyId!(body)).toBe(`sub-1:1700000000000`) + }) + + it('derives a key from the Activity id for outgoing webhook messages', () => { + const body = { id: 'activity-123', type: 'message', text: 'hi' } + expect(microsoftTeamsHandler.extractIdempotencyId!(body)).toBe('activity-123') + }) + + it('returns null when neither shape yields a stable identifier', () => { + expect(microsoftTeamsHandler.extractIdempotencyId!({ type: 'message' })).toBeNull() + }) + + it('returns null instead of throwing when body is null', () => { + expect(microsoftTeamsHandler.extractIdempotencyId!(null)).toBeNull() + }) + + it('returns null instead of throwing when body is a primitive', () => { + expect(microsoftTeamsHandler.extractIdempotencyId!('not-an-object')).toBeNull() + }) +}) + +describe('microsoftTeamsHandler formatInput (outgoing webhook channelData)', () => { + it('populates teamsTeamId/teamsChannelId from nested team/channel ids', async () => { + const body = { + id: 'activity-123', + type: 'message', + text: 'hello', + channelData: { + team: { id: 'team-1' }, + channel: { id: 'channel-1' }, + tenant: { id: 'tenant-1' }, + }, + } + const result = await microsoftTeamsHandler.formatInput!({ + body, + webhook: {}, + workflow: { id: 'wf-1', userId: 'user-1' }, + headers: {}, + requestId: 'test-req', + }) + const input = result.input as { + message: { raw: { channelData: Record } } + } + expect(input.message.raw.channelData).toEqual({ + team: { id: 'team-1' }, + tenant: { id: 'tenant-1' }, + channel: { id: 'channel-1' }, + teamsTeamId: 'team-1', + teamsChannelId: 'channel-1', + }) + }) }) diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index e073f25f184..eabb0cb656c 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { isMicrosoftContentUrl } from '@/lib/core/security/input-validation' @@ -54,24 +55,37 @@ function validateMicrosoftTeamsSignature( } } -function parseFirstNotification( - body: unknown -): { subscriptionId: string; messageId: string } | null { - const obj = body as Record - const value = obj.value as unknown[] | undefined - if (!Array.isArray(value) || value.length === 0) { +/** + * Derive a stable per-delivery identifier from a Teams webhook body. + * + * Graph change notifications (chat subscriptions) are keyed by + * `subscriptionId:messageId`. Outgoing webhook activities (channel + * @mentions) carry no `value` array — they're keyed by the Bot Framework + * Activity's own unique `id` instead. + */ +function extractNotificationKey(body: unknown): string | null { + if (!isRecordLike(body)) { return null } - const notification = value[0] as Record - const subscriptionId = notification.subscriptionId as string | undefined - const resourceData = notification.resourceData as Record | undefined - const messageId = resourceData?.id as string | undefined + const value = body.value + if (Array.isArray(value) && value.length > 0) { + const notification = value[0] + if (!isRecordLike(notification)) { + return null + } + const subscriptionId = notification.subscriptionId + const resourceData = notification.resourceData + const messageId = isRecordLike(resourceData) ? resourceData.id : undefined - if (subscriptionId && messageId) { - return { subscriptionId, messageId } + if (typeof subscriptionId === 'string' && typeof messageId === 'string') { + return `${subscriptionId}:${messageId}` + } + return null } - return null + + const activityId = body.id + return typeof activityId === 'string' && activityId ? activityId : null } async function fetchWithDNSPinning( @@ -478,7 +492,17 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { }, verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) { - if (providerConfig.hmacSecret) { + if (providerConfig.triggerId !== 'microsoftteams_chat_subscription') { + const hmacSecret = providerConfig.hmacSecret as string | undefined + if (!hmacSecret) { + logger.error( + `[${requestId}] Microsoft Teams outgoing webhook missing configured HMAC secret` + ) + return new NextResponse('Unauthorized - Missing HMAC secret configuration', { + status: 401, + }) + } + const authHeader = request.headers.get('authorization') if (!authHeader || !authHeader.startsWith('HMAC ')) { @@ -488,9 +512,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 }) } - if ( - !validateMicrosoftTeamsSignature(providerConfig.hmacSecret as string, authHeader, rawBody) - ) { + if (!validateMicrosoftTeamsSignature(hmacSecret, authHeader, rawBody)) { logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 }) } @@ -541,15 +563,14 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { }, enrichHeaders({ body }: EventFilterContext, headers: Record) { - const parsed = parseFirstNotification(body) - if (parsed) { - headers['x-teams-notification-id'] = `${parsed.subscriptionId}:${parsed.messageId}` + const key = extractNotificationKey(body) + if (key) { + headers['x-teams-notification-id'] = key } }, extractIdempotencyId(body: unknown) { - const parsed = parseFirstNotification(body) - return parsed ? `${parsed.subscriptionId}:${parsed.messageId}` : null + return extractNotificationKey(body) }, formatSuccessResponse(providerConfig: Record) { @@ -620,10 +641,16 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } ) if (checkRes.ok) { + const existingPayload = await checkRes.json() logger.info( `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` ) - return { providerConfigUpdates: { externalSubscriptionId: existingSubscriptionId } } + return { + providerConfigUpdates: { + externalSubscriptionId: existingSubscriptionId, + subscriptionExpiration: existingPayload.expirationDateTime, + }, + } } } catch { logger.debug(`[${requestId}] Existing subscription check failed, will create new one`) @@ -685,7 +712,12 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { logger.info( `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` ) - return { providerConfigUpdates: { externalSubscriptionId: payload.id as string } } + return { + providerConfigUpdates: { + externalSubscriptionId: payload.id as string, + subscriptionExpiration: payload.expirationDateTime as string, + }, + } } catch (error: unknown) { if ( error instanceof Error && @@ -793,6 +825,10 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { const timestamp = (b?.timestamp as string) || (b?.localTimestamp as string) || '' const from = (b?.from || {}) as Record const conversation = (b?.conversation || {}) as Record + const channelData = (b?.channelData || {}) as Record + const channelDataTeam = (channelData.team || {}) as Record + const channelDataChannel = (channelData.channel || {}) as Record + const channelDataTenant = (channelData.tenant || {}) as Record return { input: { @@ -804,7 +840,13 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { message: { raw: { attachments: b?.attachments || [], - channelData: b?.channelData || {}, + channelData: { + team: { id: (channelDataTeam.id || '') as string }, + tenant: { id: (channelDataTenant.id || '') as string }, + channel: { id: (channelDataChannel.id || '') as string }, + teamsTeamId: (channelData.teamsTeamId || channelDataTeam.id || '') as string, + teamsChannelId: (channelData.teamsChannelId || channelDataChannel.id || '') as string, + }, conversation: b?.conversation || {}, text: messageText, messageType: (b?.type || 'message') as string, From f42a3655d46ca2da9dce385e3bf9175a098f5f19 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:10:44 -0700 Subject: [PATCH 09/23] fix(sentry): guard webhook handler against null/non-object body matchEvent cast body to Record without a null check, so a validly-signed webhook delivery with a null (or non-object) JSON body threw inside the shared webhook loop, aborting processing for any other webhooks sharing the same path. formatInput/extractIdempotencyId already tolerated null via `|| {}`/optional chaining but didn't match the isRecordLike convention used by sibling providers (linear, sendblue, instantly). Aligned all three methods on isRecordLike and added regression tests for a null body. --- .../sim/lib/webhooks/providers/sentry.test.ts | 38 +++++++++++++++++++ apps/sim/lib/webhooks/providers/sentry.ts | 29 +++++++------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/sentry.test.ts b/apps/sim/lib/webhooks/providers/sentry.test.ts index 00da0c3d75e..e79bd3741f4 100644 --- a/apps/sim/lib/webhooks/providers/sentry.test.ts +++ b/apps/sim/lib/webhooks/providers/sentry.test.ts @@ -182,4 +182,42 @@ describe('Sentry webhook provider', () => { }) expect(id).toBe('sentry:issue:42:created') }) + + it('does not match when the body is null', async () => { + const request = new NextRequest('http://localhost/test', { + headers: { 'Sentry-Hook-Resource': 'issue' }, + }) + + const matched = await sentryHandler.matchEvent!({ + body: null, + request, + requestId: 'sentry-t8', + providerConfig: { triggerId: 'sentry_issue_created' }, + webhook: {}, + workflow: {}, + }) + + expect(matched).toBe(false) + }) + + it('formats input with an empty envelope when the body is null', async () => { + const result = await sentryHandler.formatInput!({ + body: null, + headers: { 'sentry-hook-resource': 'issue' }, + webhook: {}, + workflow: { id: 'wf-1', userId: 'user-1' }, + requestId: 'sentry-t9', + }) + + expect(result.input).toEqual({ + action: '', + installation: null, + actor: null, + issue: null, + }) + }) + + it('returns null idempotency id when the body is null', () => { + expect(sentryHandler.extractIdempotencyId!(null)).toBeNull() + }) }) diff --git a/apps/sim/lib/webhooks/providers/sentry.ts b/apps/sim/lib/webhooks/providers/sentry.ts index 746147d1097..b16feb0ad40 100644 --- a/apps/sim/lib/webhooks/providers/sentry.ts +++ b/apps/sim/lib/webhooks/providers/sentry.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' +import { isRecordLike } from '@sim/utils/object' import type { EventMatchContext, FormatInputContext, @@ -40,9 +41,8 @@ const SENTRY_RESOURCE_HEADER = 'sentry-hook-resource' * tag dropdown (the original `type` is preserved on the passthrough object). */ function aliasEventType(entity: unknown): Record | null { - if (!entity || typeof entity !== 'object') return null - const obj = entity as Record - return { ...obj, eventType: obj.type ?? null } + if (!isRecordLike(entity)) return null + return { ...entity, eventType: entity.type ?? null } } export const sentryHandler: WebhookProviderHandler = { @@ -58,8 +58,8 @@ export const sentryHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (triggerId) { const resource = request.headers.get(SENTRY_RESOURCE_HEADER) - const obj = body as Record - const action = obj.action as string | undefined + const obj = isRecordLike(body) ? body : {} + const action = typeof obj.action === 'string' ? obj.action : undefined const { isSentryEventMatch } = await import('@/triggers/sentry/utils') if (!isSentryEventMatch(triggerId, resource, action)) { @@ -73,8 +73,8 @@ export const sentryHandler: WebhookProviderHandler = { }, async formatInput({ body, headers }: FormatInputContext): Promise { - const b = (body as Record) || {} - const data = (b.data as Record) || {} + const b = isRecordLike(body) ? body : {} + const data = isRecordLike(b.data) ? b.data : {} const resource = headers[SENTRY_RESOURCE_HEADER] || '' const envelope = { @@ -113,26 +113,27 @@ export const sentryHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown): string | null { - const obj = body as Record - const data = (obj?.data as Record) || {} - const action = typeof obj?.action === 'string' ? obj.action : '' + if (!isRecordLike(body)) return null - const issue = data.issue as Record | undefined + const data = isRecordLike(body.data) ? body.data : {} + const action = typeof body.action === 'string' ? body.action : '' + + const issue = isRecordLike(data.issue) ? data.issue : undefined if (issue?.id) { return `sentry:issue:${issue.id}:${action}` } - const error = data.error as Record | undefined + const error = isRecordLike(data.error) ? data.error : undefined if (error?.event_id) { return `sentry:error:${error.event_id}` } - const event = data.event as Record | undefined + const event = isRecordLike(data.event) ? data.event : undefined if (event?.event_id) { return `sentry:event_alert:${event.event_id}` } - const metricAlert = data.metric_alert as Record | undefined + const metricAlert = isRecordLike(data.metric_alert) ? data.metric_alert : undefined if (metricAlert?.id) { return `sentry:metric_alert:${metricAlert.id}:${action}` } From 9a4a559f398e43b6f6e191bddd1c420c8ec3f229 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:10:16 -0700 Subject: [PATCH 10/23] fix(twilio): guard SMS and Voice webhook handlers against non-object body matchEvent, extractIdempotencyId, and formatInput cast body directly to Record without checking it was actually an object, so a malformed or non-form-encoded request (e.g. JSON body "null") would throw instead of degrading gracefully. Use isRecordLike, matching the pattern already used in linear.ts/sendblue.ts/instantly.ts. --- .../webhooks/providers/twilio-voice.test.ts | 22 +++++++++++++++++++ .../lib/webhooks/providers/twilio-voice.ts | 7 +++--- .../sim/lib/webhooks/providers/twilio.test.ts | 20 +++++++++++++++++ apps/sim/lib/webhooks/providers/twilio.ts | 8 ++++--- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts index f01d1333986..6496b31b699 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts @@ -81,5 +81,27 @@ describe('twilioVoiceHandler', () => { expect(twilioVoiceHandler.extractIdempotencyId!({ CallSid: 'CA1' })).toBe('CA1') expect(twilioVoiceHandler.extractIdempotencyId!({})).toBeNull() }) + + it('returns null instead of throwing when body is not a record', () => { + expect(twilioVoiceHandler.extractIdempotencyId!(null)).toBeNull() + expect(twilioVoiceHandler.extractIdempotencyId!(undefined)).toBeNull() + expect(twilioVoiceHandler.extractIdempotencyId!('not-an-object')).toBeNull() + expect(twilioVoiceHandler.extractIdempotencyId!([1, 2, 3])).toBeNull() + }) + }) + + describe('formatInput', () => { + it('degrades to empty output instead of throwing when body is not a record', async () => { + const { input } = await twilioVoiceHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf1', userId: 'u1' }, + body: null, + headers: {}, + requestId: 'r1', + }) + const i = input as Record + expect(i.callSid).toBeUndefined() + expect(i.raw).toBe('{}') + }) }) }) diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts index c00208307b5..f1aa6bcfc6f 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import { verifyTwilioAuth } from '@/lib/webhooks/providers/twilio-signature' import type { @@ -14,8 +15,8 @@ export const twilioVoiceHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const obj = body as Record - return (obj.MessageSid as string) || (obj.CallSid as string) || null + if (!isRecordLike(body)) return null + return (body.MessageSid as string) || (body.CallSid as string) || null }, formatSuccessResponse(providerConfig: Record) { @@ -46,7 +47,7 @@ export const twilioVoiceHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record + const b = isRecordLike(body) ? body : {} return { input: { callSid: b.CallSid, diff --git a/apps/sim/lib/webhooks/providers/twilio.test.ts b/apps/sim/lib/webhooks/providers/twilio.test.ts index 6b7d3916b07..7793e6be8f6 100644 --- a/apps/sim/lib/webhooks/providers/twilio.test.ts +++ b/apps/sim/lib/webhooks/providers/twilio.test.ts @@ -135,6 +135,13 @@ describe('twilioHandler', () => { expect(twilioHandler.extractIdempotencyId!({})).toBeNull() }) + it('returns null instead of throwing when body is not a record', () => { + expect(twilioHandler.extractIdempotencyId!(null)).toBeNull() + expect(twilioHandler.extractIdempotencyId!(undefined)).toBeNull() + expect(twilioHandler.extractIdempotencyId!('not-an-object')).toBeNull() + expect(twilioHandler.extractIdempotencyId!([1, 2, 3])).toBeNull() + }) + it('keys status callbacks by SID + status so each delivery state is distinct', () => { const sent = twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', MessageStatus: 'sent' }) const delivered = twilioHandler.extractIdempotencyId!({ @@ -181,6 +188,11 @@ describe('twilioHandler', () => { expect(match('twilio_sms_received', ambiguous)).toBe(false) expect(match('twilio_sms_status', ambiguous)).toBe(false) }) + + it('matches neither trigger instead of throwing when body is not a record', () => { + expect(match('twilio_sms_received', null as never)).toBe(false) + expect(match('twilio_sms_status', null as never)).toBe(false) + }) }) describe('formatInput', () => { @@ -252,5 +264,13 @@ describe('twilioHandler', () => { expect(i.errorCode).toBe('30008') expect(i.media).toEqual([]) }) + + it('degrades to empty output instead of throwing when body is not a record', async () => { + const { input } = await twilioHandler.formatInput!(ctx(null as never)) + const i = input as Record + expect(i.messageSid).toBeUndefined() + expect(i.media).toEqual([]) + expect(i.raw).toBe('{}') + }) }) }) diff --git a/apps/sim/lib/webhooks/providers/twilio.ts b/apps/sim/lib/webhooks/providers/twilio.ts index f501f32c590..30474848e21 100644 --- a/apps/sim/lib/webhooks/providers/twilio.ts +++ b/apps/sim/lib/webhooks/providers/twilio.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { verifyTwilioAuth } from '@/lib/webhooks/providers/twilio-signature' import type { AuthContext, @@ -36,7 +37,7 @@ export const twilioHandler: WebhookProviderHandler = { matchEvent({ body, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined if (!triggerId) return true - const b = body as Record + const b = isRecordLike(body) ? body : {} const messageStatus = ((b.MessageStatus as string) ?? '').toLowerCase() const smsStatus = ((b.SmsStatus as string) ?? '').toLowerCase() const isInbound = smsStatus === 'received' || messageStatus === 'received' @@ -47,7 +48,8 @@ export const twilioHandler: WebhookProviderHandler = { }, extractIdempotencyId(body: unknown) { - const obj = body as Record + if (!isRecordLike(body)) return null + const obj = body const sid = (obj.MessageSid as string) || (obj.CallSid as string) if (!sid) return null // Status callbacks repeat for the same SID as the message progresses @@ -62,7 +64,7 @@ export const twilioHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record + const b = isRecordLike(body) ? body : {} return { input: { messageSid: b.MessageSid, From c8ed9cc8c5ce2f44e43b6ee398e02039b414c4c2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:27:33 -0700 Subject: [PATCH 11/23] chore(slack): convert inline rationale comments to TSDoc Adversarial re-audit of the null-body-guard fix: no correctness issues found, backwards compatibility confirmed strictly additive. Per repo convention, converts non-TSDoc inline // comments in slack.ts to TSDoc blocks on the relevant declarations (formatSlackInteractive, extractIdempotencyId, formatInput) and drops two low-value inline comments in slack.test.ts that just restated adjacent assertions. --- apps/sim/lib/webhooks/providers/slack.test.ts | 2 -- apps/sim/lib/webhooks/providers/slack.ts | 23 ++++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index fa00c981223..f5519047669 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -37,7 +37,6 @@ describe('slackHandler formatInput - Events API', () => { expect(event.thread_ts).toBe('111.000') expect(event.team_id).toBe('T1') expect(event.event_id).toBe('Ev1') - // Interactivity-only fields stay empty for Events API payloads. expect(event.command).toBe('') expect(event.action_value).toBe('') expect(event.actions).toEqual([]) @@ -153,7 +152,6 @@ describe('slackHandler formatInput - interactivity (block_actions)', () => { ) const event = eventOf(input) expect(event.action_value).toBe('opt_b') - // No message text on the payload -> text falls back to the action value. expect(event.text).toBe('opt_b') expect(event.user_name).toBe('bob') }) diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 81bd1f5c036..7bc242c7b51 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -199,6 +199,8 @@ function formatSlackSlashCommand(b: Record): SlackTriggerEvent * Interactivity payloads (button clicks, selects, shortcuts, modal submits). * The actionable data lives in `actions[]` / `view`, plus `response_url` and * `trigger_id` which are needed to respond to or follow up on the interaction. + * `text` prefers the source message text, falling back to the triggering + * action's value so a blocks-only message still surfaces something useful. */ function formatSlackInteractive(b: Record): SlackTriggerEvent { const event = createSlackEvent() @@ -226,8 +228,6 @@ function formatSlackInteractive(b: Record): SlackTriggerEvent { event.message_ts = asString(message?.ts) || asString(container?.message_ts) event.timestamp = event.message_ts || asString(firstAction?.action_ts) event.thread_ts = asString(message?.thread_ts) - // Prefer the source message text; fall back to the triggering action's value - // so a blocks-only message still surfaces something useful in `text`. event.text = asString(message?.text) || event.action_value event.message = message ?? null @@ -495,6 +495,12 @@ export const slackHandler: WebhookProviderHandler = { return handleSlackChallenge(body) }, + /** + * `event_id` (Events API) and `team_id:event.ts` are the primary keys. + * `trigger_id` is the fallback for interactivity and slash-command payloads, + * which carry no `event_id` but reuse the same `trigger_id` across Slack's + * retries of a given interaction. + */ extractIdempotencyId(body: unknown) { if (!isRecordLike(body)) { return null @@ -509,8 +515,6 @@ export const slackHandler: WebhookProviderHandler = { return `${body.team_id}:${event.ts}` } - // Interactivity and slash-command payloads carry a unique `trigger_id` - // per interaction, which Slack reuses across retries of the same payload. if (body.trigger_id) { return String(body.trigger_id) } @@ -526,19 +530,23 @@ export const slackHandler: WebhookProviderHandler = { return new NextResponse(null, { status: 200 }) }, + /** + * Routes across Slack's three distinct payload families, each identified by + * a different shape: slash commands (flat form fields with a leading-slash + * `command`), interactivity (a JSON `payload` with an interactive `type` or + * `actions[]` and no Events-API `event` envelope), and the Events API + * (app_mention, message, reaction_added, ... nested under `event`). + */ async formatInput({ body, webhook }: FormatInputContext): Promise { const b = isRecordLike(body) ? body : {} const providerConfig = (webhook.providerConfig as Record) || {} const botToken = providerConfig.botToken as string | undefined const includeFiles = Boolean(providerConfig.includeFiles) - // Slash commands: flat form fields identified by a leading-slash `command`. if (typeof b?.command === 'string' && b.command.startsWith('/')) { return { input: { event: formatSlackSlashCommand(b) } } } - // Interactivity (button clicks, selects, shortcuts, modal submits): a JSON - // `payload` with an interactive `type` and no Events-API `event` envelope. if ( !b?.event && ((typeof b?.type === 'string' && SLACK_INTERACTIVE_TYPES.has(b.type)) || @@ -547,7 +555,6 @@ export const slackHandler: WebhookProviderHandler = { return { input: { event: formatSlackInteractive(b) } } } - // Events API (app_mention, message, reaction_added, ...). const rawEvent = b?.event as Record | undefined if (!rawEvent) { From 053bcf87101d43a8ec5297c3766febb8df2f997f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:29:03 -0700 Subject: [PATCH 12/23] test(github): add negative alias test, remove extraneous inline comments Adversarial re-audit of the workflow_run/formatInput fix: eventMap now covers all 11 GitHub trigger IDs (verified exhaustively against every file in triggers/github/), and withGitHubUserTypeAliases only augments objects shaped like a GitHub user (login+type strings) rather than renaming every `type` key in the tree. Added a test proving an unrelated nested `type` field (e.g. an issue label) is left untouched. Removed inline // comments that only restated the code; folded the two genuinely non-obvious GitHub semantics (issue_comment firing for both issues and PRs, closed vs merged pull requests) into the function's TSDoc. --- .../sim/lib/webhooks/providers/github.test.ts | 22 ++++++++++++++++++ apps/sim/triggers/github/utils.ts | 23 +++++++++++-------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/github.test.ts b/apps/sim/lib/webhooks/providers/github.test.ts index 3fb2730bf68..b7d8978d077 100644 --- a/apps/sim/lib/webhooks/providers/github.test.ts +++ b/apps/sim/lib/webhooks/providers/github.test.ts @@ -188,6 +188,28 @@ describe('GitHub webhook provider', () => { expect(repository.description).toBe('A test repo') }) + it('formatInput does not alias a nested `type` field on objects that are not user-like', async () => { + const { input } = await githubHandler.formatInput!({ + body: { + action: 'labeled', + issue: { + id: 1, + label: { name: 'bug', color: 'ff0000', type: 'default' }, + }, + }, + headers: { 'x-github-event': 'issues' }, + requestId: 't13', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + const issue = i.issue as Record + const label = issue.label as Record + expect(label.type).toBe('default') + expect(label.user_type).toBeUndefined() + expect(label.owner_type).toBeUndefined() + }) + it('formatInput derives branch from ref', async () => { const { input } = await githubHandler.formatInput!({ body: { ref: 'refs/heads/main', action: '' }, diff --git a/apps/sim/triggers/github/utils.ts b/apps/sim/triggers/github/utils.ts index 41518f762bb..d56634d7bd1 100644 --- a/apps/sim/triggers/github/utils.ts +++ b/apps/sim/triggers/github/utils.ts @@ -123,8 +123,14 @@ export const userOutputs = { } as const /** - * Check if a GitHub event matches the expected trigger configuration - * This is used for event filtering in the webhook processor + * Checks whether a delivered GitHub event matches the expected trigger + * configuration, used for event filtering in the webhook processor. + * + * GitHub fires `issue_comment` for comments on both issues and pull + * requests, and `pull_request` with action `closed` for both a merge and a + * close-without-merge; the `validator` entries disambiguate those cases via + * `issue.pull_request` (present only on PR comments) and + * `pull_request.merged` respectively. */ export function isGitHubEventMatch( triggerId: string, @@ -144,22 +150,22 @@ export function isGitHubEventMatch( github_issue_closed: { event: 'issues', actions: ['closed'] }, github_issue_comment: { event: 'issue_comment', - validator: (p) => !isRecordLike(p.issue) || !p.issue.pull_request, // Only issues, not PRs + validator: (p) => !isRecordLike(p.issue) || !p.issue.pull_request, }, github_pr_opened: { event: 'pull_request', actions: ['opened'] }, github_pr_closed: { event: 'pull_request', actions: ['closed'], - validator: (p) => isRecordLike(p.pull_request) && p.pull_request.merged === false, // Not merged + validator: (p) => isRecordLike(p.pull_request) && p.pull_request.merged === false, }, github_pr_merged: { event: 'pull_request', actions: ['closed'], - validator: (p) => isRecordLike(p.pull_request) && p.pull_request.merged === true, // Merged + validator: (p) => isRecordLike(p.pull_request) && p.pull_request.merged === true, }, github_pr_comment: { event: 'issue_comment', - validator: (p) => isRecordLike(p.issue) && !!p.issue.pull_request, // Only PRs, not issues + validator: (p) => isRecordLike(p.issue) && !!p.issue.pull_request, }, github_pr_reviewed: { event: 'pull_request_review', actions: ['submitted'] }, github_push: { event: 'push' }, @@ -169,20 +175,17 @@ export function isGitHubEventMatch( const config = eventMap[triggerId] if (!config) { - return true // Unknown trigger, allow through + return true } - // Check event type if (config.event !== eventType) { return false } - // Check action if specified if (config.actions && action && !config.actions.includes(action)) { return false } - // Run custom validator if provided if (config.validator) { return config.validator(isRecordLike(payload) ? payload : {}) } From 36190143ac505ed594ea4577864065e773598237 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:28:33 -0700 Subject: [PATCH 13/23] chore(jira): drop extraneous inline comments from second-pass re-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed self-evident line comments in isJiraEventMatch() and the jira_webhook output-schema composition — the code (variable/function names) already says what these restated. --- apps/sim/triggers/jira/utils.ts | 3 --- apps/sim/triggers/jira/webhook.ts | 2 -- 2 files changed, 5 deletions(-) diff --git a/apps/sim/triggers/jira/utils.ts b/apps/sim/triggers/jira/utils.ts index 1dbcb5fbae8..812cd77b5e7 100644 --- a/apps/sim/triggers/jira/utils.ts +++ b/apps/sim/triggers/jira/utils.ts @@ -470,7 +470,6 @@ export function isJiraEventMatch( jira_sprint_closed: ['sprint_closed'], jira_project_created: ['project_created'], jira_version_released: ['jira:version_released'], - // Generic webhook accepts all events jira_webhook: ['*'], } @@ -479,12 +478,10 @@ export function isJiraEventMatch( return false } - // Generic webhook accepts all events if (expectedEvents.includes('*')) { return true } - // Check if webhookEvent or issueEventTypeName matches return ( expectedEvents.includes(webhookEvent) || (issueEventTypeName !== undefined && expectedEvents.includes(issueEventTypeName)) diff --git a/apps/sim/triggers/jira/webhook.ts b/apps/sim/triggers/jira/webhook.ts index 12c34f5fb24..aff1bf23c16 100644 --- a/apps/sim/triggers/jira/webhook.ts +++ b/apps/sim/triggers/jira/webhook.ts @@ -32,8 +32,6 @@ export const jiraWebhookTrigger: TriggerConfig = { extraFields: buildJiraExtraFields('jira_webhook'), }), - // Superset of every event-specific output shape, since this trigger passes through - // whichever entity (issue/comment/worklog/sprint/project/version) the event carries. outputs: { ...buildIssueUpdatedOutputs(), comment: buildCommentOutputs().comment, From 43039ca7df6199d14add8b696756037623b119ac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:26:43 -0700 Subject: [PATCH 14/23] fix(salesforce): add missing permission-set and async-path steps to Flow setup instructions Re-verified the Named Credential setup instructions against live Salesforce docs. Two required steps were still missing: (1) the Flow's running user (Automated Process user for record-triggered flows) needs a permission set granting External Credential Principal Access, or the callout fails auth even with a correctly configured Named Credential; (2) record-triggered flows can only perform callouts on the Run Asynchronously path. --- apps/sim/triggers/salesforce/utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index 5b8e26e28e1..5f5e627b921 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -154,9 +154,10 @@ export function salesforceSetupInstructions(eventType: string): string { ? [ 'Copy the Webhook URL above and generate a Webhook Secret (any strong random string). Paste the secret in the Webhook Secret field here.', "In Salesforce, go to Setup → Named Credentials. Create an External Credential with a custom Authentication Parameter holding your secret, then a Named Credential whose URL is the Webhook URL above, with a custom header Authorization set to {!'Bearer ' & $Credential.YourExternalCredential.YourParameter} (or a header named X-Sim-Webhook-Secret with the raw secret). Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.", + 'Under the External Credential, add a Permission Set and enable External Credential Principal Access for its principal, then assign that permission set to the user the Flow runs as (the Automated Process user for background/record-triggered flows). Without this the callout fails authentication even though the Named Credential is configured correctly.', 'Go to Setup → Flows and click New Flow.', 'Select Record-Triggered Flow and choose the object(s) you want to monitor.', - 'Add an Action that performs an HTTP Callout using the Named Credential from step 2 — method POST, Content-Type: application/json, path / (the base URL already points at your webhook).', + 'Add an Action that performs an HTTP Callout using the Named Credential from step 2 — method POST, Content-Type: application/json, path / (the base URL already points at your webhook). Record-triggered flows can only make callouts on the Run Asynchronously path, so place this action there.', '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 before you build the Named Credential and Flow.', @@ -164,9 +165,10 @@ export function salesforceSetupInstructions(eventType: string): string { ] : [ 'Copy the Webhook URL above and set a Webhook Secret. In Salesforce, create an External Credential (holding the secret) and a Named Credential whose URL is this Webhook URL, with a custom header sending the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: …. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.', + 'Under the External Credential, add a Permission Set with External Credential Principal Access enabled and assign it to the user the Flow runs as (the Automated Process user for record-triggered flows) — otherwise the callout fails authentication.', 'Go to Setup → Flows and click New Flow.', `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`, - 'Add an HTTP Callout using the Named Credential from step 1 — POST, JSON body, path /.', + 'Add an HTTP Callout using the Named Credential from step 1 — POST, JSON body, path /. Record-triggered flows can only make callouts on the Run Asynchronously path, so place this action there.', `Include eventType in the JSON body using a value this trigger accepts (e.g. for Record Created use record_created, created, or after_insert).`, '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.', From 6340d0a2e5d985c630081ebf0645575badeb8faf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:28:16 -0700 Subject: [PATCH 15/23] fix(hubspot): fail loudly instead of silently truncating over-limit filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters within a HubSpot Search API filterGroup are AND-combined, so silently dropping the last N filters when the combined shortcut + advanced-filter count exceeded MAX_USER_FILTERS widened the match set instead of narrowing it — a poll could start matching records the user's config meant to exclude, firing the workflow on unintended records with no visible error. That's worse than the original hard-400 bug, which was at least loud. buildUserFilters now throws when the combined count exceeds the limit, which pollWebhook's existing catch turns into a visible markWebhookFailed, matching how every other misconfiguration in this file (missing objectType, invalid eventType, corrupt watermark) is already handled. Re-derived the cap arithmetic against HubSpot's live docs (developers.hubspot.com/docs/api/crm/search): max 6 filters per filterGroup, 5 groups, 18 total. Group B reserves 2 slots (filterProperty EQ + hs_object_id GT tie-break), so MAX_USER_FILTERS = 4 is exact — not off by one in either direction. --- apps/sim/lib/webhooks/polling/hubspot.test.ts | 33 +++++++++++-------- apps/sim/lib/webhooks/polling/hubspot.ts | 19 ++++++----- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/hubspot.test.ts b/apps/sim/lib/webhooks/polling/hubspot.test.ts index 3c7ac92da21..286fedc98a0 100644 --- a/apps/sim/lib/webhooks/polling/hubspot.test.ts +++ b/apps/sim/lib/webhooks/polling/hubspot.test.ts @@ -63,26 +63,33 @@ describe('buildUserFilters', () => { expect(buildUserFilters({ filters: 'not json' })).toEqual([]) }) - it('caps combined shortcut + advanced filters at the HubSpot per-group limit', () => { + it('allows exactly the HubSpot per-group limit of combined filters', () => { const filters = buildUserFilters({ objectType: 'deal', pipelineId: 'pipeline-1', stageId: 'stage-1', ownerId: 'owner-1', - filters: JSON.stringify([ - { propertyName: 'amount', operator: 'GT', value: '1000' }, - { propertyName: 'lifecyclestage', operator: 'EQ', value: 'customer' }, - ]), + filters: JSON.stringify([{ propertyName: 'amount', operator: 'GT', value: '1000' }]), }) - // 3 shortcuts + 2 advanced = 5 raw filters, capped to 4 so Group B - // (2 reserved slots + user filters) never exceeds HubSpot's 6-filter-per-group max. + // 3 shortcuts + 1 advanced = 4, exactly MAX_USER_FILTERS. expect(filters).toHaveLength(4) - expect(filters).toEqual([ - { propertyName: 'pipeline', operator: 'EQ', value: 'pipeline-1' }, - { propertyName: 'dealstage', operator: 'EQ', value: 'stage-1' }, - { propertyName: 'hubspot_owner_id', operator: 'EQ', value: 'owner-1' }, - { propertyName: 'amount', operator: 'GT', value: '1000' }, - ]) + }) + + it('throws rather than silently dropping filters when the combined count exceeds the limit', () => { + // Filters within a filterGroup are AND-combined, so silently dropping one would widen + // the match set instead of narrowing it — throwing surfaces the misconfiguration loudly. + expect(() => + buildUserFilters({ + objectType: 'deal', + pipelineId: 'pipeline-1', + stageId: 'stage-1', + ownerId: 'owner-1', + filters: JSON.stringify([ + { propertyName: 'amount', operator: 'GT', value: '1000' }, + { propertyName: 'lifecyclestage', operator: 'EQ', value: 'customer' }, + ]), + }) + ).toThrow(/exceeding the 4-filter limit/) }) }) diff --git a/apps/sim/lib/webhooks/polling/hubspot.ts b/apps/sim/lib/webhooks/polling/hubspot.ts index 03b89d90605..a854f28511b 100644 --- a/apps/sim/lib/webhooks/polling/hubspot.ts +++ b/apps/sim/lib/webhooks/polling/hubspot.ts @@ -102,11 +102,10 @@ const MAX_PAGES_PER_POLL = 10 /** Cap on property-change snapshot size to bound providerConfig payload. */ const MAX_SNAPSHOT_SIZE = 1000 /** - * HubSpot Search API caps each filterGroup at 6 filters. `buildBody` reserves 2 slots in - * Group B (filterProperty EQ + hs_object_id GT), so user-supplied filters (pipeline/stage/ - * owner shortcuts plus advanced JSON filters) must leave room for those — cap at 4 so Group - * B never exceeds 6. Excess filters are dropped rather than sent, which would otherwise fail - * every poll with an opaque 400 from HubSpot. + * HubSpot Search API caps each filterGroup at 6 filters (developers.hubspot.com/docs/api/crm/search). + * `buildBody` reserves 2 slots in Group B (filterProperty EQ + hs_object_id GT), so + * user-supplied filters (pipeline/stage/owner shortcuts plus advanced JSON filters) must + * leave room for those — cap at 4 so Group B never exceeds 6. */ const MAX_USER_FILTERS = 4 @@ -545,7 +544,6 @@ export function buildUserFilters( ): FilterClause[] { const filters: FilterClause[] = [] - // Shortcut fields translate to common HubSpot filter conditions. if (config.pipelineId?.trim()) { const property = config.objectType === 'ticket' ? 'hs_pipeline' : 'pipeline' filters.push({ propertyName: property, operator: 'EQ', value: config.pipelineId.trim() }) @@ -585,11 +583,14 @@ export function buildUserFilters( } } + // Filters within a filterGroup are AND-combined, so dropping any of them would silently + // widen the match set (matching records the user's config meant to exclude) rather than + // just failing the request — a worse outcome than a loud poll failure. Throw instead so + // the webhook is marked failed and the user can trim their filter configuration. if (filters.length > MAX_USER_FILTERS) { - logger?.warn( - `[${requestId ?? ''}] HubSpot filters exceed the ${MAX_USER_FILTERS}-filter limit (pipeline/stage/owner shortcuts + advanced filters combined); dropping the last ${filters.length - MAX_USER_FILTERS}` + throw new Error( + `[${requestId ?? ''}] HubSpot webhook has ${filters.length} combined filters (pipeline/stage/owner shortcuts + advanced filters), exceeding the ${MAX_USER_FILTERS}-filter limit HubSpot's Search API allows alongside the reserved cursor filters. Reduce the number of filters.` ) - return filters.slice(0, MAX_USER_FILTERS) } return filters From e729a32d2a268da3f7f54b18e00241330b4c7f40 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:28:28 -0700 Subject: [PATCH 16/23] fix(zendesk): validate subdomain as a bare hostname label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSubscription/deleteSubscription interpolate the user-supplied subdomain directly into the Zendesk API URL (`https://${subdomain}.zendesk.com`). An unvalidated value containing a '/' (e.g. "evil.example.com/x") escapes the host portion of the URL, redirecting the request — and its Basic-auth admin credentials — to an attacker-controlled host. Unlike the equivalent pattern in apps/sim/tools/zendesk (invoked only when a user explicitly runs a block with their own credentials), this fires automatically on deploy and undeploy using admin-scoped API tokens, making it the more acute instance of the pattern. Reject anything but letters, digits, and internal hyphens. Also drops a few restated-in-code inline comments per repo convention. --- .../lib/webhooks/providers/zendesk.test.ts | 73 +++++++++++++++++++ apps/sim/lib/webhooks/providers/zendesk.ts | 26 ++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/zendesk.test.ts b/apps/sim/lib/webhooks/providers/zendesk.test.ts index 7c88f4a6723..831c2ceaeea 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.test.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.test.ts @@ -210,6 +210,79 @@ describe('Zendesk webhook provider', () => { }) }) + describe('createSubscription', () => { + it('rejects a subdomain containing a path separator to prevent host-boundary escape', async () => { + await expect( + zendeskHandler.createSubscription!({ + webhook: { + id: 'wh-1', + path: 'p', + providerConfig: { + subdomain: 'evil.example.com/x', + email: 'admin@example.com', + apiToken: 'token', + }, + }, + workflow: {}, + userId: 'u1', + requestId: 't11', + request: reqWithHeaders({}), + }) + ).rejects.toThrow(/subdomain must contain only letters, numbers, and hyphens/) + }) + + it('rejects a subdomain that is missing entirely', async () => { + await expect( + zendeskHandler.createSubscription!({ + webhook: { id: 'wh-2', path: 'p', providerConfig: {} }, + workflow: {}, + userId: 'u1', + requestId: 't12', + request: reqWithHeaders({}), + }) + ).rejects.toThrow(/subdomain is required/) + }) + }) + + describe('deleteSubscription', () => { + it('skips (non-strict) when the stored subdomain is invalid', async () => { + await expect( + zendeskHandler.deleteSubscription!({ + webhook: { + id: 'wh-3', + providerConfig: { + subdomain: 'evil.example.com/x', + email: 'admin@example.com', + apiToken: 'token', + externalId: 'ext-1', + }, + }, + workflow: {}, + requestId: 't13', + }) + ).resolves.toBeUndefined() + }) + + it('throws (strict) when the stored subdomain is invalid', async () => { + await expect( + zendeskHandler.deleteSubscription!({ + webhook: { + id: 'wh-4', + providerConfig: { + subdomain: 'evil.example.com/x', + email: 'admin@example.com', + apiToken: 'token', + externalId: 'ext-1', + }, + }, + workflow: {}, + requestId: 't14', + strict: true, + }) + ).rejects.toThrow(/Invalid Zendesk subdomain/) + }) + }) + describe('extractIdempotencyId', () => { it('returns the stable event id', () => { expect(zendeskHandler.extractIdempotencyId!({ id: 'evt-1' })).toBe('evt-1') diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts index 54c50b914b0..665f11b5853 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -25,6 +25,18 @@ function zendeskApiBase(subdomain: string): string { return `https://${subdomain}.zendesk.com/api/v2` } +/** + * Zendesk subdomains are bare hostname labels (letters, digits, internal hyphens). + * Rejecting anything else prevents the value from escaping the host portion of the + * interpolated URL (e.g. a subdomain containing `/` would redirect the request — + * and its Basic-auth admin credentials — to an attacker-controlled host). + */ +const ZENDESK_SUBDOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i + +function isValidZendeskSubdomain(subdomain: string): boolean { + return ZENDESK_SUBDOMAIN_PATTERN.test(subdomain) +} + /** Basic auth header for the Zendesk API-token scheme (`email/token:apiToken`). */ function zendeskAuthHeader(email: string, apiToken: string): string { return `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}` @@ -79,8 +91,6 @@ export const zendeskHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - // The signing secret is fetched during auto-registration, so a missing - // secret means misconfiguration — fail closed rather than skip. logger.warn(`[${requestId}] Zendesk webhook secret not configured`) return new NextResponse('Unauthorized - Missing Zendesk webhook secret', { status: 401 }) } @@ -168,6 +178,11 @@ export const zendeskHandler: WebhookProviderHandler = { const triggerId = config.triggerId as string | undefined if (!subdomain) throw new Error('Zendesk subdomain is required to create the webhook.') + if (!isValidZendeskSubdomain(subdomain)) { + throw new Error( + 'Zendesk subdomain must contain only letters, numbers, and hyphens (e.g. "yourcompany").' + ) + } if (!email) throw new Error('Zendesk admin email is required to create the webhook.') if (!apiToken) throw new Error('Zendesk API token is required to create the webhook.') @@ -216,7 +231,6 @@ export const zendeskHandler: WebhookProviderHandler = { `[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`, { detail } ) - // Avoid leaving an orphaned webhook in Zendesk when secret retrieval fails. await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`) } @@ -247,6 +261,12 @@ export const zendeskHandler: WebhookProviderHandler = { return } + if (!isValidZendeskSubdomain(subdomain)) { + if (ctx.strict) throw new Error('Invalid Zendesk subdomain for deletion.') + logger.warn(`[${ctx.requestId}] Skipping Zendesk webhook cleanup — invalid subdomain`) + return + } + const res = await fetch(`${zendeskApiBase(subdomain)}/webhooks/${externalId}`, { method: 'DELETE', headers: { Authorization: zendeskAuthHeader(email, apiToken) }, From ee59edc3d128933ea0cc8be604acaa436376f032 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:43:39 -0700 Subject: [PATCH 17/23] fix(triggers): restore jira fieldFilters as a real feature, fix twilio-voice status collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jira: the second-pass audit removed the `fieldFilters` subBlock on issue_updated as "dead code, never read" — true, but that meant the feature was already non-functional before removal (a UI control promising field-level filtering that did nothing), and removing it silently dropped an already-visible control from the merged block UI (flagged independently by both Greptile and Cursor). Rather than either reinstating a broken control or leaving it removed, implement the feature for real: matchEvent now checks the comma-separated field list against Jira's changelog.items[].field on issue_updated deliveries, matching Jira's actual webhook payload shape. twilio-voice: extractIdempotencyId keyed on CallSid alone, so every status callback for a call (ringing/in-progress/completed/etc) shared one idempotency key and only the first was ever processed — later CallStatus transitions were silently dropped as duplicates. Fixed to include CallStatus in the key, matching the SMS handler's existing SID:status pattern. Also converted an inline rationale comment in the SMS handler to TSDoc for consistency with the rest of this cleanup. --- apps/sim/lib/webhooks/providers/jira.test.ts | 57 +++++++++++++++++++ apps/sim/lib/webhooks/providers/jira.ts | 34 +++++++++++ .../webhooks/providers/twilio-voice.test.ts | 30 ++++++++++ .../lib/webhooks/providers/twilio-voice.ts | 12 +++- apps/sim/lib/webhooks/providers/twilio.ts | 11 ++-- apps/sim/triggers/jira/utils.ts | 19 +++++++ 6 files changed, 158 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/jira.test.ts b/apps/sim/lib/webhooks/providers/jira.test.ts index ea92a508f3e..615f5b6ce7b 100644 --- a/apps/sim/lib/webhooks/providers/jira.test.ts +++ b/apps/sim/lib/webhooks/providers/jira.test.ts @@ -90,6 +90,63 @@ describe('Jira webhook provider', () => { expect(result).toBe(false) }) + it('matchEvent applies fieldFilters on issue_updated, matching a changed field', async () => { + const result = await jiraHandler.matchEvent!({ + body: { + webhookEvent: 'jira:issue_updated', + changelog: { items: [{ field: 'status', from: 'Open', to: 'Done' }] }, + }, + requestId: 't7', + providerConfig: { triggerId: 'jira_issue_updated', fieldFilters: 'status, assignee' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + + it('matchEvent applies fieldFilters on issue_updated, skipping when no filtered field changed', async () => { + const result = await jiraHandler.matchEvent!({ + body: { + webhookEvent: 'jira:issue_updated', + changelog: { items: [{ field: 'description', from: 'a', to: 'b' }] }, + }, + requestId: 't8', + providerConfig: { triggerId: 'jira_issue_updated', fieldFilters: 'status, assignee' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + + it('matchEvent ignores fieldFilters for other trigger types', async () => { + const result = await jiraHandler.matchEvent!({ + body: { webhookEvent: 'jira:issue_created' }, + requestId: 't9', + providerConfig: { triggerId: 'jira_issue_created', fieldFilters: 'status' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + + it('matchEvent matches any field change when fieldFilters is empty', async () => { + const result = await jiraHandler.matchEvent!({ + body: { + webhookEvent: 'jira:issue_updated', + changelog: { items: [{ field: 'description', from: 'a', to: 'b' }] }, + }, + requestId: 't10', + providerConfig: { triggerId: 'jira_issue_updated' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + it('formatInput extracts issue data for the issue_created trigger', async () => { const { input } = await jiraHandler.formatInput!({ body: { diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts index 58b472d18fc..3e7d850ab76 100644 --- a/apps/sim/lib/webhooks/providers/jira.ts +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -12,6 +12,27 @@ import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Jira') +/** + * `changelog.items[].field` lists the Jira field names that changed in this + * update (only present on issue_updated deliveries). Empty filter list means + * no restriction — match on any field change. + */ +function matchesFieldFilters(body: Record, fieldFilters: string): boolean { + const wanted = fieldFilters + .split(',') + .map((f) => f.trim().toLowerCase()) + .filter(Boolean) + if (wanted.length === 0) return true + + const changelog = body.changelog as Record | undefined + const items = Array.isArray(changelog?.items) ? changelog.items : [] + const changedFields = items + .map((item) => (isRecordLike(item) && typeof item.field === 'string' ? item.field : '')) + .map((f) => f.toLowerCase()) + + return wanted.some((field) => changedFields.includes(field)) +} + export function validateJiraSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { @@ -132,6 +153,19 @@ export const jiraHandler: WebhookProviderHandler = { ) return false } + + const fieldFilters = providerConfig.fieldFilters as string | undefined + if ( + triggerId === 'jira_issue_updated' && + fieldFilters && + !matchesFieldFilters(obj, fieldFilters) + ) { + logger.debug( + `[${requestId}] Jira issue_updated field filter did not match for trigger ${triggerId}. Skipping execution.`, + { webhookId: webhook.id, workflowId: workflow.id, fieldFilters } + ) + return false + } } return true diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts index 6496b31b699..75f03e60858 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts @@ -88,6 +88,36 @@ describe('twilioVoiceHandler', () => { expect(twilioVoiceHandler.extractIdempotencyId!('not-an-object')).toBeNull() expect(twilioVoiceHandler.extractIdempotencyId!([1, 2, 3])).toBeNull() }) + + it('distinguishes each CallStatus transition for the same CallSid', () => { + const ringing = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'ringing', + }) + const inProgress = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'in-progress', + }) + const completed = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'completed', + }) + expect(ringing).not.toBeNull() + expect(ringing).not.toBe(inProgress) + expect(inProgress).not.toBe(completed) + }) + + it('dedupes a retried delivery of the same CallStatus transition', () => { + const first = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'completed', + }) + const retry = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'completed', + }) + expect(first).toBe(retry) + }) }) describe('formatInput', () => { diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts index f1aa6bcfc6f..c405ff55b59 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -14,9 +14,19 @@ export const twilioVoiceHandler: WebhookProviderHandler = { return verifyTwilioAuth(ctx, 'Twilio Voice') }, + /** + * A call fires multiple status callbacks against the same CallSid as it + * progresses (queued -> ringing -> in-progress -> completed/busy/failed/ + * no-answer/canceled), so CallStatus is part of the key to keep each + * transition distinct while still deduping Twilio's retries of the same + * status. + */ extractIdempotencyId(body: unknown) { if (!isRecordLike(body)) return null - return (body.MessageSid as string) || (body.CallSid as string) || null + const sid = (body.MessageSid as string) || (body.CallSid as string) + if (!sid) return null + const status = ((body.CallStatus as string) ?? '').toLowerCase() + return status ? `${sid}:${status}` : sid }, formatSuccessResponse(providerConfig: Record) { diff --git a/apps/sim/lib/webhooks/providers/twilio.ts b/apps/sim/lib/webhooks/providers/twilio.ts index 30474848e21..3ac6d1f3136 100644 --- a/apps/sim/lib/webhooks/providers/twilio.ts +++ b/apps/sim/lib/webhooks/providers/twilio.ts @@ -47,15 +47,18 @@ export const twilioHandler: WebhookProviderHandler = { return true }, + /** + * Status callbacks repeat for the same SID as the message progresses + * (sent -> delivered -> ...), so the delivery status is part of the key to + * keep each distinct callback (while still deduping Twilio's retries of + * the same status). Inbound messages fire once (SmsStatus 'received'), + * keyed by SID alone. + */ extractIdempotencyId(body: unknown) { if (!isRecordLike(body)) return null const obj = body const sid = (obj.MessageSid as string) || (obj.CallSid as string) if (!sid) return null - // Status callbacks repeat for the same SID as the message progresses - // (sent -> delivered -> ...), so the delivery status is part of the key to - // keep each distinct callback (while still deduping Twilio's retries of the - // same status). Inbound messages fire once (SmsStatus 'received'), keyed by SID. const status = ( ((obj.MessageStatus as string) || (obj.SmsStatus as string)) ?? '' diff --git a/apps/sim/triggers/jira/utils.ts b/apps/sim/triggers/jira/utils.ts index 812cd77b5e7..215533923a0 100644 --- a/apps/sim/triggers/jira/utils.ts +++ b/apps/sim/triggers/jira/utils.ts @@ -76,9 +76,25 @@ function jiraJqlFilterField(triggerId: string, description: string): SubBlockCon } } +function jiraFieldFiltersField(triggerId: string): SubBlockConfig { + return { + id: 'fieldFilters', + title: 'Field Filters', + type: 'long-input', + placeholder: 'status, assignee, priority', + description: + 'Comma-separated list of Jira field names. Only trigger when one of these fields changes. Leave empty to trigger on any field change.', + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + } +} + /** * Extra fields for Jira triggers (webhook secret, plus an optional JQL filter * for issue-scoped events — project/sprint/version events have no JQL analog). + * `fieldFilters` is issue_updated-only since it's matched against that + * event's changelog, which no other Jira webhook carries. */ export function buildJiraExtraFields( triggerId: string, @@ -88,6 +104,9 @@ export function buildJiraExtraFields( if (jqlFilterDescription) { fields.push(jiraJqlFilterField(triggerId, jqlFilterDescription)) } + if (triggerId === 'jira_issue_updated') { + fields.push(jiraFieldFiltersField(triggerId)) + } return fields } From 6f242c89437e3a56e97b3bd24f1dd447fbc04690 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:55:58 -0700 Subject: [PATCH 18/23] fix(twilio-voice): key idempotency on all callback discriminators, not just status CallStatus/RecordingStatus/TranscriptionStatus share overlapping values (e.g. 'completed'), and Gather turns / recording events fan out multiple distinct deliveries under one CallSid while CallStatus itself stays unchanged. Build the key from field=value pairs across every known discriminator (CallStatus, Digits, SpeechResult, RecordingSid, RecordingStatus, TranscriptionSid, TranscriptionStatus) so callback kinds can never collide on a shared value, while identical retries still dedupe. --- .../webhooks/providers/twilio-voice.test.ts | 67 +++++++++++++++++++ .../lib/webhooks/providers/twilio-voice.ts | 37 ++++++++-- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts index 75f03e60858..528674c89ac 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts @@ -118,6 +118,73 @@ describe('twilioVoiceHandler', () => { }) expect(first).toBe(retry) }) + + it('distinguishes recording and transcription status callbacks from CallStatus callbacks on the same CallSid', () => { + const callCompleted = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'completed', + }) + const recordingCompleted = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + RecordingStatus: 'completed', + }) + const transcriptionCompleted = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + TranscriptionStatus: 'completed', + }) + expect(callCompleted).not.toBeNull() + expect(recordingCompleted).not.toBeNull() + expect(transcriptionCompleted).not.toBeNull() + expect(callCompleted).not.toBe(recordingCompleted) + expect(recordingCompleted).not.toBe(transcriptionCompleted) + }) + + it('distinguishes multiple Gather turns that share the same CallSid and CallStatus', () => { + const firstDigits = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'in-progress', + Digits: '1', + }) + const secondDigits = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'in-progress', + Digits: '2', + }) + const speech = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'in-progress', + SpeechResult: 'sales', + }) + expect(firstDigits).not.toBe(secondDigits) + expect(firstDigits).not.toBe(speech) + expect(secondDigits).not.toBe(speech) + }) + + it('distinguishes recording status callbacks by RecordingSid for the same CallSid', () => { + const recordingOne = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + RecordingSid: 'RE1', + }) + const recordingTwo = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + RecordingSid: 'RE2', + }) + expect(recordingOne).not.toBe(recordingTwo) + }) + + it('dedupes a retried delivery of the same Gather turn', () => { + const first = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'in-progress', + Digits: '1', + }) + const retry = twilioVoiceHandler.extractIdempotencyId!({ + CallSid: 'CA1', + CallStatus: 'in-progress', + Digits: '1', + }) + expect(first).toBe(retry) + }) }) describe('formatInput', () => { diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts index c405ff55b59..f6dd8308322 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -15,18 +15,41 @@ export const twilioVoiceHandler: WebhookProviderHandler = { }, /** - * A call fires multiple status callbacks against the same CallSid as it - * progresses (queued -> ringing -> in-progress -> completed/busy/failed/ - * no-answer/canceled), so CallStatus is part of the key to keep each - * transition distinct while still deduping Twilio's retries of the same - * status. + * A call fires many independent callbacks against the same CallSid as it + * progresses: CallStatus transitions (queued -> ringing -> in-progress -> + * completed/...), repeated Gather turns while CallStatus stays + * "in-progress" (differentiated only by Digits or SpeechResult), and + * separate recording/transcription completions via RecordingStatus/ + * TranscriptionStatus. The discriminator is built from field=value pairs + * (not bare values) so callbacks of different kinds can never collide even + * when they share a value — e.g. CallStatus=completed vs + * RecordingStatus=completed are distinct strings — while a retried + * delivery of the identical payload still produces the identical key. */ extractIdempotencyId(body: unknown) { if (!isRecordLike(body)) return null const sid = (body.MessageSid as string) || (body.CallSid as string) if (!sid) return null - const status = ((body.CallStatus as string) ?? '').toLowerCase() - return status ? `${sid}:${status}` : sid + + const discriminatorFields = [ + 'CallStatus', + 'Digits', + 'SpeechResult', + 'RecordingSid', + 'RecordingStatus', + 'TranscriptionSid', + 'TranscriptionStatus', + ] as const + + const discriminator = discriminatorFields + .map((field) => { + const value = body[field] + return typeof value === 'string' && value ? `${field}=${value.toLowerCase()}` : null + }) + .filter(Boolean) + .join('&') + + return discriminator ? `${sid}:${discriminator}` : sid }, formatSuccessResponse(providerConfig: Record) { From 3a826c185bddd9e4cbb090a42cd21e89e5904e2d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:46:53 -0700 Subject: [PATCH 19/23] chore(github-trigger): remove extraneous inline comment Round 3 re-audit: verified push event handling (commits[].author/ committer, head_commit, pusher) is correctly excluded from the GitHub-user type-alias walk since those objects lack login+type, so no output-schema drift. No functional issues found. Removed a leftover inline // comment in the generic webhook trigger's output schema per repo comment conventions. --- apps/sim/triggers/github/webhook.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/triggers/github/webhook.ts b/apps/sim/triggers/github/webhook.ts index 5b1b4c630ea..c0c45539e40 100644 --- a/apps/sim/triggers/github/webhook.ts +++ b/apps/sim/triggers/github/webhook.ts @@ -508,7 +508,6 @@ export const githubWebhookTrigger: TriggerConfig = { }, }, - // Convenient flat fields for easy access event_type: { type: 'string', description: 'Type of GitHub event (e.g., push, pull_request, issues)', From c3ca7c2f4712af9682fc3ca133bce499679331f3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:47:28 -0700 Subject: [PATCH 20/23] fix(stripe-trigger): add missing 2025 event types to eventTypes allowlist invoice.payment_attempt_required and balance_settings.updated shipped in Stripe's 2025-10-29 API update but were missing from the trigger's curated eventTypes dropdown despite their categories (Invoices, Balance) already being represented. --- apps/sim/triggers/stripe/webhook.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/sim/triggers/stripe/webhook.ts b/apps/sim/triggers/stripe/webhook.ts index e087b619346..1b33742c954 100644 --- a/apps/sim/triggers/stripe/webhook.ts +++ b/apps/sim/triggers/stripe/webhook.ts @@ -86,6 +86,10 @@ export const stripeWebhookTrigger: TriggerConfig = { { label: 'invoice.voided', id: 'invoice.voided' }, { label: 'invoice.marked_uncollectible', id: 'invoice.marked_uncollectible' }, { label: 'invoice.overdue', id: 'invoice.overdue' }, + { + label: 'invoice.payment_attempt_required', + id: 'invoice.payment_attempt_required', + }, // Products & Prices { label: 'product.created', id: 'product.created' }, @@ -149,6 +153,7 @@ export const stripeWebhookTrigger: TriggerConfig = { // Balance { label: 'balance.available', id: 'balance.available' }, + { label: 'balance_settings.updated', id: 'balance_settings.updated' }, ], placeholder: 'Leave empty to receive all events', description: From 70522b0f26b832921e918a87e3e41c6d614f8b07 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:48:40 -0700 Subject: [PATCH 21/23] fix(salesforce-trigger): document required Allow Formulas in HTTP Header checkbox Setup instructions told admins to add a Named Credential custom header using a $Credential formula, but never mentioned that the "Allow Formulas in HTTP Header" checkbox must be checked when adding that header. Left unchecked, Salesforce sends the literal "{!\$Credential...}" text instead of evaluating it, so the shared-secret auth silently fails. --- apps/sim/triggers/salesforce/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index 5f5e627b921..1da79f322ec 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -153,7 +153,7 @@ export function salesforceSetupInstructions(eventType: string): string { const instructions = isGeneric ? [ 'Copy the Webhook URL above and generate a Webhook Secret (any strong random string). Paste the secret in the Webhook Secret field here.', - "In Salesforce, go to Setup → Named Credentials. Create an External Credential with a custom Authentication Parameter holding your secret, then a Named Credential whose URL is the Webhook URL above, with a custom header Authorization set to {!'Bearer ' & $Credential.YourExternalCredential.YourParameter} (or a header named X-Sim-Webhook-Secret with the raw secret). Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.", + "In Salesforce, go to Setup → Named Credentials. Create an External Credential with a custom Authentication Parameter holding your secret, then a Named Credential whose URL is the Webhook URL above, with a custom header Authorization set to {!'Bearer ' & $Credential.YourExternalCredential.YourParameter} (or a header named X-Sim-Webhook-Secret with the raw secret). When adding that custom header, check Allow Formulas in HTTP Header — if left unchecked, Salesforce sends the literal {!$Credential…} text instead of evaluating it, and authentication silently fails. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.", 'Under the External Credential, add a Permission Set and enable External Credential Principal Access for its principal, then assign that permission set to the user the Flow runs as (the Automated Process user for background/record-triggered flows). Without this the callout fails authentication even though the Named Credential is configured correctly.', 'Go to Setup → Flows and click New Flow.', 'Select Record-Triggered Flow and choose the object(s) you want to monitor.', @@ -164,7 +164,7 @@ export function salesforceSetupInstructions(eventType: string): string { 'Click "Save" above to activate your trigger.', ] : [ - 'Copy the Webhook URL above and set a Webhook Secret. In Salesforce, create an External Credential (holding the secret) and a Named Credential whose URL is this Webhook URL, with a custom header sending the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: …. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.', + 'Copy the Webhook URL above and set a Webhook Secret. In Salesforce, create an External Credential (holding the secret) and a Named Credential whose URL is this Webhook URL, with a custom header sending the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: …. If the header value uses a {!$Credential…} formula, check Allow Formulas in HTTP Header when adding it — otherwise Salesforce sends the literal formula text instead of the secret. Flow’s HTTP Callout action requires a Named Credential — it cannot call an arbitrary URL directly.', 'Under the External Credential, add a Permission Set with External Credential Principal Access enabled and assign it to the user the Flow runs as (the Automated Process user for record-triggered flows) — otherwise the callout fails authentication.', 'Go to Setup → Flows and click New Flow.', `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`, From e81631b67441c72798c0693f748fe33ca2d447be Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:49:14 -0700 Subject: [PATCH 22/23] fix(slack): skip block_suggestion payloads instead of wastefully executing workflows block_suggestion (external select option loading) requires Slack to receive a synchronous JSON options response within 3 seconds, which this trigger's async fire-and-forget webhook execution model can never provide. It was previously routed through the generic interactivity handler like block_actions/shortcut/view_submission, meaning every keystroke in an external-select typeahead would silently trigger a full (useless) workflow execution. Now explicitly skipped via the existing skip mechanism. --- apps/sim/lib/webhooks/providers/slack.test.ts | 17 +++++++++++++++++ apps/sim/lib/webhooks/providers/slack.ts | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index f5519047669..e2105b0dce5 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -157,6 +157,23 @@ describe('slackHandler formatInput - interactivity (block_actions)', () => { }) }) +describe('slackHandler formatInput - block_suggestion', () => { + it('skips execution instead of triggering the workflow', async () => { + const { input, skip } = await slackHandler.formatInput!( + ctx({ + type: 'block_suggestion', + action_id: 'external_select', + block_id: 'b1', + value: 'sea', + team: { id: 'T1' }, + user: { id: 'U1' }, + }) + ) + expect(input).toBeNull() + expect(skip?.message).toBeTruthy() + }) +}) + describe('slackHandler formatInput - slash commands', () => { it('maps flat slash-command form fields', async () => { const { input } = await slackHandler.formatInput!( diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 7bc242c7b51..cf212a00fc8 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -17,7 +17,8 @@ import type { const logger = createLogger('WebhookProvider:Slack') -const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB +/** 50 MB */ +const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 const SLACK_MAX_FILES = 15 const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) @@ -27,6 +28,11 @@ const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) * `payload` field (button clicks, selects, shortcuts, modal submits). These have * no Events-API `event` envelope, so they need their own mapping. * See https://api.slack.com/interactivity/handling#payloads + * + * `block_suggestion` (external select option loading) is deliberately excluded: + * Slack requires a synchronous JSON `options` response within 3 seconds, which + * this trigger's fire-and-forget webhook execution model cannot provide — it is + * skipped explicitly in `formatInput` instead of being routed here. */ const SLACK_INTERACTIVE_TYPES = new Set([ 'block_actions', @@ -35,7 +41,6 @@ const SLACK_INTERACTIVE_TYPES = new Set([ 'shortcut', 'view_submission', 'view_closed', - 'block_suggestion', ]) interface SlackDownloadedFile { @@ -547,6 +552,16 @@ export const slackHandler: WebhookProviderHandler = { return { input: { event: formatSlackSlashCommand(b) } } } + if (b?.type === 'block_suggestion') { + return { + input: null, + skip: { + message: + 'Slack block_suggestion payloads require a synchronous options response and cannot be served by an async workflow trigger', + }, + } + } + if ( !b?.event && ((typeof b?.type === 'string' && SLACK_INTERACTIVE_TYPES.has(b.type)) || From 39f7fd00a356b8a012497935d03d5557f943815a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:49:40 -0700 Subject: [PATCH 23/23] fix(microsoftteams): recreate Teams subscription when renewal PATCH 404s If every renewal attempt in a subscription's 48h renewal window failed (revoked consent, prolonged Graph outage), the subscription actually expired on Microsoft's side and the cron kept PATCHing a deleted subscription forever, always 404ing, with the webhook silently dead. Now a 404/410 from the renewal PATCH triggers a POST to recreate the subscription from the stored chatId, closing the same "never comes back" failure mode the original bug had, for the narrower case where renewal itself keeps failing. Also dedupes getCredentialOwner onto the shared provider-subscription-utils helper instead of a local copy, and drops a couple of restated-in-code comments. --- .../app/api/cron/renew-subscriptions/route.ts | 118 +++++++++++++----- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 9dcc8375623..4728f0fd666 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { account, webhook as webhookTable } from '@sim/db/schema' +import { webhook as webhookTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { and, eq, or } from 'drizzle-orm' @@ -8,7 +8,8 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('TeamsSubscriptionRenewal') @@ -16,23 +17,58 @@ const LOCK_KEY = 'teams-subscription-renewal-lock' /** Lock TTL in seconds — generous enough to cover the Graph API renewal loop. */ const LOCK_TTL_SECONDS = 300 -async function getCredentialOwner( - credentialId: string -): Promise<{ userId: string; accountId: string } | null> { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - logger.error(`Failed to resolve OAuth account for credential ${credentialId}`) +/** Microsoft Graph subscriptions are hard-capped at ~3 days. */ +const MAX_LIFETIME_MINUTES = 4230 + +/** + * Recreate a Teams chat subscription from scratch after the existing one has + * actually expired on Microsoft's side (PATCH returns 404/410). Without this, + * a subscription that expires while every renewal attempt in its 48h window + * failed (revoked consent, prolonged Graph outage, etc.) would stay dead + * forever — the webhook remains `isActive` but never receives events again. + */ +async function recreateSubscription( + webhook: Record, + config: Record, + accessToken: string +): Promise<{ id: string; expirationDateTime: string } | null> { + const chatId = config.chatId as string | undefined + if (!chatId) { + logger.error(`Missing chatId for webhook ${webhook.id}, cannot recreate subscription`) + return null + } + + const notificationUrl = getNotificationUrl(webhook) + const expirationDateTime = new Date(Date.now() + MAX_LIFETIME_MINUTES * 60 * 1000).toISOString() + + const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + changeType: 'created,updated', + notificationUrl, + lifecycleNotificationUrl: notificationUrl, + resource: `/chats/${chatId}/messages`, + includeResourceData: false, + expirationDateTime, + clientState: webhook.id, + }), + }) + + if (!res.ok) { + const error = await res.json() + logger.error(`Failed to recreate Teams subscription for webhook ${webhook.id}`, { + status: res.status, + error: error.error, + }) return null } - const [credentialRecord] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - return credentialRecord - ? { userId: credentialRecord.userId, accountId: resolved.accountId } - : null + + const payload = await res.json() + return { id: payload.id as string, expirationDateTime: payload.expirationDateTime as string } } /** @@ -53,7 +89,6 @@ async function renewExpiringSubscriptions(): Promise<{ let totalFailed = 0 let totalChecked = 0 - // Get all active Microsoft Teams webhooks const webhooksWithWorkflows = await db .select({ webhook: webhookTable, @@ -73,22 +108,22 @@ async function renewExpiringSubscriptions(): Promise<{ `Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions` ) - // Renewal threshold: 48 hours before expiration + /** Renew any subscription expiring within the next 48 hours. */ const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000) for (const { webhook } of webhooksWithWorkflows) { const config = (webhook.providerConfig as Record) || {} - // Check if this is a Teams chat subscription that needs renewal if (config.triggerId !== 'microsoftteams_chat_subscription') continue const expirationStr = config.subscriptionExpiration as string | undefined if (!expirationStr) continue const expiresAt = new Date(expirationStr) - if (expiresAt > renewalThreshold) continue // Not expiring soon + if (expiresAt > renewalThreshold) continue totalChecked++ + const requestId = `renewal-${webhook.id}` try { logger.info( @@ -104,18 +139,17 @@ async function renewExpiringSubscriptions(): Promise<{ continue } - const credentialOwner = await getCredentialOwner(credentialId) + const credentialOwner = await getCredentialOwner(credentialId, requestId) if (!credentialOwner) { logger.error(`Credential owner not found for credential ${credentialId}`) totalFailed++ continue } - // Get fresh access token const accessToken = await refreshAccessTokenIfNeeded( credentialOwner.accountId, credentialOwner.userId, - `renewal-${webhook.id}` + requestId ) if (!accessToken) { @@ -124,10 +158,8 @@ async function renewExpiringSubscriptions(): Promise<{ continue } - // Extend subscription to maximum lifetime (4230 minutes = ~3 days) - const maxLifetimeMinutes = 4230 const newExpirationDateTime = new Date( - Date.now() + maxLifetimeMinutes * 60 * 1000 + Date.now() + MAX_LIFETIME_MINUTES * 60 * 1000 ).toISOString() const res = await fetch( @@ -142,22 +174,40 @@ async function renewExpiringSubscriptions(): Promise<{ } ) + let newSubscriptionId: string | undefined + let newExpiration: string | undefined + if (!res.ok) { const error = await res.json() logger.error( `Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`, { status: res.status, error: error.error } ) - totalFailed++ - continue - } - const payload = await res.json() + if (res.status === 404 || res.status === 410) { + const recreated = await recreateSubscription(webhook, config, accessToken) + if (!recreated) { + totalFailed++ + continue + } + newSubscriptionId = recreated.id + newExpiration = recreated.expirationDateTime + logger.info( + `Recreated Teams subscription for webhook ${webhook.id} after the previous one expired (new id: ${newSubscriptionId})` + ) + } else { + totalFailed++ + continue + } + } else { + const payload = await res.json() + newExpiration = payload.expirationDateTime as string + } - // Update webhook config with new expiration const updatedConfig = { ...config, - subscriptionExpiration: payload.expirationDateTime, + ...(newSubscriptionId ? { externalSubscriptionId: newSubscriptionId } : {}), + subscriptionExpiration: newExpiration, } await db @@ -166,7 +216,7 @@ async function renewExpiringSubscriptions(): Promise<{ .where(eq(webhookTable.id, webhook.id)) logger.info( - `Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}` + `Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${newExpiration}` ) totalRenewed++ } catch (error) {