import assert from "node:assert/strict"; import { createHmac, timingSafeEqual } from "node:crypto"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; const source = "https://elicitra.eigen.rest"; const lifecycleDocsBase = `${source}/developers/v5/schemas/webhooks`; const automationDocsBase = `${source}/developers/v6/schemas/webhooks`; const eventTypes = [ "com.elicitra.interaction.ended.v1", "com.elicitra.analysis.completed.v1", "com.elicitra.analysis.failed.v1", "com.elicitra.automation.triggered.v1", ] as const; type EventType = (typeof eventTypes)[number]; type JsonRecord = Record; const schemaBaseFor = (type: EventType) => type === "com.elicitra.automation.triggered.v1" ? automationDocsBase : lifecycleDocsBase; export type PersistEventOnce = (event: { id: string; rawBody: Buffer; }) => Promise; /** * Compile lifecycle data schemas from `/developers/v5/` and the automation * schema from `/developers/v6/`, then dispatch by `type` and `dataschema`. */ export type ValidateEventData = (input: { type: EventType; dataschema: string; data: unknown; }) => boolean; export type WebhookAcceptance = | { status: 202; accepted: true; duplicate: false; eventId: string } | { status: 200; accepted: true; duplicate: true; eventId: string } | { status: 200; accepted: true; duplicate: false; challenge: string; } | { status: 400 | 401; accepted: false; duplicate: false; error: "invalid_cloudevent" | "invalid_signature"; }; export function verifyElicitraSignature( rawBody: Buffer, header: string, secrets: readonly string[], nowSeconds = Date.now() / 1000, ): boolean { const parts = header.split(",").map((part) => part.trim()); const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2); const signatures = parts .filter((part) => part.startsWith("v1=")) .map((part) => part.slice(3)); if ( !timestamp || timestamp.length > 16 || !/^\d+$/.test(timestamp) || signatures.length === 0 ) { return false; } const timestampNumber = Number(timestamp); if ( !Number.isSafeInteger(timestampNumber) || Math.abs(nowSeconds - timestampNumber) > 300 ) { return false; } return secrets.some((secret) => { const expected = createHmac("sha256", secret) .update(timestamp) .update(".") .update(rawBody) .digest(); return signatures.some((candidate) => { if (!/^[a-f0-9]{64}$/i.test(candidate)) return false; const actual = Buffer.from(candidate, "hex"); return ( actual.length === expected.length && timingSafeEqual(actual, expected) ); }); }); } const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value); const hasExactKeys = (value: JsonRecord, expected: readonly string[]) => { const actual = Object.keys(value).sort(); return ( actual.length === expected.length && expected.every((key, index) => actual[index] === key) ); }; const parseCloudEvent = ( value: unknown, validateEventData: ValidateEventData, ): { id: string } | null => { if (!isRecord(value)) return null; const required = [ "data", "datacontenttype", "dataschema", "id", "source", "specversion", "subject", "time", "type", ].sort(); if (!hasExactKeys(value, required)) return null; const type = eventTypes.find((candidate) => candidate === value.type); const expectedSequence = type === "com.elicitra.interaction.ended.v1" ? 1 : type === "com.elicitra.analysis.completed.v1" || type === "com.elicitra.analysis.failed.v1" ? 2 : null; if ( value.specversion !== "1.0" || typeof value.id !== "string" || value.id.length < 1 || value.id.length > 256 || value.source !== source || !type || typeof value.subject !== "string" || !/^interactions\/[A-Za-z0-9_-]{1,256}$/.test(value.subject) || typeof value.time !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( value.time, ) || !Number.isFinite(Date.parse(value.time)) || value.datacontenttype !== "application/json" || typeof value.dataschema !== "string" || value.dataschema !== `${schemaBaseFor(type)}/${type}.schema.json` || !isRecord(value.data) || (expectedSequence === null ? Object.hasOwn(value.data, "lifecycleSequence") : value.data.lifecycleSequence !== expectedSequence) || !validateEventData({ type, dataschema: value.dataschema, data: value.data }) ) { return null; } return { id: value.id }; }; /** * The persistence adapter must use one atomic insert protected by a unique * constraint on CloudEvent id. Do not implement it as SELECT followed by INSERT. * Store the accepted raw body or an inbox work item before returning from it. */ export async function acceptElicitraWebhook({ rawBody, signatureHeader, secrets, validateEventData, persistEventOnce, exposeChallenge, nowSeconds, }: { rawBody: Buffer; signatureHeader: string; secrets: readonly string[]; validateEventData: ValidateEventData; persistEventOnce: PersistEventOnce; exposeChallenge?: (challenge: string) => void | Promise; nowSeconds?: number; }): Promise { // Authenticate the exact bytes before parsing or changing the body. if (!verifyElicitraSignature(rawBody, signatureHeader, secrets, nowSeconds)) { return { status: 401, accepted: false, duplicate: false, error: "invalid_signature", }; } let value: unknown; try { value = JSON.parse(rawBody.toString("utf8")); } catch { return { status: 400, accepted: false, duplicate: false, error: "invalid_cloudevent", }; } // Endpoint verification is a signed control message, not a CloudEvent. if ( isRecord(value) && hasExactKeys(value, ["challenge"]) && typeof value.challenge === "string" && value.challenge.length >= 16 && value.challenge.length <= 256 ) { await exposeChallenge?.(value.challenge); return { status: 200, accepted: true, duplicate: false, challenge: value.challenge, }; } const event = parseCloudEvent(value, validateEventData); if (!event) { return { status: 400, accepted: false, duplicate: false, error: "invalid_cloudevent", }; } const inserted = await persistEventOnce({ id: event.id, rawBody }); return inserted ? { status: 202, accepted: true, duplicate: false, eventId: event.id } : { status: 200, accepted: true, duplicate: true, eventId: event.id }; } const sign = (rawBody: Buffer, timestamp: string, secret: string) => createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest("hex"); async function runDemo() { const secret = "whsec_test_only"; const rotatedSecret = "whsec_rotated_test_only"; const timestamp = "1785499200"; const type = eventTypes[0]; const data = { lifecycleSequence: 1, organization: { id: "org_01" }, interaction: { id: "call_01", channel: "phone", outcome: "completed", reasonCode: "respondent_ended", endedAt: "2026-07-31T12:00:00.000Z", }, campaign: null, scenario: { id: "scenario_01", slug: "customer_research", name: "Customer research", revision: 1, }, respondent: { name: null, email: null, phone: null }, consents: [], truncation: { truncated: false, omittedFields: [] }, }; const event = { specversion: "1.0", id: "evt_test", source, type, subject: "interactions/call_01", time: "2026-07-31T12:00:00.000Z", datacontenttype: "application/json", dataschema: `${schemaBaseFor(type)}/${type}.schema.json`, data, }; const body = Buffer.from(JSON.stringify(event)); const signature = sign(body, timestamp, secret); const inbox = new Map(); const persistEventOnce: PersistEventOnce = async ({ id, rawBody }) => { // Test double only. Production storage must enforce uniqueness atomically. if (inbox.has(id)) return false; inbox.set(id, Buffer.from(rawBody)); return true; }; // Test double only. Production code must compile and run the pinned JSON // Schema 2020-12 data schema selected by type and dataschema. const validateEventData: ValidateEventData = (candidate) => candidate.type === type && candidate.dataschema === event.dataschema && JSON.stringify(candidate.data) === JSON.stringify(data); const accepted = await acceptElicitraWebhook({ rawBody: body, signatureHeader: `t=${timestamp},v1=${signature}`, secrets: [secret], validateEventData, persistEventOnce, nowSeconds: Number(timestamp), }); assert.deepEqual(accepted, { status: 202, accepted: true, duplicate: false, eventId: "evt_test", }); const duplicate = await acceptElicitraWebhook({ rawBody: body, signatureHeader: `t=${timestamp},v1=${signature}`, secrets: [secret], validateEventData, persistEventOnce, nowSeconds: Number(timestamp), }); assert.deepEqual(duplicate, { status: 200, accepted: true, duplicate: true, eventId: "evt_test", }); assert.equal(inbox.size, 1); const challengeBody = Buffer.from( JSON.stringify({ challenge: "0123456789abcdef0123456789abcdef" }), ); let observedChallenge = ""; const challenge = await acceptElicitraWebhook({ rawBody: challengeBody, signatureHeader: `t=${timestamp},v1=${sign(challengeBody, timestamp, secret)}`, secrets: [secret], validateEventData, persistEventOnce, exposeChallenge: (value) => { observedChallenge = value; }, nowSeconds: Number(timestamp), }); assert.equal(challenge.status, 200); assert.equal(observedChallenge, "0123456789abcdef0123456789abcdef"); assert.equal(inbox.size, 1); const tampered = await acceptElicitraWebhook({ rawBody: Buffer.from(`${body.toString()} `), signatureHeader: `t=${timestamp},v1=${signature}`, secrets: [secret], validateEventData, persistEventOnce, nowSeconds: Number(timestamp), }); assert.equal(tampered.status, 401); assert.equal(inbox.size, 1); assert.equal( verifyElicitraSignature( body, `t=${timestamp},v1=${signature}`, [secret], Number(timestamp) + 301, ), false, ); assert.equal( verifyElicitraSignature( body, `t=${"1".repeat(10_000)},v1=${signature}`, [secret], Number(timestamp), ), false, ); const rotatedEvent = { ...event, id: "evt_rotated" }; const rotatedBody = Buffer.from(JSON.stringify(rotatedEvent)); const oldSignature = sign(rotatedBody, timestamp, secret); const newSignature = sign(rotatedBody, timestamp, rotatedSecret); const rotated = await acceptElicitraWebhook({ rawBody: rotatedBody, signatureHeader: `t=${timestamp},v1=${oldSignature},v1=${newSignature}`, secrets: [rotatedSecret], validateEventData, persistEventOnce, nowSeconds: Number(timestamp), }); assert.equal(rotated.status, 202); assert.equal(inbox.size, 2); const malformedBody = Buffer.from("{not-json"); const malformed = await acceptElicitraWebhook({ rawBody: malformedBody, signatureHeader: `t=${timestamp},v1=${sign(malformedBody, timestamp, secret)}`, secrets: [secret], validateEventData, persistEventOnce, nowSeconds: Number(timestamp), }); assert.equal(malformed.status, 400); assert.equal(inbox.size, 2); } const invokedPath = process.argv[1]; if ( invokedPath && import.meta.url === pathToFileURL(resolve(invokedPath)).href ) { await runDemo(); }