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) { 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/polling/hubspot.test.ts b/apps/sim/lib/webhooks/polling/hubspot.test.ts new file mode 100644 index 00000000000..286fedc98a0 --- /dev/null +++ b/apps/sim/lib/webhooks/polling/hubspot.test.ts @@ -0,0 +1,95 @@ +/** + * @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('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' }]), + }) + + // 3 shortcuts + 1 advanced = 4, exactly MAX_USER_FILTERS. + expect(filters).toHaveLength(4) + }) + + 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 65c64ecffa7..a854f28511b 100644 --- a/apps/sim/lib/webhooks/polling/hubspot.ts +++ b/apps/sim/lib/webhooks/polling/hubspot.ts @@ -101,6 +101,13 @@ 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 (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 const BUILT_IN_PATH: Record = { contact: 'contacts', @@ -530,14 +537,13 @@ function resolveRequestedProperties( return [...requested] } -function buildUserFilters( +export function buildUserFilters( config: HubSpotWebhookConfig, logger?: Logger, requestId?: string ): 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() }) @@ -577,6 +583,16 @@ 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) { + 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 } 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..b7d8978d077 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/github.test.ts @@ -0,0 +1,246 @@ +/** + * @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 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: '' }, + 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/lib/webhooks/providers/jira.test.ts b/apps/sim/lib/webhooks/providers/jira.test.ts new file mode 100644 index 00000000000..615f5b6ce7b --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jira.test.ts @@ -0,0 +1,196 @@ +/** + * @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('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: { + 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..3e7d850ab76 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, @@ -11,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) { @@ -89,7 +111,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 +134,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 @@ -131,13 +153,26 @@ 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 }, 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/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, 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}` } diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 1c261ba3ec3..e2105b0dce5 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: {}, @@ -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,12 +152,28 @@ 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') }) }) +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!( @@ -207,4 +222,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..cf212a00fc8 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, @@ -16,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']) @@ -26,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', @@ -34,7 +41,6 @@ const SLACK_INTERACTIVE_TYPES = new Set([ 'shortcut', 'view_submission', 'view_closed', - 'block_suggestion', ]) interface SlackDownloadedFile { @@ -198,6 +204,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() @@ -225,8 +233,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 @@ -440,9 +446,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 @@ -491,21 +500,28 @@ 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) { - const obj = body as Record - if (obj.event_id) { - return String(obj.event_id) + if (!isRecordLike(body)) { + return null } - const event = obj.event as Record | undefined - if (event?.ts && obj.team_id) { - return `${obj.team_id}:${event.ts}` + if (body.event_id) { + return String(body.event_id) } - // 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) + const event = isRecordLike(body.event) ? body.event : undefined + if (event?.ts && body.team_id) { + return `${body.team_id}:${event.ts}` + } + + if (body.trigger_id) { + return String(body.trigger_id) } return null @@ -519,19 +535,33 @@ 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 = 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) - // 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?.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)) || @@ -540,7 +570,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) { 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 }, diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts index f01d1333986..528674c89ac 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.test.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.test.ts @@ -81,5 +81,124 @@ 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() + }) + + 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) + }) + + 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', () => { + 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..f6dd8308322 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 { @@ -13,9 +14,42 @@ export const twilioVoiceHandler: WebhookProviderHandler = { return verifyTwilioAuth(ctx, 'Twilio Voice') }, + /** + * 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) { - const obj = body as Record - return (obj.MessageSid as string) || (obj.CallSid as string) || null + if (!isRecordLike(body)) return null + const sid = (body.MessageSid as string) || (body.CallSid as string) + if (!sid) return null + + 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) { @@ -46,7 +80,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..3ac6d1f3136 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' @@ -46,14 +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) { - 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 - // (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)) ?? '' @@ -62,7 +67,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, 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..831c2ceaeea --- /dev/null +++ b/apps/sim/lib/webhooks/providers/zendesk.test.ts @@ -0,0 +1,299 @@ +/** + * @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('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') + }) + + 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() + }) + }) +}) 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) }, diff --git a/apps/sim/triggers/github/utils.ts b/apps/sim/triggers/github/utils.ts index c7b31e8f6bf..d56634d7bd1 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 */ @@ -121,63 +123,71 @@ 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, 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, }, 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, }, 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, }, 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, }, 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] 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 && payload) { - return config.validator(payload) + if (config.validator) { + return config.validator(isRecordLike(payload) ? payload : {}) } return true 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)', 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..215533923a0 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,67 @@ 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 }, + } +} + +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, + jqlFilterDescription?: string +): SubBlockConfig[] { + const fields: SubBlockConfig[] = [jiraWebhookSecretField(triggerId)] + if (jqlFilterDescription) { + fields.push(jiraJqlFilterField(triggerId, jqlFilterDescription)) + } + if (triggerId === 'jira_issue_updated') { + fields.push(jiraFieldFiltersField(triggerId)) + } + return fields +} + function buildBaseWebhookOutputs(): Record { return { webhookEvent: { @@ -426,7 +489,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: ['*'], } @@ -435,12 +497,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)) @@ -448,7 +508,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 +520,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 +531,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 +743,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 +756,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 +769,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..aff1bf23c16 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,20 @@ 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'), + }), 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(), diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts index 99a1fc10230..1da79f322ec 100644 --- a/apps/sim/triggers/salesforce/utils.ts +++ b/apps/sim/triggers/salesforce/utils.ts @@ -153,20 +153,22 @@ 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). 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.', - '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). 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; 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: …. 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.`, - 'Add an HTTP CalloutPOST, JSON body, URL = webhook URL.', + '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.', 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: