From d7d9fa90af246536272c4cb666a0750f39aaeced Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:43:25 -0700 Subject: [PATCH 01/13] fix(instantly): add idempotency dedup for webhook deliveries Instantly webhook deliveries had no extractIdempotencyId, so retried deliveries were never deduped. Instantly doesn't send a delivery-id header, so key on email_id when present (unique per email/reply) and fall back to a content-based key (event_type + campaign_id + lead_email + event timestamp) otherwise. Added instantly.test.ts covering auth, event matching, formatInput/output alignment, idempotency, and the subscription lifecycle. --- .../lib/webhooks/providers/instantly.test.ts | 372 ++++++++++++++++++ apps/sim/lib/webhooks/providers/instantly.ts | 19 + 2 files changed, 391 insertions(+) create mode 100644 apps/sim/lib/webhooks/providers/instantly.test.ts diff --git a/apps/sim/lib/webhooks/providers/instantly.test.ts b/apps/sim/lib/webhooks/providers/instantly.test.ts new file mode 100644 index 00000000000..fe5a12d18a2 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/instantly.test.ts @@ -0,0 +1,372 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { instantlyHandler } from '@/lib/webhooks/providers/instantly' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('Instantly webhook provider', () => { + it('verifyAuth rejects when secretToken is missing (fail-closed)', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when the token header is missing', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { secretToken: 'expected-token' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects an incorrect token', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({ 'x-sim-webhook-token': 'wrong-token' }), + rawBody: '{}', + requestId: 't3', + providerConfig: { secretToken: 'expected-token' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts a matching token', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({ 'x-sim-webhook-token': 'expected-token' }), + rawBody: '{}', + requestId: 't4', + providerConfig: { secretToken: 'expected-token' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('matchEvent passes all events through for the generic webhook trigger', async () => { + const matched = await instantlyHandler.matchEvent!({ + body: { event_type: 'lead_interested' }, + requestId: 't5', + providerConfig: { triggerId: 'instantly_webhook' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matched).toBe(true) + }) + + it('matchEvent filters events that do not match the configured trigger', async () => { + const matched = await instantlyHandler.matchEvent!({ + body: { event_type: 'email_opened' }, + requestId: 't6', + providerConfig: { triggerId: 'instantly_email_sent' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matched).toBe(false) + }) + + it('matchEvent matches events for the configured trigger', async () => { + const matched = await instantlyHandler.matchEvent!({ + body: { event_type: 'email_sent' }, + requestId: 't7', + providerConfig: { triggerId: 'instantly_email_sent' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matched).toBe(true) + }) + + it('matchEvent accepts both link-click event type spellings', async () => { + const matchedA = await instantlyHandler.matchEvent!({ + body: { event_type: 'link_clicked' }, + requestId: 't8a', + providerConfig: { triggerId: 'instantly_link_clicked' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + const matchedB = await instantlyHandler.matchEvent!({ + body: { event_type: 'email_link_clicked' }, + requestId: 't8b', + providerConfig: { triggerId: 'instantly_link_clicked' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matchedA).toBe(true) + expect(matchedB).toBe(true) + }) + + it('formats input with keys matching the trigger outputs', async () => { + const body = { + timestamp: '2026-07-08T12:00:00.000Z', + event_type: 'reply_received', + workspace: 'ws-1', + campaign_id: 'camp-1', + campaign_name: 'Q3 Outreach', + lead_email: 'lead@example.com', + email_account: 'sender@example.com', + unibox_url: 'https://app.instantly.ai/unibox/1', + step: 2, + variant: 1, + is_first: true, + email_id: 'email-1', + reply_text_snippet: 'Sounds good', + reply_subject: 'Re: Q3 Outreach', + reply_text: 'Sounds good, thanks!', + reply_html: '

Sounds good, thanks!

', + } + + const result = await instantlyHandler.formatInput!({ + body, + webhook: {}, + workflow: { id: 'wf-1', userId: 'user-1' }, + headers: {}, + requestId: 't9', + }) + + expect(result.input).toEqual({ + timestamp: '2026-07-08T12:00:00.000Z', + eventType: 'reply_received', + workspace: 'ws-1', + campaignId: 'camp-1', + campaignName: 'Q3 Outreach', + leadEmail: 'lead@example.com', + emailAccount: 'sender@example.com', + uniboxUrl: 'https://app.instantly.ai/unibox/1', + step: 2, + variant: 1, + isFirst: true, + emailId: 'email-1', + emailSubject: null, + emailText: null, + emailHtml: null, + replyTextSnippet: 'Sounds good', + replySubject: 'Re: Q3 Outreach', + replyText: 'Sounds good, thanks!', + replyHtml: '

Sounds good, thanks!

', + payload: body, + }) + }) + + describe('extractIdempotencyId', () => { + it('prefers the email_id when present', () => { + const id = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_sent', + email_id: 'email-123', + campaign_id: 'camp-1', + lead_email: 'lead@example.com', + timestamp: '2026-07-08T12:00:00.000Z', + }) + expect(id).toBe('instantly:email_sent:email-123') + }) + + it('falls back to a content-based key without an email_id', () => { + const id = instantlyHandler.extractIdempotencyId!({ + event_type: 'lead_interested', + campaign_id: 'camp-1', + lead_email: 'lead@example.com', + timestamp: '2026-07-08T12:00:00.000Z', + }) + expect(id).toBe('instantly:lead_interested:camp-1:lead@example.com:2026-07-08T12:00:00.000Z') + }) + + it('is stable across retries of the same delivery', () => { + const body = { + event_type: 'lead_interested', + campaign_id: 'camp-1', + lead_email: 'lead@example.com', + timestamp: '2026-07-08T12:00:00.000Z', + } + const first = instantlyHandler.extractIdempotencyId!(body) + const second = instantlyHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + + it('returns null when there is not enough data to build a stable key', () => { + expect(instantlyHandler.extractIdempotencyId!({ event_type: 'account_error' })).toBeNull() + expect(instantlyHandler.extractIdempotencyId!({})).toBeNull() + expect(instantlyHandler.extractIdempotencyId!('not-an-object')).toBeNull() + }) + }) + + describe('createSubscription', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test') + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('creates an Instantly webhook with the mapped event type', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'wh-123' }), + }) + + const result = await instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-1', + path: 'abc-path', + providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_email_sent' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-1', + request: new NextRequest('http://localhost/test'), + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.instantly.ai/api/v2/webhooks') + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer instantly-key') + const sent = JSON.parse(init.body) + expect(sent.event_type).toBe('email_sent') + expect(sent.target_hook_url).toContain('/api/webhooks/trigger/abc-path') + expect(typeof sent.headers['X-Sim-Webhook-Token']).toBe('string') + + expect(result?.providerConfigUpdates?.externalId).toBe('wh-123') + }) + + it('maps the link-clicked trigger to the email_link_clicked subscription event', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'wh-456' }) }) + + await instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-2', + path: 'p2', + providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_link_clicked' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-2', + request: new NextRequest('http://localhost/test'), + }) + + const sent = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(sent.event_type).toBe('email_link_clicked') + }) + + it('throws a friendly error on 401', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ message: 'unauthorized' }), + }) + + await expect( + instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-3', + path: 'p3', + providerConfig: { triggerApiKey: 'bad-key', triggerId: 'instantly_email_sent' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-3', + request: new NextRequest('http://localhost/test'), + }) + ).rejects.toThrow(/Invalid Instantly API Key/) + }) + + it('throws when the API key is missing', async () => { + await expect( + instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-4', + path: 'p4', + providerConfig: { triggerId: 'instantly_email_sent' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-4', + request: new NextRequest('http://localhost/test'), + }) + ).rejects.toThrow(/Instantly API Key is required/) + expect(fetchMock).not.toHaveBeenCalled() + }) + }) + + describe('deleteSubscription', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('deletes the webhook by externalId', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, body: null }) + + await instantlyHandler.deleteSubscription!({ + webhook: { + id: 'webhook-1', + providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-123' }, + }, + workflow: {}, + requestId: 'req-del-1', + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.instantly.ai/api/v2/webhooks/wh-123') + expect(init.method).toBe('DELETE') + }) + + it('skips when apiKey or externalId is missing and does not throw', async () => { + await instantlyHandler.deleteSubscription!({ + webhook: { id: 'webhook-2', providerConfig: { externalId: 'wh-123' } }, + workflow: {}, + requestId: 'req-del-2', + }) + await instantlyHandler.deleteSubscription!({ + webhook: { id: 'webhook-3', providerConfig: { triggerApiKey: 'instantly-key' } }, + workflow: {}, + requestId: 'req-del-3', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not throw on a non-ok response in non-strict mode', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }) + + await expect( + instantlyHandler.deleteSubscription!({ + webhook: { + id: 'webhook-4', + providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-9' }, + }, + workflow: {}, + requestId: 'req-del-4', + }) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index fcfc81d48b0..6f6cd1d8686 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -57,6 +57,25 @@ export const instantlyHandler: WebhookProviderHandler = { return true }, + extractIdempotencyId(body: unknown): string | null { + if (!isRecordLike(body)) return null + + const eventType = typeof body.event_type === 'string' ? body.event_type : undefined + if (!eventType) return null + + const emailId = typeof body.email_id === 'string' ? body.email_id : undefined + if (emailId) return `instantly:${eventType}:${emailId}` + + const campaignId = typeof body.campaign_id === 'string' ? body.campaign_id : undefined + const leadEmail = typeof body.lead_email === 'string' ? body.lead_email : undefined + const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined + if (campaignId && leadEmail && timestamp) { + return `instantly:${eventType}:${campaignId}:${leadEmail}:${timestamp}` + } + + return null + }, + async formatInput({ body }: FormatInputContext): Promise { const payload = isRecordLike(body) ? body : {} From 33cb75f76ceb1593a6252ea8fddf8eab0f532a22 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:56:36 -0700 Subject: [PATCH 02/13] fix(gitlab): add delivery idempotency and fix reserved type-field collision risk extractIdempotencyId was missing entirely, so GitLab's automatic webhook retries (triggered after timeouts/5xx, up to 4 consecutive failures before temporary disable) would re-execute the workflow as a brand-new event. Added a content-derived fallback key (checkout_sha for push, object id + updated_at otherwise) since GitLab's X-Gitlab-Event-UUID delivery header isn't available to extractIdempotencyId (body-only). Also renamed the Issue Hook payload's object_attributes.type (GitLab 17.2+ work item type) to work_item_type in formatInput and exposed it in buildGitLabIssueOutputs, since 'type' collides with the TriggerOutput meta-key. Added detailed_merge_status to the merge request outputs to match the deprecation of merge_status. Added apps/sim/lib/webhooks/providers/gitlab.test.ts covering verifyAuth, matchEvent, formatInput, and extractIdempotencyId. --- .../sim/lib/webhooks/providers/gitlab.test.ts | 151 ++++++++++++++++++ apps/sim/lib/webhooks/providers/gitlab.ts | 46 +++++- apps/sim/triggers/gitlab/utils.ts | 7 +- 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/gitlab.test.ts diff --git a/apps/sim/lib/webhooks/providers/gitlab.test.ts b/apps/sim/lib/webhooks/providers/gitlab.test.ts new file mode 100644 index 00000000000..4c09e565a81 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gitlab.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { gitlabHandler } from '@/lib/webhooks/providers/gitlab' +import { isGitLabEventMatch } from '@/triggers/gitlab/utils' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('GitLab webhook provider', () => { + it('verifyAuth rejects when webhookSecret is missing', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when X-Gitlab-Token header is missing', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when the token does not match', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Gitlab-Token': 'wrong' }), + rawBody: '{}', + requestId: 't3', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts a matching X-Gitlab-Token', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Gitlab-Token': 'my-secret' }), + rawBody: '{}', + requestId: 't4', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('isGitLabEventMatch matches the configured trigger to its object_kind', () => { + expect(isGitLabEventMatch('gitlab_push', 'push')).toBe(true) + expect(isGitLabEventMatch('gitlab_push', 'issue')).toBe(false) + expect(isGitLabEventMatch('gitlab_comment', 'note')).toBe(true) + expect(isGitLabEventMatch('gitlab_webhook', 'anything')).toBe(true) + }) + + it('matchEvent passes through all events for the all-events trigger', async () => { + const result = await gitlabHandler.matchEvent!({ + body: { object_kind: 'issue' }, + requestId: 't5', + providerConfig: { triggerId: 'gitlab_webhook' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + + it('matchEvent filters events that do not match the configured trigger', async () => { + const result = await gitlabHandler.matchEvent!({ + body: { object_kind: 'issue' }, + requestId: 't6', + providerConfig: { triggerId: 'gitlab_push' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + + it('formatInput derives event_type and branch from the push payload', async () => { + const { input } = await gitlabHandler.formatInput!({ + body: { object_kind: 'push', ref: 'refs/heads/main', checkout_sha: 'abc123' }, + headers: { 'x-gitlab-event': 'Push Hook' }, + requestId: 't7', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.event_type).toBe('Push Hook') + expect(i.branch).toBe('main') + expect(i.checkout_sha).toBe('abc123') + }) + + it('formatInput renames object_attributes.type to work_item_type on issue payloads', async () => { + const { input } = await gitlabHandler.formatInput!({ + body: { + object_kind: 'issue', + object_attributes: { id: 1, iid: 2, title: 'Bug', type: 'Issue' }, + }, + headers: { 'x-gitlab-event': 'Issue Hook' }, + requestId: 't8', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + const attrs = i.object_attributes as Record + expect(attrs.work_item_type).toBe('Issue') + expect(attrs.type).toBeUndefined() + expect(attrs.title).toBe('Bug') + }) + + it('extractIdempotencyId derives a stable key for push events from checkout_sha', () => { + const body = { object_kind: 'push', project: { id: 42 }, checkout_sha: 'abc123' } + const first = gitlabHandler.extractIdempotencyId!(body) + const second = gitlabHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + expect(first).toContain('abc123') + expect(first).toContain('42') + }) + + it('extractIdempotencyId derives a stable key for issue events from object_attributes', () => { + const body = { + object_kind: 'issue', + project: { id: 7 }, + object_attributes: { id: 99, updated_at: '2026-01-01T00:00:00.000Z' }, + } + const first = gitlabHandler.extractIdempotencyId!(body) + const second = gitlabHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + expect(first).toContain('99') + expect(first).toContain('7') + }) + + it('extractIdempotencyId returns null when there is no stable identifier', () => { + expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'push' })).toBeNull() + expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'issue' })).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index d812ee19d92..8a21e6d7455 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -104,9 +104,51 @@ export const gitlabHandler: WebhookProviderHandler = { const eventType = headers['x-gitlab-event'] || '' const ref = (b.ref as string) || '' const branch = ref.replace('refs/heads/', '') - return { - input: { ...b, event_type: eventType, branch }, + + // GitLab 17.2+ adds a `type` field inside `object_attributes` on Issue Hook + // payloads (e.g. "Issue", "Incident", "Task"). `type` is a reserved + // TriggerOutput meta-key, so it's renamed to `work_item_type` here to match + // the declared output schema in buildGitLabIssueOutputs. + const objectAttributes = b.object_attributes + let input: Record = { ...b, event_type: eventType, branch } + if ( + objectAttributes && + typeof objectAttributes === 'object' && + !Array.isArray(objectAttributes) + ) { + const { type: workItemType, ...restAttributes } = objectAttributes as Record + if (workItemType !== undefined) { + input = { ...input, object_attributes: { ...restAttributes, work_item_type: workItemType } } + } } + + return { input } + }, + + /** + * GitLab doesn't include a delivery id in the body (X-Gitlab-Event-UUID is a + * header, and extractIdempotencyId only receives the body), so this falls back + * to a stable, content-derived key. `updated_at`/`checkout_sha` come from the + * payload itself and stay identical across GitLab's automatic retries, so the + * key never incorporates wall-clock time. + */ + extractIdempotencyId(body: unknown): string | null { + const b = asRecord(body) + const objectKind = (b.object_kind as string) || '' + const project = asRecord(b.project) + const projectId = project.id != null ? String(project.id) : '' + + if (objectKind === 'push' || objectKind === 'tag_push') { + const checkoutSha = (b.checkout_sha as string) || (b.after as string) || '' + if (!checkoutSha) return null + return `gitlab:${objectKind}:${projectId}:${checkoutSha}` + } + + const objectAttributes = asRecord(b.object_attributes) + const id = objectAttributes.id != null ? String(objectAttributes.id) : '' + if (!id) return null + const updatedAt = (objectAttributes.updated_at as string) || '' + return `gitlab:${objectKind || 'event'}:${projectId}:${id}:${updatedAt}` }, async createSubscription(ctx: SubscriptionContext): Promise { diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index a25d09e3447..38e20c64e99 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -160,7 +160,8 @@ export function buildGitLabMergeRequestOutputs(): Record action: { type: 'string', description: 'Action (open, close, reopen, update, merge, etc.)' }, source_branch: { type: 'string', description: 'Source branch' }, target_branch: { type: 'string', description: 'Target branch' }, - merge_status: { type: 'string', description: 'Merge status' }, + merge_status: { type: 'string', description: 'Merge status (deprecated by GitLab)' }, + detailed_merge_status: { type: 'string', description: 'Detailed merge status' }, draft: { type: 'boolean', description: 'Whether the merge request is a draft' }, url: { type: 'string', description: 'Merge request URL' }, }, @@ -182,6 +183,10 @@ export function buildGitLabIssueOutputs(): Record { description: { type: 'string', description: 'Issue description' }, confidential: { type: 'boolean', description: 'Whether the issue is confidential' }, url: { type: 'string', description: 'Issue URL' }, + work_item_type: { + type: 'string', + description: 'Work item type (e.g. Issue, Incident, Task); GitLab 17.2+ only', + }, }, } } From 4d477f6281f72774b06542266584d718c9d9218d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:49:10 -0700 Subject: [PATCH 03/13] fix(gmail): correct labels output type and add formatInput/schema alignment test The gmail_poller trigger declared its `labels` output as type: 'string' while the description and actual runtime value (email.labelIds) are a string array. Changed to type: 'json' to match the shape actually delivered, consistent with how other triggers type array-of-string fields. Added apps/sim/lib/webhooks/providers/gmail.test.ts covering gmailHandler.formatInput passthrough behavior and a regression check that every key formatInput can deliver on `email` matches a key declared in the trigger's output schema. --- apps/sim/lib/webhooks/providers/gmail.test.ts | 62 +++++++++++++++++++ apps/sim/triggers/gmail/poller.ts | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/webhooks/providers/gmail.test.ts diff --git a/apps/sim/lib/webhooks/providers/gmail.test.ts b/apps/sim/lib/webhooks/providers/gmail.test.ts new file mode 100644 index 00000000000..301ef37c372 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gmail.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { gmailHandler } from '@/lib/webhooks/providers/gmail' +import { gmailPollingTrigger } from '@/triggers/gmail' + +const sampleEmail = { + id: 'msg-123', + threadId: 'thread-456', + subject: 'Test subject', + from: 'sender@example.com', + to: 'recipient@example.com', + cc: '', + date: '2026-07-08T00:00:00.000Z', + bodyText: 'plain text body', + bodyHtml: '

html body

', + labels: ['INBOX', 'UNREAD'], + hasAttachments: false, + attachments: [], +} + +describe('Gmail webhook provider', () => { + it('formatInput passes through the polled email and timestamp unchanged', async () => { + const { input } = await gmailHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { email: sampleEmail, timestamp: '2026-07-08T00:00:05.000Z' }, + headers: {}, + requestId: 'test', + }) + + expect(input).toEqual({ + email: sampleEmail, + timestamp: '2026-07-08T00:00:05.000Z', + }) + }) + + it('passes the raw body through when it has no email key', async () => { + const { input } = await gmailHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { foo: 'bar' }, + headers: {}, + requestId: 'test', + }) + + expect(input).toEqual({ foo: 'bar' }) + }) + + it('every key formatInput can deliver on `email` matches a declared trigger output key', async () => { + const { input } = await gmailHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { email: sampleEmail, timestamp: '2026-07-08T00:00:05.000Z' }, + headers: {}, + requestId: 'test', + }) + + const declaredEmailKeys = Object.keys(gmailPollingTrigger.outputs.email) + const deliveredEmailKeys = Object.keys((input as { email: object }).email) + + expect(deliveredEmailKeys.sort()).toEqual(declaredEmailKeys.sort()) + }) +}) diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 6689f3fc9cb..614be2db482 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -184,7 +184,7 @@ Return ONLY the Gmail search query, no explanations or markdown.`, description: 'HTML email body', }, labels: { - type: 'string', + type: 'json', description: 'Email labels array', }, hasAttachments: { From 82a5e13a68a3b4590e8250a1439b7274c4e1104b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 15:57:22 -0700 Subject: [PATCH 04/13] fix(linear): tighten replay window, dedupe HMAC verify, add idempotency fallback - Reuse the shared createHmacVerifier helper for Linear's signature check instead of hand-rolling header/secret validation. - Tighten the webhookTimestamp replay-protection skew from 5 minutes to 60 seconds, matching Linear's documented recommendation. - Add extractIdempotencyId as a content-based fallback (type:action:id: updatedAt) for when the Linear-Delivery header is unavailable. - Remove unused teamOutputs/stateOutputs dead code from triggers/linear/utils.ts. --- .../sim/lib/webhooks/providers/linear.test.ts | 67 +++++++++++++++++++ apps/sim/lib/webhooks/providers/linear.ts | 53 +++++++++------ apps/sim/triggers/linear/utils.ts | 56 ---------------- 3 files changed, 101 insertions(+), 75 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts index 0f94977f3e7..3bb181f210e 100644 --- a/apps/sim/lib/webhooks/providers/linear.test.ts +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -75,4 +75,71 @@ describe('Linear webhook provider', () => { expect(res).toBeNull() }) + + it('rejects signed requests older than the 60s window Linear recommends', async () => { + const secret = 'linear-secret' + const rawBody = JSON.stringify({ + action: 'update', + type: 'Issue', + webhookTimestamp: Date.now() - 61_000, + }) + + const res = await linearHandler.verifyAuth!({ + request: requestWithLinearSignature(secret, rawBody), + rawBody, + requestId: 'linear-t4', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + + expect(res?.status).toBe(401) + }) + + it('skips verification entirely when no webhookSecret is configured', async () => { + const rawBody = JSON.stringify({ action: 'create', type: 'Issue' }) + + const res = await linearHandler.verifyAuth!({ + request: new NextRequest('http://localhost/test'), + rawBody, + requestId: 'linear-t5', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + + expect(res).toBeNull() + }) + + describe('extractIdempotencyId', () => { + it('builds a stable key from type, action, entity id, and updatedAt', () => { + const key = linearHandler.extractIdempotencyId!({ + type: 'Issue', + action: 'update', + data: { id: 'issue-1', updatedAt: '2026-07-08T00:00:00.000Z' }, + }) + + expect(key).toBe('linear:Issue:update:issue-1:2026-07-08T00:00:00.000Z') + }) + + it('falls back to createdAt when updatedAt is absent (create events)', () => { + const key = linearHandler.extractIdempotencyId!({ + type: 'Comment', + action: 'create', + data: { id: 'comment-1', createdAt: '2026-07-08T00:00:00.000Z' }, + }) + + expect(key).toBe('linear:Comment:create:comment-1:2026-07-08T00:00:00.000Z') + }) + + it('returns null when the entity id is missing', () => { + const key = linearHandler.extractIdempotencyId!({ type: 'Issue', action: 'create' }) + expect(key).toBeNull() + }) + + it('returns null when type is missing', () => { + const key = linearHandler.extractIdempotencyId!({ action: 'create', data: { id: 'x' } }) + expect(key).toBeNull() + }) + }) }) diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 4402659f71d..c8170dd473c 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -15,6 +15,7 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Linear') @@ -43,30 +44,26 @@ function validateLinearSignature(secret: string, signature: string, body: string } } -const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000 +/** Linear recommends rejecting webhooks not within 60s of the current time. @see https://linear.app/developers/webhooks */ +const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 60 * 1000 + +const verifyLinearSignature = createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'Linear-Signature', + validateFn: validateLinearSignature, + providerLabel: 'Linear', +}) export const linearHandler: WebhookProviderHandler = { - async verifyAuth({ - request, - rawBody, - requestId, - providerConfig, - }: AuthContext): Promise { - const secret = providerConfig.webhookSecret as string | undefined - if (!secret) { + async verifyAuth(ctx: AuthContext): Promise { + const { rawBody, requestId, providerConfig } = ctx + if (!providerConfig.webhookSecret) { + // Webhook secret is optional in Linear's setup UI; skip verification entirely when unset. return null } - const signature = request.headers.get('Linear-Signature') - if (!signature) { - logger.warn(`[${requestId}] Linear webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Linear signature', { status: 401 }) - } - - if (!validateLinearSignature(secret, signature, rawBody)) { - logger.warn(`[${requestId}] Linear signature verification failed`) - return new NextResponse('Unauthorized - Invalid Linear signature', { status: 401 }) - } + const signatureError = await verifyLinearSignature(ctx) + if (signatureError) return signatureError try { const parsed = JSON.parse(rawBody) as Record @@ -143,6 +140,24 @@ export const linearHandler: WebhookProviderHandler = { return true }, + /** + * Fallback for dedup when the `Linear-Delivery` header (already handled generically by the + * idempotency service) is unavailable. Keys on the entity id plus its own updatedAt/createdAt, + * not a request-time timestamp, so retried deliveries of the same event still collapse. + */ + extractIdempotencyId(body: unknown): string | null { + const b = body as Record + const type = typeof b.type === 'string' ? b.type : undefined + const action = typeof b.action === 'string' ? b.action : undefined + const data = b.data as Record | undefined + const id = typeof data?.id === 'string' ? data.id : undefined + if (!type || !id) { + return null + } + const version = data?.updatedAt || data?.createdAt || b.createdAt + return [`linear:${type}`, action, id, version].filter(Boolean).join(':') + }, + async createSubscription(ctx: SubscriptionContext): Promise { const config = getProviderConfig(ctx.webhook) const triggerId = config.triggerId as string | undefined diff --git a/apps/sim/triggers/linear/utils.ts b/apps/sim/triggers/linear/utils.ts index 371c1049681..2c29fdb484d 100644 --- a/apps/sim/triggers/linear/utils.ts +++ b/apps/sim/triggers/linear/utils.ts @@ -217,62 +217,6 @@ export const userOutputs = { }, } as const -/** - * Shared team output schema - */ -export const teamOutputs = { - id: { - type: 'string', - description: 'Team ID', - }, - name: { - type: 'string', - description: 'Team name', - }, - key: { - type: 'string', - description: 'Team key (used in issue identifiers)', - }, - description: { - type: 'string', - description: 'Team description', - }, - private: { - type: 'boolean', - description: 'Whether the team is private', - }, - timezone: { - type: 'string', - description: 'Team timezone', - }, -} as const - -/** - * Shared state output schema - */ -export const stateOutputs = { - id: { - type: 'string', - description: 'State ID', - }, - name: { - type: 'string', - description: 'State name', - }, - type: { - type: 'string', - description: 'State type (backlog, unstarted, started, completed, canceled)', - }, - color: { - type: 'string', - description: 'State color', - }, - position: { - type: 'number', - description: 'State position in the workflow', - }, -} as const - /** * Build output schema for issue events */ From 9156d9709ff1baf490e804917b58a55e373d51a2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:00:24 -0700 Subject: [PATCH 05/13] fix(gitlab): preserve raw object_attributes.type alongside work_item_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatInput previously spread the entire raw body, so object_attributes.type (GitLab 17.2+ work item type) was already reachable via a manual expression even though undocumented. The earlier fix renamed it to work_item_type in the delivered data too, which would silently break any pre-existing workflow referencing the raw path. Keep both keys in the delivered data — this is plain passthrough data, not schema-constrained, so there's no reserved-key collision risk in doing so; only the declared *schema* needs the work_item_type name. --- apps/sim/lib/webhooks/providers/gitlab.test.ts | 4 ++-- apps/sim/lib/webhooks/providers/gitlab.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.test.ts b/apps/sim/lib/webhooks/providers/gitlab.test.ts index 4c09e565a81..aebeb85803c 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.test.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.test.ts @@ -104,7 +104,7 @@ describe('GitLab webhook provider', () => { expect(i.checkout_sha).toBe('abc123') }) - it('formatInput renames object_attributes.type to work_item_type on issue payloads', async () => { + it('formatInput exposes object_attributes.type as work_item_type on issue payloads, keeping the raw type key too', async () => { const { input } = await gitlabHandler.formatInput!({ body: { object_kind: 'issue', @@ -118,7 +118,7 @@ describe('GitLab webhook provider', () => { const i = input as Record const attrs = i.object_attributes as Record expect(attrs.work_item_type).toBe('Issue') - expect(attrs.type).toBeUndefined() + expect(attrs.type).toBe('Issue') expect(attrs.title).toBe('Bug') }) diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 8a21e6d7455..7aa53e4ec99 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -107,8 +107,11 @@ export const gitlabHandler: WebhookProviderHandler = { // GitLab 17.2+ adds a `type` field inside `object_attributes` on Issue Hook // payloads (e.g. "Issue", "Incident", "Task"). `type` is a reserved - // TriggerOutput meta-key, so it's renamed to `work_item_type` here to match - // the declared output schema in buildGitLabIssueOutputs. + // TriggerOutput meta-key, so it can't be *declared* in the output schema + // under that name — exposed there as `work_item_type` instead. The raw + // `type` key is kept in the delivered data alongside it (this is plain + // passthrough data, not schema-constrained) so a workflow already + // referencing the undocumented raw path keeps working. const objectAttributes = b.object_attributes let input: Record = { ...b, event_type: eventType, branch } if ( @@ -116,9 +119,12 @@ export const gitlabHandler: WebhookProviderHandler = { typeof objectAttributes === 'object' && !Array.isArray(objectAttributes) ) { - const { type: workItemType, ...restAttributes } = objectAttributes as Record + const workItemType = (objectAttributes as Record).type if (workItemType !== undefined) { - input = { ...input, object_attributes: { ...restAttributes, work_item_type: workItemType } } + input = { + ...input, + object_attributes: { ...objectAttributes, work_item_type: workItemType }, + } } } From 731a9053b2a5256e701b5d381d1eaf3958bf2bff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:06:34 -0700 Subject: [PATCH 06/13] fix(instantly): differentiate idempotency key by timestamp when email_id repeats Instantly's is_first field ("whether this is the first event of this type for the lead") only makes sense if the same event_type can fire more than once for the same email_id: every open of the same email, every click, and every reply in a thread all share one email_id (Instantly's reply_to_uuid). Keying idempotency on email_id alone collapsed all of those distinct, legitimate deliveries into a single dedup slot, so every open/click/reply after the first was silently dropped for the 7-day idempotency TTL. Append timestamp (fixed per event occurrence, confirmed not to regenerate on retry) to keep the key both retry-stable and unique per occurrence. --- .../lib/webhooks/providers/instantly.test.ts | 39 ++++++++++++++++++- apps/sim/lib/webhooks/providers/instantly.ts | 18 ++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/instantly.test.ts b/apps/sim/lib/webhooks/providers/instantly.test.ts index fe5a12d18a2..a1c139a7a94 100644 --- a/apps/sim/lib/webhooks/providers/instantly.test.ts +++ b/apps/sim/lib/webhooks/providers/instantly.test.ts @@ -165,7 +165,7 @@ describe('Instantly webhook provider', () => { }) describe('extractIdempotencyId', () => { - it('prefers the email_id when present', () => { + it('prefers the email_id when present, qualified by timestamp', () => { const id = instantlyHandler.extractIdempotencyId!({ event_type: 'email_sent', email_id: 'email-123', @@ -173,6 +173,14 @@ describe('Instantly webhook provider', () => { lead_email: 'lead@example.com', timestamp: '2026-07-08T12:00:00.000Z', }) + expect(id).toBe('instantly:email_sent:email-123:2026-07-08T12:00:00.000Z') + }) + + it('falls back to the bare email_id when timestamp is missing', () => { + const id = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_sent', + email_id: 'email-123', + }) expect(id).toBe('instantly:email_sent:email-123') }) @@ -198,6 +206,35 @@ describe('Instantly webhook provider', () => { expect(first).toBe(second) }) + it('is stable across retries of the same email_id-keyed delivery', () => { + const body = { + event_type: 'email_opened', + email_id: 'email-123', + timestamp: '2026-07-08T12:00:00.000Z', + } + const first = instantlyHandler.extractIdempotencyId!(body) + const second = instantlyHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + + it('does not collide across distinct occurrences of the same email_id (e.g. repeat opens/clicks/replies)', () => { + // Instantly's `is_first` field only makes sense if the same event_type can fire + // more than once for the same email_id — every open, click, or in-thread reply + // shares one email_id. The key must differentiate occurrences via timestamp so a + // second legitimate open/click/reply is not silently dropped as a duplicate. + const firstOpen = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_opened', + email_id: 'email-123', + timestamp: '2026-07-08T12:00:00.000Z', + }) + const secondOpen = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_opened', + email_id: 'email-123', + timestamp: '2026-07-08T13:30:00.000Z', + }) + expect(firstOpen).not.toBe(secondOpen) + }) + it('returns null when there is not enough data to build a stable key', () => { expect(instantlyHandler.extractIdempotencyId!({ event_type: 'account_error' })).toBeNull() expect(instantlyHandler.extractIdempotencyId!({})).toBeNull() diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index 6f6cd1d8686..01afbeaa338 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -63,12 +63,26 @@ export const instantlyHandler: WebhookProviderHandler = { const eventType = typeof body.event_type === 'string' ? body.event_type : undefined if (!eventType) return null + const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined + + // `email_id` (Instantly's `reply_to_uuid`) identifies the sent email, not one + // occurrence of an event on that email. Instantly's `is_first` field ("whether this + // is the first event of this type for the lead") only makes sense if the same + // event_type can fire more than once for the same email_id — e.g. every open of the + // same email, every click, or every reply in a thread all share one email_id. Keying + // on email_id alone would collapse those distinct, legitimate deliveries into a + // single idempotency slot and silently drop everything after the first. `timestamp` + // is fixed per event occurrence (not regenerated on retry), so appending it keeps the + // key both retry-stable and unique per occurrence. const emailId = typeof body.email_id === 'string' ? body.email_id : undefined - if (emailId) return `instantly:${eventType}:${emailId}` + if (emailId) { + return timestamp + ? `instantly:${eventType}:${emailId}:${timestamp}` + : `instantly:${eventType}:${emailId}` + } const campaignId = typeof body.campaign_id === 'string' ? body.campaign_id : undefined const leadEmail = typeof body.lead_email === 'string' ? body.lead_email : undefined - const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined if (campaignId && leadEmail && timestamp) { return `instantly:${eventType}:${campaignId}:${leadEmail}:${timestamp}` } From 505c2ec1205b2b238b4ccbaf43ed30a3788a42ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:07:57 -0700 Subject: [PATCH 07/13] =?UTF-8?q?fix(linear):=20revert=20replay-window=20t?= =?UTF-8?q?ightening=20=E2=80=94=20retry=20timestamp=20semantics=20unverif?= =?UTF-8?q?ied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior pass tightened LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS from 5 minutes to Linear's suggested 60 seconds, justified by the claim that webhookTimestamp is re-stamped fresh on every delivery/retry attempt. That claim is not stated anywhere in Linear's webhook docs (verified against the raw docs page directly, including the exact wording around retries and the webhookTimestamp field) — the docs only say it is "the time when the webhook was sent" with no mention of retry behavior. A third-party implementation guide explicitly recommends a 5-minute window instead of Linear's literal 60s suggestion, citing this same ambiguity. If webhookTimestamp is actually fixed to the original event time (not refreshed per attempt), a strict 60s window would silently and permanently reject every one of Linear's own 1hr/6hr retry deliveries following any transient outage on our side, since Linear gives up after 3 failed attempts. Idempotency dedup (Linear-Delivery header / extractIdempotencyId fallback) already prevents double-processing of replayed/retried deliveries within a wider window, so the extra replay-protection from matching the literal 60s suggestion is marginal next to the risk of dropping real business events. Reverting to the 5-minute window pending explicit confirmation from Linear on retry timestamp semantics. --- apps/sim/lib/webhooks/providers/linear.test.ts | 4 ++-- apps/sim/lib/webhooks/providers/linear.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts index 3bb181f210e..68a6fd37bff 100644 --- a/apps/sim/lib/webhooks/providers/linear.test.ts +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -76,7 +76,7 @@ describe('Linear webhook provider', () => { expect(res).toBeNull() }) - it('rejects signed requests older than the 60s window Linear recommends', async () => { + it('accepts signed requests within the 5-minute skew window (wider than Linear’s literal 60s suggestion, to tolerate retries)', async () => { const secret = 'linear-secret' const rawBody = JSON.stringify({ action: 'update', @@ -93,7 +93,7 @@ describe('Linear webhook provider', () => { workflow: {}, }) - expect(res?.status).toBe(401) + expect(res).toBeNull() }) it('skips verification entirely when no webhookSecret is configured', async () => { diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index c8170dd473c..9d68bdaf4b4 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -44,8 +44,20 @@ function validateLinearSignature(secret: string, signature: string, body: string } } -/** Linear recommends rejecting webhooks not within 60s of the current time. @see https://linear.app/developers/webhooks */ -const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 60 * 1000 +/** + * Linear's docs recommend a 60s window ("Reject any webhooks not within 60 seconds of the + * current time to prevent replay attacks") but do NOT document whether `webhookTimestamp` is + * re-stamped per delivery attempt or fixed to the original event time. Linear's own retry policy + * resends failed deliveries after 1 minute, 1 hour, and 6 hours (@see + * https://linear.app/developers/webhooks) — if the timestamp is fixed rather than refreshed per + * attempt, a strict 60s window would silently and permanently drop every legitimate 1hr/6hr retry + * following any transient outage on our side, since Linear gives up after 3 failed attempts. + * We keep a wider 5-minute window: idempotency dedup (Linear-Delivery header / extractIdempotencyId + * fallback below) already prevents double-processing of any replayed or retried delivery within + * that window, so the incremental replay-protection benefit of matching Linear's 60s suggestion + * literally is marginal compared to the risk of dropping real business events. + */ +const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000 const verifyLinearSignature = createHmacVerifier({ configKey: 'webhookSecret', From 2355427e45d5ad72f8968faf9fe1ba8334eb44c8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:08:37 -0700 Subject: [PATCH 08/13] fix(gitlab): include ref in push idempotency key to avoid branch-deletion collisions GitLab sets checkout_sha to null on branch/tag deletion, so the extractIdempotencyId fallback (checkout_sha || after) degenerated to the all-zeros SHA for every deletion. Without ref in the key, two unrelated branch deletions in the same project produced the same idempotency key, silently dropping the second (and any subsequent) legitimate trigger execution as a false duplicate. --- .../sim/lib/webhooks/providers/gitlab.test.ts | 44 ++++++++++++++++++- apps/sim/lib/webhooks/providers/gitlab.ts | 20 ++++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.test.ts b/apps/sim/lib/webhooks/providers/gitlab.test.ts index aebeb85803c..79f335b3757 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.test.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.test.ts @@ -123,7 +123,12 @@ describe('GitLab webhook provider', () => { }) it('extractIdempotencyId derives a stable key for push events from checkout_sha', () => { - const body = { object_kind: 'push', project: { id: 42 }, checkout_sha: 'abc123' } + const body = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/main', + checkout_sha: 'abc123', + } const first = gitlabHandler.extractIdempotencyId!(body) const second = gitlabHandler.extractIdempotencyId!({ ...body }) expect(first).toBe(second) @@ -131,6 +136,43 @@ describe('GitLab webhook provider', () => { expect(first).toContain('42') }) + it('extractIdempotencyId does not collide across different branches deleted in the same project', () => { + // GitLab sets checkout_sha to null and after to the all-zeros SHA on branch + // deletion, so two unrelated branch deletions must not produce the same key. + const deleteMain = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/main', + checkout_sha: null, + after: '0000000000000000000000000000000000000000', + } + const deleteFeature = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/feature', + checkout_sha: null, + after: '0000000000000000000000000000000000000000', + } + const first = gitlabHandler.extractIdempotencyId!(deleteMain) + const second = gitlabHandler.extractIdempotencyId!(deleteFeature) + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(first).not.toBe(second) + }) + + it('extractIdempotencyId is stable for a repeated delivery of the same branch deletion', () => { + const body = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/main', + checkout_sha: null, + after: '0000000000000000000000000000000000000000', + } + const first = gitlabHandler.extractIdempotencyId!(body) + const second = gitlabHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + it('extractIdempotencyId derives a stable key for issue events from object_attributes', () => { const body = { object_kind: 'issue', diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 7aa53e4ec99..f894b2154d9 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -133,10 +133,12 @@ export const gitlabHandler: WebhookProviderHandler = { /** * GitLab doesn't include a delivery id in the body (X-Gitlab-Event-UUID is a - * header, and extractIdempotencyId only receives the body), so this falls back - * to a stable, content-derived key. `updated_at`/`checkout_sha` come from the - * payload itself and stay identical across GitLab's automatic retries, so the - * key never incorporates wall-clock time. + * header — the shared idempotency service already keys on it when present, + * ahead of this method — and extractIdempotencyId only receives the body), + * so this is a fallback content-derived key for when that header is absent. + * `updated_at`/`ref`/`checkout_sha` come from the payload itself and stay + * identical across GitLab's automatic retries, so the key never incorporates + * wall-clock time. */ extractIdempotencyId(body: unknown): string | null { const b = asRecord(body) @@ -145,9 +147,15 @@ export const gitlabHandler: WebhookProviderHandler = { const projectId = project.id != null ? String(project.id) : '' if (objectKind === 'push' || objectKind === 'tag_push') { + // GitLab sets checkout_sha to null on branch/tag deletion, so the fallback + // to `after` (the all-zeros SHA on deletion) is not unique by itself — two + // unrelated branches deleted in the same project would otherwise collide + // on the same key. `ref` disambiguates by branch/tag so a real deletion of + // a different ref is never mistaken for a duplicate of a prior one. + const ref = (b.ref as string) || '' const checkoutSha = (b.checkout_sha as string) || (b.after as string) || '' - if (!checkoutSha) return null - return `gitlab:${objectKind}:${projectId}:${checkoutSha}` + if (!checkoutSha && !ref) return null + return `gitlab:${objectKind}:${projectId}:${ref}:${checkoutSha}` } const objectAttributes = asRecord(b.object_attributes) From 69ffc020539eda10050154d80610f3d2962001da Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:09:22 -0700 Subject: [PATCH 09/13] fix(gmail): use 'array' not 'json' for poller labels output type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior pass changed the gmail_poller trigger's `labels` output from 'string' to 'json', which is directionally correct (labelIds is a string array, not a string) but not the precise type. The codebase's own PrimitiveValueType union has a dedicated 'array' variant, used by sibling triggers with identical semantics (github issue/PR triggers, Jira triggers all declare their `labels` field as type: 'array'). Concretely, 'json' vs 'array' diverges in generateMockValue (apps/sim/lib/workflows/triggers/trigger-utils.ts), which backs the auto-generated "Event Payload Example" sample payload shown in the trigger config UI (apps/sim/triggers/index.ts, gated on trigger.id.includes('poller') — gmail_poller qualifies). 'json' mocks as a single generic object ({id, name, status}), actively misleading for a field that is always an array. 'array' mocks as a list, matching the true shape (Gmail API docs confirm labelIds is a string array on the Message resource). No behavioral difference in output-path validation/resolution (isPathInSchema, generateOutputPaths) since the field declares no items/properties either way — this is purely a type-declaration correctness fix, verified against Gmail API docs and cross-checked against every other output field in poller.ts (all match runtime shape). --- apps/sim/triggers/gmail/poller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 614be2db482..1d03c838f3b 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -184,7 +184,7 @@ Return ONLY the Gmail search query, no explanations or markdown.`, description: 'HTML email body', }, labels: { - type: 'json', + type: 'array', description: 'Email labels array', }, hasAttachments: { From 77dd673c280b2504fd80c9d1d7b9b80618e4d4b8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:23:33 -0700 Subject: [PATCH 10/13] docs(triggers): correct GitLab retry claim, convert inline comments to TSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitLab does not automatically retry failed webhook deliveries — a failed request only counts toward auto-disabling the webhook (4 consecutive failures = temporary disable, 40 = permanent), and re-delivery only happens via a manual "Resend Request" (UI or API), which carries the same webhook-id/Idempotency-Key/X-Gitlab-Event-UUID headers as the original delivery. Those headers are already in the shared idempotency service's allowlist and checked ahead of extractIdempotencyId, so the body-based fallback matters for the rare case those headers are stripped in transit — not for "automatic retries" as previously stated. Verified directly against docs.gitlab.com. Also replaced inline `//` comments across the gitlab/gmail/linear/ instantly provider and trigger files with TSDoc blocks on the relevant declaration where the reasoning was non-obvious, or removed them outright where they only restated adjacent code. --- .../sim/lib/webhooks/providers/gitlab.test.ts | 2 - apps/sim/lib/webhooks/providers/gitlab.ts | 56 ++++++++++--------- .../lib/webhooks/providers/instantly.test.ts | 4 -- apps/sim/lib/webhooks/providers/instantly.ts | 19 +++---- apps/sim/lib/webhooks/providers/linear.ts | 1 - apps/sim/triggers/gitlab/utils.ts | 9 ++- apps/sim/triggers/linear/utils.ts | 2 - 7 files changed, 45 insertions(+), 48 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.test.ts b/apps/sim/lib/webhooks/providers/gitlab.test.ts index 79f335b3757..5ba4b94bc25 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.test.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.test.ts @@ -137,8 +137,6 @@ describe('GitLab webhook provider', () => { }) it('extractIdempotencyId does not collide across different branches deleted in the same project', () => { - // GitLab sets checkout_sha to null and after to the all-zeros SHA on branch - // deletion, so two unrelated branch deletions must not produce the same key. const deleteMain = { object_kind: 'push', project: { id: 42 }, diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index f894b2154d9..c002da39ec3 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -99,19 +99,20 @@ export const gitlabHandler: WebhookProviderHandler = { return true }, + /** + * GitLab 17.2+ adds a `type` field inside `object_attributes` on Issue Hook + * payloads (e.g. "Issue", "Incident", "Task"). `type` is a reserved + * TriggerOutput meta-key, so it can't be declared in the output schema + * under that name — exposed there as `work_item_type` instead. The raw + * `type` key is kept in the delivered data alongside it (this is plain + * passthrough data, not schema-constrained) so a workflow already + * referencing the undocumented raw path keeps working. + */ async formatInput({ body, headers }: FormatInputContext): Promise { const b = asRecord(body) const eventType = headers['x-gitlab-event'] || '' const ref = (b.ref as string) || '' const branch = ref.replace('refs/heads/', '') - - // GitLab 17.2+ adds a `type` field inside `object_attributes` on Issue Hook - // payloads (e.g. "Issue", "Incident", "Task"). `type` is a reserved - // TriggerOutput meta-key, so it can't be *declared* in the output schema - // under that name — exposed there as `work_item_type` instead. The raw - // `type` key is kept in the delivered data alongside it (this is plain - // passthrough data, not schema-constrained) so a workflow already - // referencing the undocumented raw path keeps working. const objectAttributes = b.object_attributes let input: Record = { ...b, event_type: eventType, branch } if ( @@ -132,13 +133,18 @@ export const gitlabHandler: WebhookProviderHandler = { }, /** - * GitLab doesn't include a delivery id in the body (X-Gitlab-Event-UUID is a - * header — the shared idempotency service already keys on it when present, - * ahead of this method — and extractIdempotencyId only receives the body), - * so this is a fallback content-derived key for when that header is absent. - * `updated_at`/`ref`/`checkout_sha` come from the payload itself and stay - * identical across GitLab's automatic retries, so the key never incorporates - * wall-clock time. + * GitLab does not automatically retry a failed delivery — a failed request + * only counts toward auto-disabling the webhook (4 consecutive failures + * disables it temporarily, 40 permanently), and re-delivery only happens + * via a manual "Resend Request" (UI or API), which carries the same + * `webhook-id`/`Idempotency-Key`/`X-Gitlab-Event-UUID` headers as the + * original. Those headers are already in the shared idempotency service's + * allowlist and checked ahead of this method, which only receives the body. + * This is a content-derived fallback for the rare case those headers are + * stripped in transit (e.g. by an intermediary proxy). checkout_sha is + * null on branch/tag deletion (after falls back to the all-zeros SHA), so + * ref is included to keep unrelated deletions in one project from + * colliding onto the same key. */ extractIdempotencyId(body: unknown): string | null { const b = asRecord(body) @@ -147,11 +153,6 @@ export const gitlabHandler: WebhookProviderHandler = { const projectId = project.id != null ? String(project.id) : '' if (objectKind === 'push' || objectKind === 'tag_push') { - // GitLab sets checkout_sha to null on branch/tag deletion, so the fallback - // to `after` (the all-zeros SHA on deletion) is not unique by itself — two - // unrelated branches deleted in the same project would otherwise collide - // on the same key. `ref` disambiguates by branch/tag so a real deletion of - // a different ref is never mistaken for a duplicate of a prior one. const ref = (b.ref as string) || '' const checkoutSha = (b.checkout_sha as string) || (b.after as string) || '' if (!checkoutSha && !ref) return null @@ -165,6 +166,11 @@ export const gitlabHandler: WebhookProviderHandler = { return `gitlab:${objectKind || 'event'}:${projectId}:${id}:${updatedAt}` }, + /** + * Validates the optional self-managed host up front so a structurally + * unsafe value surfaces as a clear error instead of an unhandled + * UnsafeGitLabHostError. + */ async createSubscription(ctx: SubscriptionContext): Promise { const config = getProviderConfig(ctx.webhook) const accessToken = config.accessToken as string | undefined @@ -176,8 +182,6 @@ export const gitlabHandler: WebhookProviderHandler = { throw new Error('GitLab Personal Access Token is required to create the webhook.') if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.') - // Validate the optional self-managed host up front so a structurally unsafe - // value surfaces as a clear error instead of an unhandled UnsafeGitLabHostError. try { getGitLabApiBase(host) } catch (error) { @@ -219,8 +223,6 @@ export const gitlabHandler: WebhookProviderHandler = { const created = (await res.json().catch(() => ({}))) as { id?: number | string } if (created.id === undefined || created.id === null) { - // The hook was created but we can't read its id — delete it by URL so it - // is not orphaned in GitLab. await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook), host) throw new Error('GitLab webhook created but no hook ID was returned.') } @@ -229,6 +231,10 @@ export const gitlabHandler: WebhookProviderHandler = { return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } } }, + /** + * A structurally unsafe host must not abort cleanup in non-strict mode — + * mirrors the graceful skip used for missing credentials below. + */ async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { const config = getProviderConfig(ctx.webhook) const accessToken = config.accessToken as string | undefined @@ -244,8 +250,6 @@ export const gitlabHandler: WebhookProviderHandler = { return } - // A structurally unsafe host must not abort cleanup in non-strict mode — mirror - // the graceful skip used for missing credentials above. try { getGitLabApiBase(host) } catch (error) { diff --git a/apps/sim/lib/webhooks/providers/instantly.test.ts b/apps/sim/lib/webhooks/providers/instantly.test.ts index a1c139a7a94..c4a6e578e2d 100644 --- a/apps/sim/lib/webhooks/providers/instantly.test.ts +++ b/apps/sim/lib/webhooks/providers/instantly.test.ts @@ -218,10 +218,6 @@ describe('Instantly webhook provider', () => { }) it('does not collide across distinct occurrences of the same email_id (e.g. repeat opens/clicks/replies)', () => { - // Instantly's `is_first` field only makes sense if the same event_type can fire - // more than once for the same email_id — every open, click, or in-thread reply - // shares one email_id. The key must differentiate occurrences via timestamp so a - // second legitimate open/click/reply is not silently dropped as a duplicate. const firstOpen = instantlyHandler.extractIdempotencyId!({ event_type: 'email_opened', email_id: 'email-123', diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index 01afbeaa338..560baaadf1e 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -57,6 +57,15 @@ export const instantlyHandler: WebhookProviderHandler = { return true }, + /** + * `email_id` (Instantly's `reply_to_uuid`) identifies the sent email, not + * one occurrence of an event on that email — the same event_type can fire + * more than once for one email_id (every open, every click, every reply in + * a thread), so email_id alone would collapse those distinct, legitimate + * deliveries into one idempotency slot. `timestamp` is fixed per event + * occurrence (not regenerated on retry), so appending it keeps the key + * both retry-stable and unique per occurrence. + */ extractIdempotencyId(body: unknown): string | null { if (!isRecordLike(body)) return null @@ -64,16 +73,6 @@ export const instantlyHandler: WebhookProviderHandler = { if (!eventType) return null const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined - - // `email_id` (Instantly's `reply_to_uuid`) identifies the sent email, not one - // occurrence of an event on that email. Instantly's `is_first` field ("whether this - // is the first event of this type for the lead") only makes sense if the same - // event_type can fire more than once for the same email_id — e.g. every open of the - // same email, every click, or every reply in a thread all share one email_id. Keying - // on email_id alone would collapse those distinct, legitimate deliveries into a - // single idempotency slot and silently drop everything after the first. `timestamp` - // is fixed per event occurrence (not regenerated on retry), so appending it keeps the - // key both retry-stable and unique per occurrence. const emailId = typeof body.email_id === 'string' ? body.email_id : undefined if (emailId) { return timestamp diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 9d68bdaf4b4..9c6404b5874 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -70,7 +70,6 @@ export const linearHandler: WebhookProviderHandler = { async verifyAuth(ctx: AuthContext): Promise { const { rawBody, requestId, providerConfig } = ctx if (!providerConfig.webhookSecret) { - // Webhook secret is optional in Linear's setup UI; skip verification entirely when unset. return null } diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index 38e20c64e99..be47e1bcf47 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -38,9 +38,12 @@ const ALL_EVENT_FLAGS = { tag_push_events: true, } as const -// Tag pushes (object_kind 'tag_push') only flow through the all-events trigger; -// there is no dedicated single-event trigger for them. A future "GitLab Tag Push" -// trigger would need its own object_kind mapping in TRIGGER_OBJECT_KINDS above. +/** + * Tag pushes (object_kind 'tag_push') only flow through the all-events + * trigger; there is no dedicated single-event trigger for them. A future + * "GitLab Tag Push" trigger would need its own object_kind mapping in + * TRIGGER_OBJECT_KINDS above. + */ const TRIGGER_EVENT_FLAGS: Record> = { gitlab_push: { push_events: true }, gitlab_merge_request: { merge_requests_events: true }, diff --git a/apps/sim/triggers/linear/utils.ts b/apps/sim/triggers/linear/utils.ts index 2c29fdb484d..5970288923e 100644 --- a/apps/sim/triggers/linear/utils.ts +++ b/apps/sim/triggers/linear/utils.ts @@ -1033,12 +1033,10 @@ export function isLinearEventMatch(triggerId: string, eventType: string, action? return false } - // Check event type if (config.type !== eventType) { return false } - // Check action if specified if (config.actions && action && !config.actions.includes(action)) { return false } From 01ac230c4d559dc81a59bc0df05e7d9495ea215e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:25:12 -0700 Subject: [PATCH 11/13] fix(triggers): close two Greptile-flagged crash/collision gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instantly: the timestamp-present branch was fixed for the email_id collision risk, but the no-timestamp fallback still keyed on bare email_id — same collision risk, unfixed. Return null instead so dedup is skipped rather than risking a false collision between repeat opens/ clicks/replies on the same email. linear: extractIdempotencyId cast `body` straight to a Record without checking it was actually an object first, so a JSON `null` body (no Linear-Delivery header available) would throw on `b.type` and turn a malformed payload into a webhook 500 instead of a clean skip. --- apps/sim/lib/webhooks/providers/instantly.test.ts | 4 ++-- apps/sim/lib/webhooks/providers/instantly.ts | 8 ++++---- apps/sim/lib/webhooks/providers/linear.test.ts | 10 ++++++++++ apps/sim/lib/webhooks/providers/linear.ts | 2 ++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/instantly.test.ts b/apps/sim/lib/webhooks/providers/instantly.test.ts index c4a6e578e2d..9e3584cde9a 100644 --- a/apps/sim/lib/webhooks/providers/instantly.test.ts +++ b/apps/sim/lib/webhooks/providers/instantly.test.ts @@ -176,12 +176,12 @@ describe('Instantly webhook provider', () => { expect(id).toBe('instantly:email_sent:email-123:2026-07-08T12:00:00.000Z') }) - it('falls back to the bare email_id when timestamp is missing', () => { + it('returns null when email_id is present but timestamp is missing, rather than risk a false collision', () => { const id = instantlyHandler.extractIdempotencyId!({ event_type: 'email_sent', email_id: 'email-123', }) - expect(id).toBe('instantly:email_sent:email-123') + expect(id).toBeNull() }) it('falls back to a content-based key without an email_id', () => { diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index 560baaadf1e..30acd025ce1 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -64,7 +64,9 @@ export const instantlyHandler: WebhookProviderHandler = { * a thread), so email_id alone would collapse those distinct, legitimate * deliveries into one idempotency slot. `timestamp` is fixed per event * occurrence (not regenerated on retry), so appending it keeps the key - * both retry-stable and unique per occurrence. + * both retry-stable and unique per occurrence — without it there's no way + * to distinguish occurrences, so dedup is skipped rather than risking a + * false collision. */ extractIdempotencyId(body: unknown): string | null { if (!isRecordLike(body)) return null @@ -75,9 +77,7 @@ export const instantlyHandler: WebhookProviderHandler = { const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined const emailId = typeof body.email_id === 'string' ? body.email_id : undefined if (emailId) { - return timestamp - ? `instantly:${eventType}:${emailId}:${timestamp}` - : `instantly:${eventType}:${emailId}` + return timestamp ? `instantly:${eventType}:${emailId}:${timestamp}` : null } const campaignId = typeof body.campaign_id === 'string' ? body.campaign_id : undefined diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts index 68a6fd37bff..9e874de7d41 100644 --- a/apps/sim/lib/webhooks/providers/linear.test.ts +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -141,5 +141,15 @@ describe('Linear webhook provider', () => { const key = linearHandler.extractIdempotencyId!({ action: 'create', data: { id: 'x' } }) expect(key).toBeNull() }) + + it('returns null instead of throwing when the body is null', () => { + expect(linearHandler.extractIdempotencyId!(null)).toBeNull() + }) + + it('returns null instead of throwing when the body is a non-object', () => { + expect(linearHandler.extractIdempotencyId!('not an object')).toBeNull() + expect(linearHandler.extractIdempotencyId!(42)).toBeNull() + expect(linearHandler.extractIdempotencyId!(['array'])).toBeNull() + }) }) }) diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 9c6404b5874..fb6d95b5a83 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -157,6 +157,8 @@ export const linearHandler: WebhookProviderHandler = { * not a request-time timestamp, so retried deliveries of the same event still collapse. */ extractIdempotencyId(body: unknown): string | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null + const b = body as Record const type = typeof b.type === 'string' ? b.type : undefined const action = typeof b.action === 'string' ? b.action : undefined From fa9a792c944d54e61a06ad446eca084af8865633 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:28:39 -0700 Subject: [PATCH 12/13] fix(linear): guard matchEvent and formatInput against a null body Found during a final backwards-compat pass, same class of bug Greptile just caught in extractIdempotencyId: both cast body straight to a Record without checking it was actually an object, so a genuinely null/malformed body would throw instead of degrading gracefully. gitlab.ts (asRecord's `|| {}`) and instantly.ts (isRecordLike checks) already guard this everywhere; linear.ts was the one inconsistent provider. Uses the same isRecordLike helper as instantly.ts. --- .../sim/lib/webhooks/providers/linear.test.ts | 21 +++++++++++++++++++ apps/sim/lib/webhooks/providers/linear.ts | 9 ++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts index 9e874de7d41..9962ba1f790 100644 --- a/apps/sim/lib/webhooks/providers/linear.test.ts +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -111,6 +111,27 @@ describe('Linear webhook provider', () => { expect(res).toBeNull() }) + describe('matchEvent', () => { + it('returns null-body-safe result instead of throwing when body is null', async () => { + const result = await linearHandler.matchEvent!({ + body: null, + requestId: 'linear-t6', + providerConfig: { triggerId: 'linear_issue_created' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(result).toBe(false) + }) + }) + + describe('formatInput', () => { + it('does not throw when body is null', async () => { + const { input } = await linearHandler.formatInput!({ body: null } as any) + expect(input).toMatchObject({ action: '', type: '', actor: null }) + }) + }) + describe('extractIdempotencyId', () => { it('builds a stable key from type, action, entity id, and updatedAt', () => { const key = linearHandler.extractIdempotencyId!({ diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index fb6d95b5a83..dd0068ff561 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -3,6 +3,7 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -106,7 +107,7 @@ export const linearHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record + const b = isRecordLike(body) ? body : {} const rawActor = b.actor let actor: unknown = null if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) { @@ -138,9 +139,9 @@ export const linearHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (triggerId && !triggerId.endsWith('_webhook') && !triggerId.endsWith('_webhook_v2')) { const { isLinearEventMatch } = await import('@/triggers/linear/utils') - const obj = body as Record - const action = obj.action as string | undefined - const type = obj.type as string | undefined + const obj = isRecordLike(body) ? body : {} + const action = typeof obj.action === 'string' ? obj.action : undefined + const type = typeof obj.type === 'string' ? obj.type : undefined if (!isLinearEventMatch(triggerId, type || '', action)) { logger.debug( `[${requestId}] Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.` From 96acfc41e52f927a9143fa664918f4db9f7912d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 17:30:37 -0700 Subject: [PATCH 13/13] fix(gitlab): distinguish pipeline lifecycle transitions in idempotency key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline Hook payloads have no updated_at field (confirmed against docs.gitlab.com — only Issue/Merge Request/Note hooks reliably include it), so every lifecycle transition of the same pipeline (pending -> running -> success/failed) collapsed onto the identical fallback key and later real transitions were skipped as duplicates. Falls back to status + finished_at/created_at when updated_at is absent. --- .../sim/lib/webhooks/providers/gitlab.test.ts | 38 +++++++++++++++++++ apps/sim/lib/webhooks/providers/gitlab.ts | 15 ++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.test.ts b/apps/sim/lib/webhooks/providers/gitlab.test.ts index 5ba4b94bc25..5ac2143a252 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.test.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.test.ts @@ -184,6 +184,44 @@ describe('GitLab webhook provider', () => { expect(first).toContain('7') }) + it('extractIdempotencyId distinguishes pipeline lifecycle transitions despite no updated_at', () => { + const pending = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { id: 31, status: 'pending', created_at: '2026-01-01T00:00:00Z' }, + }) + const running = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { id: 31, status: 'running', created_at: '2026-01-01T00:00:00Z' }, + }) + const success = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { + id: 31, + status: 'success', + created_at: '2026-01-01T00:00:00Z', + finished_at: '2026-01-01T00:03:00Z', + }, + }) + expect(pending).not.toBeNull() + expect(pending).not.toBe(running) + expect(running).not.toBe(success) + + const retryOfSuccess = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { + id: 31, + status: 'success', + created_at: '2026-01-01T00:00:00Z', + finished_at: '2026-01-01T00:03:00Z', + }, + }) + expect(success).toBe(retryOfSuccess) + }) + it('extractIdempotencyId returns null when there is no stable identifier', () => { expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'push' })).toBeNull() expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'issue' })).toBeNull() diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index c002da39ec3..2d2d9e66f58 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -144,7 +144,11 @@ export const gitlabHandler: WebhookProviderHandler = { * stripped in transit (e.g. by an intermediary proxy). checkout_sha is * null on branch/tag deletion (after falls back to the all-zeros SHA), so * ref is included to keep unrelated deletions in one project from - * colliding onto the same key. + * colliding onto the same key. Pipeline Hook payloads have no updated_at + * at all (confirmed against docs.gitlab.com — only Issue/Merge Request/ + * Note hooks reliably include it), so status + finished_at/created_at is + * used there instead to keep each lifecycle transition of one pipeline + * (pending/running/success/failed) from colliding onto the same key. */ extractIdempotencyId(body: unknown): string | null { const b = asRecord(body) @@ -162,8 +166,13 @@ export const gitlabHandler: WebhookProviderHandler = { const objectAttributes = asRecord(b.object_attributes) const id = objectAttributes.id != null ? String(objectAttributes.id) : '' if (!id) return null - const updatedAt = (objectAttributes.updated_at as string) || '' - return `gitlab:${objectKind || 'event'}:${projectId}:${id}:${updatedAt}` + const version = + (objectAttributes.updated_at as string) || + [objectAttributes.status, objectAttributes.finished_at || objectAttributes.created_at] + .filter(Boolean) + .join(':') || + '' + return `gitlab:${objectKind || 'event'}:${projectId}:${id}:${version}` }, /**