diff --git a/apps/sim/lib/webhooks/providers/ashby.test.ts b/apps/sim/lib/webhooks/providers/ashby.test.ts new file mode 100644 index 00000000000..f51eb1f8d9a --- /dev/null +++ b/apps/sim/lib/webhooks/providers/ashby.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment node + */ +import crypto from 'crypto' +import { createMockRequest } from '@sim/testing' +import { describe, expect, it } from 'vitest' +import { ashbyHandler } from '@/lib/webhooks/providers/ashby' + +describe('ashbyHandler', () => { + describe('verifyAuth', () => { + const secret = 'test-secret-token' + const rawBody = JSON.stringify({ action: 'ping', data: { webhookActionType: 'ping' } }) + const signature = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}` + + it('returns 401 when secretToken is missing', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'ashby-signature': signature, + }) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns 401 when signature header is missing', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), {}) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { secretToken: secret }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns 401 when signature is invalid', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'ashby-signature': 'sha256=deadbeef', + }) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { secretToken: secret }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('returns null when signature is valid', () => { + const request = createMockRequest('POST', JSON.parse(rawBody), { + 'ashby-signature': signature, + }) + const res = ashbyHandler.verifyAuth!({ + request: request as any, + rawBody, + requestId: 'r1', + providerConfig: { secretToken: secret }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + }) + + describe('matchEvent', () => { + it('rejects ping events', async () => { + const matched = await ashbyHandler.matchEvent!({ + webhook: { id: 'w1' } as any, + body: { action: 'ping', data: { webhookActionType: 'ping' } }, + requestId: 'r1', + providerConfig: { triggerId: 'ashby_application_submit' }, + } as any) + expect(matched).toBe(false) + }) + + it('matches when action equals the configured trigger event', async () => { + const matched = await ashbyHandler.matchEvent!({ + webhook: { id: 'w1' } as any, + body: { action: 'applicationSubmit', data: {} }, + requestId: 'r1', + providerConfig: { triggerId: 'ashby_application_submit' }, + } as any) + expect(matched).toBe(true) + }) + + it('rejects when action does not match the configured trigger event', async () => { + const matched = await ashbyHandler.matchEvent!({ + webhook: { id: 'w1' } as any, + body: { action: 'jobCreate', data: {} }, + requestId: 'r1', + providerConfig: { triggerId: 'ashby_application_submit' }, + } as any) + expect(matched).toBe(false) + }) + }) + + describe('formatInput', () => { + it('spreads data fields to the top level alongside action', async () => { + const result = await ashbyHandler.formatInput!({ + body: { + action: 'applicationSubmit', + data: { application: { id: 'app-1', status: 'Active' } }, + }, + } as any) + expect(result.input).toEqual({ + action: 'applicationSubmit', + application: { id: 'app-1', status: 'Active' }, + }) + }) + + it('renames currentInterviewStage.type to stageType, matching the trigger output schema', async () => { + const result = await ashbyHandler.formatInput!({ + body: { + action: 'candidateStageChange', + data: { + application: { + id: 'app-1', + currentInterviewStage: { id: 'stage-1', title: 'Offer', type: 'Offer' }, + }, + }, + }, + } as any) + expect(result.input.application).toEqual({ + id: 'app-1', + currentInterviewStage: { id: 'stage-1', title: 'Offer', stageType: 'Offer' }, + }) + }) + }) + + describe('extractIdempotencyId', () => { + it('derives a stable key from application id + updatedAt', () => { + const body = { + action: 'candidateStageChange', + data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } }, + } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe( + 'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z' + ) + expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe( + ashbyHandler.extractIdempotencyId!(body) + ) + }) + + it('derives a key from candidate id for candidateDelete', () => { + const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1') + }) + + it('derives a key from job id for jobCreate', () => { + const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } } + expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1') + }) + + it('derives a stable key from offer id alone, ignoring mutable decidedAt', () => { + const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } } + expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1') + + const retriedAfterDecision = { + action: 'offerCreate', + data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } }, + } + expect(ashbyHandler.extractIdempotencyId!(retriedAfterDecision)).toBe( + ashbyHandler.extractIdempotencyId!(created) + ) + }) + + it('falls back to a content fingerprint when updatedAt is missing, still deduping retries', () => { + const body = { + action: 'candidateStageChange', + data: { application: { id: 'app-1', status: 'Active' } }, + } + const key = ashbyHandler.extractIdempotencyId!(body) + expect(key).not.toBeNull() + expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key) + + const different = { + action: 'candidateStageChange', + data: { application: { id: 'app-1', status: 'Hired' } }, + } + expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key) + }) + + it('distinguishes candidateHire deliveries that share an application snapshot but differ in offer', () => { + const application = { id: 'app-1', status: 'Hired' } + const first = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-1' } }, + } + const second = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-2' } }, + } + expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe( + ashbyHandler.extractIdempotencyId!(second) + ) + }) + + it('distinguishes candidateHire deliveries sharing application id + updatedAt but differing in offer', () => { + const application = { id: 'app-1', status: 'Hired', updatedAt: '2026-01-01T00:00:00Z' } + const first = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-1' } }, + } + const second = { + action: 'candidateHire', + data: { application, offer: { id: 'offer-2' } }, + } + expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe( + ashbyHandler.extractIdempotencyId!(second) + ) + // a genuine retry of `first` (identical offer too) still dedupes + expect(ashbyHandler.extractIdempotencyId!({ ...first })).toBe( + ashbyHandler.extractIdempotencyId!(first) + ) + }) + + it('returns null when no recognizable resource is present', () => { + expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull() + expect(ashbyHandler.extractIdempotencyId!({})).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 77fabe6e024..781fef57c66 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' +import { omit } from '@sim/utils/object' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -14,6 +15,7 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Ashby') @@ -37,18 +39,54 @@ function validateAshbySignature(secretToken: string, signature: string, body: st export const ashbyHandler: WebhookProviderHandler = { extractIdempotencyId(body: unknown): string | null { const obj = body as Record - const webhookActionId = obj.webhookActionId - if (typeof webhookActionId === 'string' && webhookActionId) { - return `ashby:${webhookActionId}` + const action = typeof obj.action === 'string' ? obj.action : undefined + const data = obj.data as Record | undefined + if (!action || !data) return null + + const application = data.application as Record | undefined + const candidate = data.candidate as Record | undefined + const job = data.job as Record | undefined + const offer = data.offer as Record | undefined + + if (application?.id) { + const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data) + const offerSuffix = offer?.id ? `:${offer.id}` : '' + return `ashby:${action}:${application.id}:${discriminator}${offerSuffix}` + } + if (offer?.id) { + return `ashby:${action}:${offer.id}` + } + if (candidate?.id) { + return `ashby:${action}:${candidate.id}` + } + if (job?.id) { + return `ashby:${action}:${job.id}` } return null }, async formatInput({ body }: FormatInputContext): Promise { const b = body as Record + const data = (b.data as Record) || {} + const application = data.application as Record | undefined + const currentInterviewStage = application?.currentInterviewStage as + | Record + | undefined + return { input: { - ...((b.data as Record) || {}), + ...data, + ...(application && currentInterviewStage + ? { + application: { + ...application, + currentInterviewStage: { + ...omit(currentInterviewStage, ['type']), + stageType: currentInterviewStage.type, + }, + }, + } + : {}), action: b.action, }, } @@ -185,6 +223,9 @@ export const ashbyHandler: WebhookProviderHandler = { } else if (ashbyResponse.status === 403) { userFriendlyMessage = 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' + } else if (/duplicate webhook/i.test(errorMessage)) { + userFriendlyMessage = + 'A webhook for this URL and event already exists in Ashby. This usually happens when a previous save succeeded in Ashby but Sim failed to record it. Delete the duplicate webhook under Ashby Settings > API/Webhooks, then re-save this trigger.' } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { userFriendlyMessage = `Ashby error: ${errorMessage}` } diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts index 3aef2eb2d12..8ead8812df0 100644 --- a/apps/sim/lib/webhooks/providers/salesforce.ts +++ b/apps/sim/lib/webhooks/providers/salesforce.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sha256Hex } from '@sim/security/hash' import { NextResponse } from 'next/server' import type { AuthContext, @@ -8,7 +7,7 @@ import type { FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' +import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils' export function extractSalesforceObjectTypeFromPayload( body: Record @@ -98,25 +97,6 @@ function pickTimestamp(body: Record, record: Record stableSerialize(item)).join(',')}]` - } - - if (value && typeof value === 'object') { - return `{${Object.entries(value as Record) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`) - .join(',')}}` - } - - return JSON.stringify(value) -} - -function buildFallbackDeliveryFingerprint(body: Record): string { - return sha256Hex(stableSerialize(body)) -} - function pickRecordId(body: Record, record: Record): string { const id = (typeof body.recordId === 'string' && body.recordId) || diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts index 43ff2b45fed..1afe2ce3196 100644 --- a/apps/sim/lib/webhooks/providers/utils.ts +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -1,11 +1,41 @@ import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' import { NextResponse } from 'next/server' import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProviderAuth') +/** + * Deterministic JSON serialization with object keys sorted, so structurally + * identical payloads produce identical output regardless of key order. + */ +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +/** + * Fallback idempotency fingerprint for payloads with no stable delivery id + * or content timestamp to key on. A provider retry resends identical bytes, + * so this hash is stable across retries of the same delivery while still + * differentiating distinct events. + */ +export function buildFallbackDeliveryFingerprint(body: unknown): string { + return sha256Hex(stableSerialize(body)) +} + interface HmacVerifierOptions { configKey: string headerName: string diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts index cc26a224cda..54367de590a 100644 --- a/apps/sim/triggers/ashby/utils.ts +++ b/apps/sim/triggers/ashby/utils.ts @@ -144,6 +144,10 @@ export function buildApplicationSubmitOutputs(): Record { currentInterviewStage: { id: { type: 'string', description: 'Current interview stage UUID' }, title: { type: 'string', description: 'Current interview stage title' }, + stageType: { + type: 'string', + description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)', + }, }, job: { id: { type: 'string', description: 'Job UUID' }, @@ -181,6 +185,10 @@ export function buildCandidateStageChangeOutputs(): Record { currentInterviewStage: { id: { type: 'string', description: 'Current interview stage UUID' }, title: { type: 'string', description: 'Current interview stage title' }, + stageType: { + type: 'string', + description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)', + }, }, job: { id: { type: 'string', description: 'Job UUID' }, @@ -262,7 +274,7 @@ export function buildJobCreateOutputs(): Record { status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' }, employmentType: { type: 'string', - description: 'Employment type (Full-time, Part-time, etc.)', + description: 'Employment type (FullTime, PartTime, Intern, Contract)', }, }, } as Record @@ -281,13 +293,12 @@ export function buildOfferCreateOutputs(): Record { applicationId: { type: 'string', description: 'Associated application UUID' }, acceptanceStatus: { type: 'string', - description: - 'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled, WaitingOnResponse)', + description: 'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled)', }, offerStatus: { type: 'string', description: - 'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnCandidateResponse, CandidateAccepted, CandidateRejected, OfferCancelled)', + 'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnApprovalDefinition, WaitingOnCandidateResponse, CandidateRejected, CandidateAccepted, OfferCancelled)', }, decidedAt: { type: 'string',