diff --git a/package-lock.json b/package-lock.json index da26434..da23932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52404,7 +52404,7 @@ "version": "1.1.7", "license": "MIT", "dependencies": { - "@friggframework/core": "^2.0.0-next.16" + "@friggframework/core": "^2.0.0-next.88" }, "devDependencies": { "@friggframework/devtools": "^1.1.2", @@ -52417,9 +52417,9 @@ } }, "packages/v1-ready/hubspot/node_modules/@friggframework/core": { - "version": "2.0.0-next.75", - "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.75.tgz", - "integrity": "sha512-y053DvLZvCJAEzw5t3FchiMtMAVZepSZp8vMaGrlUlFeRsRp8MAP6PqTwzzSWWpPpSauT1uV9qMMAahRnhfYhA==", + "version": "2.0.0-next.88", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.88.tgz", + "integrity": "sha512-DmvG035UJQloD3vEwJJPrH4RIPTXQA603yFY1jzSWKxKCnkQl1E1E6mUcGTBcC9r8QjPTLtj9ZDJlgOWESn8aA==", "license": "MIT", "dependencies": { "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", diff --git a/packages/v1-ready/hubspot/README.md b/packages/v1-ready/hubspot/README.md index 155d2f0..47a334d 100644 --- a/packages/v1-ready/hubspot/README.md +++ b/packages/v1-ready/hubspot/README.md @@ -2,4 +2,93 @@ This is the API Module for hubspot that allows the [Frigg](https://friggframework.org) code to talk to the hubspot API. -Read more on the [Frigg documentation site](https://docs.friggframework.org/api-modules/list/hubspot \ No newline at end of file +Read more on the [Frigg documentation site](https://docs.friggframework.org/api-modules/list/hubspot). + +## Webhooks (Integration Extension) + +HubSpot webhooks are **app-level**, not account-level: one HubSpot app has a single +webhook target URL, and every connected portal (account) fires events to that same +URL. A Frigg app, by contrast, runs many per-portal integration records behind one +deployment. Bridging the two means every integration that wants HubSpot webhooks has +to write the same plumbing — an HTTP receiver, signature verification, a portal-ID +lookup to find the right integration record, and a hand-off to a queue worker. + +This module ships that plumbing once as a **Tier 3 Integration Extension** — +`hubspot.extensions.webhooks` — so an integration just plugs it in and writes the +business logic. The extension is a reusable bundle of `{ routes, events }` that the +Frigg framework merges into the consuming integration's definition. See the framework +[EXTENSIONS.md](https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md) +for the full Tier 3 contract. + +### How the app-level / account-level split is handled + +| Concern | Where it lives | Identity used | +|---|---|---| +| Signature verification | Extension receiver (`HUBSPOT_WEBHOOK_RECEIVED`) | App client secret (`HUBSPOT_CLIENT_SECRET`) | +| Portal → integration routing | `findIntegrationByEntityExternalId` via friggCommands | Inbound `portalId` from the payload | +| Per-account business logic | Your bound `HUBSPOT_WEBHOOK` handler | Per-portal OAuth credentials (loaded by the worker) | + +At request time HubSpot POSTs the whole app's events to the receiver. The receiver +verifies the v3 signature, then resolves each event's `portalId` to the owning Frigg +integration and enqueues a per-event `HUBSPOT_WEBHOOK` job. The queue worker hydrates +that integration with its own per-portal credentials and runs your handler. + +### Enabling it on an integration + +Bind the extension on your integration's `static Definition.extensions` and map the +`HUBSPOT_WEBHOOK` event to a method on your class: + +```javascript +const { IntegrationBase, createFriggCommands } = require('@friggframework/core'); +const hubspot = require('@friggframework/api-module-hubspot'); + +class HubSpotIntegration extends IntegrationBase { + static Definition = { + name: 'hubspot', + modules: { hubspot: { definition: hubspot.Definition } }, + extensions: { + hubspotWebhooks: { + extension: hubspot.extensions.webhooks, + handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' }, + }, + }, + }; + + constructor(params) { + super(params); + // Required: the receiver resolves portalId → integration through commands. + this.commands = createFriggCommands({ integrationClass: HubSpotIntegration }); + } + + async onHubSpotEvent({ data }) { + // Pure business logic. Signature verification, portalId lookup, and + // queue dispatch are already done — `data.body` is the HubSpot event. + const { subscriptionType, objectId } = data.body; + if (subscriptionType === 'contact.creation') { + await this.syncContact(objectId); + } + } +} +``` + +The framework auto-mounts the receiver route at the integration's base path +(`POST //webhooks`) — register that URL as the app's webhook target +in your HubSpot app settings. + +### Configuration + +| Env var | Purpose | +|---|---| +| `HUBSPOT_CLIENT_SECRET` | App client secret used to verify the `X-HubSpot-Signature-V3` header. The receiver rejects with `401` if it is unset or the signature does not match. | + +### What the bundle contributes + +- **Route:** `POST /webhooks` → `HUBSPOT_WEBHOOK_RECEIVED` +- **`HUBSPOT_WEBHOOK_RECEIVED`** — default receiver: verifies the signature, resolves + each event's `portalId`, and queues matched events. Events whose portal does not map + to any integration are skipped (HubSpot broadcasts every portal's events to the app). + Events are resolved and enqueued in parallel; if a `portalId` resolves ambiguously + (one external ID owned by multiple integrations) the whole batch is rejected rather + than risk cross-tenant routing. +- **`HUBSPOT_WEBHOOK`** — default no-op; override it via `binding.handlers.HUBSPOT_WEBHOOK` + to run your per-event logic. diff --git a/packages/v1-ready/hubspot/extensions/webhooks/handlers.js b/packages/v1-ready/hubspot/extensions/webhooks/handlers.js new file mode 100644 index 0000000..eb711a9 --- /dev/null +++ b/packages/v1-ready/hubspot/extensions/webhooks/handlers.js @@ -0,0 +1,105 @@ +const { verifyHubSpotSignature } = require('./signature-verifier'); +const { findIntegrationByPortalId } = require('./lookup'); + +/** + * Receiver handler for `POST /webhooks`. + * + * Verifies HubSpot's v3 signature using `process.env.HUBSPOT_CLIENT_SECRET` + * (the same env var the api-module already reads for OAuth), iterates the + * inbound batch, resolves each event's `portalId` to a Frigg integration via + * the platform-neutral reverse lookup, and enqueues a per-event + * `HUBSPOT_WEBHOOK` job for the matched integration. Events whose portal + * does not map to any integration are silently skipped (HubSpot sends events + * for the whole app, not per-account). + * + * Per the Tier 3 contract, this function is bound as a plain function on the + * integration instance — `this` is the IntegrationBase instance and exposes + * `commands.findIntegrationByEntityExternalId` and `queueWebhook`. + * + * @this {import('@friggframework/core').IntegrationBase} + * @param {Object} args + * @param {import('express').Request} args.req + * @param {import('express').Response} args.res + * @returns {Promise} + */ +async function onHubSpotWebhookReceived({ req, res }) { + const verification = verifyHubSpotSignature({ + req, + clientSecret: process.env.HUBSPOT_CLIENT_SECRET, + }); + if (!verification.valid) { + console.warn( + `[hubspot-webhooks] rejecting webhook: ${verification.reason}` + ); + res.status(401).json({ error: 'invalid signature' }); + return; + } + + const events = Array.isArray(req.body) ? req.body : []; + + // Phase 1 — resolve every portalId in parallel before queueing anything. + // Two reasons for two-phase: (a) HubSpot batches can be large and each + // lookup is an independent DB round-trip; running them serially blows + // the HubSpot response budget; (b) if any lookup is ambiguous, the core + // command throws by design — we want that throw to land BEFORE any + // queueWebhook fires so we never leave the batch in a partial-enqueue + // state that HubSpot's retry would then duplicate. + const resolutions = await Promise.all( + events.map(async (evt, i) => { + const portalId = evt && evt.portalId; + if (portalId === undefined || portalId === null) { + console.warn( + `[hubspot-webhooks] event[${i}] missing portalId ` + + `(subscriptionType=${evt && evt.subscriptionType}); skipping` + ); + return null; + } + const integrationId = await findIntegrationByPortalId( + this, + portalId + ); + if (!integrationId) return null; + return { integrationId, evt }; + }) + ); + + // Phase 2 — enqueue matched events in parallel. Every match resolved + // cleanly above, so any failure here is genuinely SQS-side and should + // surface to HubSpot for retry. + const matches = resolutions.filter(Boolean); + await Promise.all( + matches.map(({ integrationId, evt }) => + this.queueWebhook({ + integrationId, + body: evt, + event: 'HUBSPOT_WEBHOOK', + }) + ) + ); + + res.status(200).json({ + received: events.length, + queued: matches.length, + skipped: events.length - matches.length, + }); +} + +/** + * Default per-event handler. Integration consumers override this via + * `binding.handlers.HUBSPOT_WEBHOOK = 'methodName'`; the default is a no-op + * so a misconfigured binding fails predictably rather than crashing the + * queue worker. + * + * @this {import('@friggframework/core').IntegrationBase} + * @param {Object} args + * @param {Object} args.data + * @returns {Promise} + */ +async function onHubSpotWebhook({ data }) { + // intentional no-op — override via binding.handlers.HUBSPOT_WEBHOOK +} + +module.exports = { + onHubSpotWebhookReceived, + onHubSpotWebhook, +}; diff --git a/packages/v1-ready/hubspot/extensions/webhooks/index.js b/packages/v1-ready/hubspot/extensions/webhooks/index.js new file mode 100644 index 0000000..ed1cb07 --- /dev/null +++ b/packages/v1-ready/hubspot/extensions/webhooks/index.js @@ -0,0 +1,39 @@ +const { + onHubSpotWebhookReceived, + onHubSpotWebhook, +} = require('./handlers'); + +/** + * HubSpot Webhooks — Tier 3 Integration Extension bundle. + * + * Contributes a single receiver route (`POST /webhooks`) plus two events: + * + * HUBSPOT_WEBHOOK_RECEIVED — bound to the route; verifies signature, + * resolves portalId → integrationId, queues + * one HUBSPOT_WEBHOOK per matched event. + * HUBSPOT_WEBHOOK — default no-op; integrations override via + * `binding.handlers.HUBSPOT_WEBHOOK`. + * + * See the binding contract at: + * https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md + */ +module.exports = { + name: 'hubspot-webhooks', + routes: [ + { + path: '/webhooks', + method: 'POST', + event: 'HUBSPOT_WEBHOOK_RECEIVED', + }, + ], + events: { + HUBSPOT_WEBHOOK_RECEIVED: { + type: 'LIFE_CYCLE_EVENT', + handler: onHubSpotWebhookReceived, + }, + HUBSPOT_WEBHOOK: { + type: 'LIFE_CYCLE_EVENT', + handler: onHubSpotWebhook, + }, + }, +}; diff --git a/packages/v1-ready/hubspot/extensions/webhooks/lookup.js b/packages/v1-ready/hubspot/extensions/webhooks/lookup.js new file mode 100644 index 0000000..aa44f63 --- /dev/null +++ b/packages/v1-ready/hubspot/extensions/webhooks/lookup.js @@ -0,0 +1,48 @@ +const { Definition } = require('../../definition'); + +/** + * HubSpot-vocabulary wrapper around the platform-neutral friggCommand + * `findIntegrationByEntityExternalId`. The command is the canonical access + * point for cross-cutting reverse lookups; this wrapper exists so the + * api-module's webhook code reads in HubSpot terms ("portalId") rather than + * the generic "externalId / moduleName" pair. + * + * Lives inside the api-module per the Tier 3 contract: "portalId" is HubSpot + * vocabulary and does not belong in core. The wrapper takes the integration + * instance explicitly so it can be called from extension default handlers + * (where `this` is the integration) or from worker code that holds a handle + * to an instance. + * + * The module name is sourced from `definition.js` so a rename of the api + * module flows through automatically. + * + * Intentionally does not catch ambiguous-resolution errors — those signal a + * cross-tenant routing risk that callers should not paper over. + * + * @param {Object} integration - An IntegrationBase instance with `commands` + * wired (see `createFriggCommands` in @friggframework/core). + * @param {string|number} portalId - The HubSpot portal/hub ID from the + * webhook event. + * @returns {Promise} The Frigg integration id, or null if no + * matching integration exists for that portal. + */ +async function findIntegrationByPortalId(integration, portalId) { + if ( + !integration || + !integration.commands || + typeof integration.commands.findIntegrationByEntityExternalId !== + 'function' + ) { + throw new Error( + 'findIntegrationByPortalId: integration instance must expose commands.findIntegrationByEntityExternalId() — wire up createFriggCommands in your integration constructor.' + ); + } + return integration.commands.findIntegrationByEntityExternalId( + portalId, + Definition.moduleName + ); +} + +module.exports = { + findIntegrationByPortalId, +}; diff --git a/packages/v1-ready/hubspot/extensions/webhooks/signature-verifier.js b/packages/v1-ready/hubspot/extensions/webhooks/signature-verifier.js new file mode 100644 index 0000000..a18a990 --- /dev/null +++ b/packages/v1-ready/hubspot/extensions/webhooks/signature-verifier.js @@ -0,0 +1,181 @@ +const crypto = require('crypto'); + +const SIGNATURE_HEADER = 'x-hubspot-signature-v3'; +const TIMESTAMP_HEADER = 'x-hubspot-request-timestamp'; +// HubSpot's documented skew window: reject if older than 5 minutes. +const MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000; + +/** + * Read a header value case-insensitively from a request-like object. + * Express normalises header keys to lowercase, but extension authors may + * hand us raw objects in tests; tolerate both. + * + * @param {Object} headers - The headers map from the incoming request. + * @param {string} name - The header name (any case). + * @returns {string|undefined} The header value, or undefined. + */ +function getHeader(headers, name) { + if (!headers || typeof headers !== 'object') return undefined; + if (headers[name] !== undefined) return headers[name]; + const lower = name.toLowerCase(); + if (headers[lower] !== undefined) return headers[lower]; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) return headers[key]; + } + return undefined; +} + +/** + * Reconstruct the request body string as it left HubSpot's servers. + * If middleware preserved the raw body bytes (req.rawBody), use those — + * any whitespace or key ordering tweak from JSON.parse → JSON.stringify + * will break the HMAC. Otherwise fall back to JSON.stringify(parsed body), + * which matches HubSpot's own Node.js sample. + * + * @param {import('express').Request|Object} req - The Express request. + * @returns {string} The body string to feed into the HMAC. + */ +function extractBodyString(req) { + if (typeof req.rawBody === 'string') return req.rawBody; + if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString('utf8'); + if (req.body === undefined || req.body === null) return ''; + if (typeof req.body === 'string') return req.body; + if (Buffer.isBuffer(req.body)) return req.body.toString('utf8'); + return JSON.stringify(req.body); +} + +/** + * Reconstruct the full request URL HubSpot signed. + * + * HubSpot signs `https://` — protocol, host, path, and + * query string. Behind a load balancer Express sees `http`, so we honour + * `X-Forwarded-Proto` and `X-Forwarded-Host` when present. Callers can + * also set `HUBSPOT_WEBHOOK_BASE_URL` to override entirely (useful for + * tunnels or custom domains). + * + * @param {import('express').Request|Object} req - The Express request. + * @returns {string} The fully-qualified URL string to feed into the HMAC. + */ +function reconstructUrl(req) { + const override = process.env.HUBSPOT_WEBHOOK_BASE_URL; + if (override) { + const trimmed = override.replace(/\/$/, ''); + return `${trimmed}${req.originalUrl || req.url || ''}`; + } + const forwardedProto = getHeader(req.headers, 'x-forwarded-proto'); + const proto = + (forwardedProto && forwardedProto.split(',')[0].trim()) || + req.protocol || + 'https'; + const forwardedHost = getHeader(req.headers, 'x-forwarded-host'); + const host = + (forwardedHost && forwardedHost.split(',')[0].trim()) || + getHeader(req.headers, 'host') || + ''; + const path = req.originalUrl || req.url || ''; + return `${proto}://${host}${path}`; +} + +/** + * Validate that two base64 signatures are equal in constant time. + * + * Decodes both into Buffers so timingSafeEqual sees same-length inputs. + * Returns false on any decode/length mismatch. + * + * @param {string} expected - The signature we computed. + * @param {string} provided - The signature HubSpot sent. + * @returns {boolean} + */ +function safeCompare(expected, provided) { + if (typeof expected !== 'string' || typeof provided !== 'string') { + return false; + } + try { + const a = Buffer.from(expected, 'base64'); + const b = Buffer.from(provided, 'base64'); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } catch (_err) { + return false; + } +} + +/** + * Verify a HubSpot v3 webhook signature against the configured client secret. + * + * Algorithm (per HubSpot docs): + * message = method + uri + body + timestamp + * signature = base64(HMAC-SHA256(client_secret, utf8(message))) + * + * Returns a structured result instead of throwing so the receiver can map + * different failures to the right log line and HTTP status. Never throws + * for normal "untrusted request" cases. + * + * @param {Object} args + * @param {import('express').Request|Object} args.req - The Express request. + * @param {string} args.clientSecret - The HubSpot app's client secret. + * @param {number} [args.maxSkewMs=300000] - Allowed timestamp skew in milliseconds. + * @param {Function} [args.now=Date.now] - Clock source for tests. + * @returns {{ valid: boolean, reason?: string }} + */ +function verifyHubSpotSignature({ + req, + clientSecret, + maxSkewMs = MAX_TIMESTAMP_SKEW_MS, + now = Date.now, +}) { + if (!clientSecret || typeof clientSecret !== 'string') { + return { valid: false, reason: 'missing client secret' }; + } + if (!req || typeof req !== 'object') { + return { valid: false, reason: 'missing request' }; + } + + const headers = req.headers || {}; + const signature = getHeader(headers, SIGNATURE_HEADER); + const timestamp = getHeader(headers, TIMESTAMP_HEADER); + + if (!signature) { + return { valid: false, reason: `missing ${SIGNATURE_HEADER} header` }; + } + if (!timestamp) { + return { valid: false, reason: `missing ${TIMESTAMP_HEADER} header` }; + } + + const tsNumber = Number(timestamp); + if (!Number.isFinite(tsNumber)) { + return { valid: false, reason: 'malformed timestamp header' }; + } + const skew = Math.abs(now() - tsNumber); + if (skew > maxSkewMs) { + return { + valid: false, + reason: `timestamp skew ${skew}ms exceeds max ${maxSkewMs}ms`, + }; + } + + const method = (req.method || 'POST').toUpperCase(); + const uri = reconstructUrl(req); + const body = extractBodyString(req); + const message = `${method}${uri}${body}${timestamp}`; + + const expected = crypto + .createHmac('sha256', clientSecret) + .update(message, 'utf8') + .digest('base64'); + + if (!safeCompare(expected, signature)) { + return { valid: false, reason: 'signature mismatch' }; + } + return { valid: true }; +} + +module.exports = { + verifyHubSpotSignature, + extractBodyString, + reconstructUrl, + safeCompare, + SIGNATURE_HEADER, + TIMESTAMP_HEADER, + MAX_TIMESTAMP_SKEW_MS, +}; diff --git a/packages/v1-ready/hubspot/index.js b/packages/v1-ready/hubspot/index.js index 002e1fc..caf21ec 100644 --- a/packages/v1-ready/hubspot/index.js +++ b/packages/v1-ready/hubspot/index.js @@ -1,9 +1,13 @@ const {Api} = require('./api'); const {Definition} = require('./definition'); const Config = require('./defaultConfig'); +const webhooks = require('./extensions/webhooks'); module.exports = { Api, Config, - Definition + Definition, + extensions: { + webhooks, + }, }; diff --git a/packages/v1-ready/hubspot/package.json b/packages/v1-ready/hubspot/package.json index 1889d24..27c4e8f 100644 --- a/packages/v1-ready/hubspot/package.json +++ b/packages/v1-ready/hubspot/package.json @@ -20,6 +20,6 @@ "prettier": "^2.7.1" }, "dependencies": { - "@friggframework/core": "^2.0.0-next.16" + "@friggframework/core": "^2.0.0-next.88" } } diff --git a/packages/v1-ready/hubspot/tests/extensions/webhooks.test.js b/packages/v1-ready/hubspot/tests/extensions/webhooks.test.js new file mode 100644 index 0000000..55f44a7 --- /dev/null +++ b/packages/v1-ready/hubspot/tests/extensions/webhooks.test.js @@ -0,0 +1,590 @@ +const crypto = require('crypto'); +const webhooksExtension = require('../../extensions/webhooks'); +const { + verifyHubSpotSignature, + reconstructUrl, + extractBodyString, + safeCompare, +} = require('../../extensions/webhooks/signature-verifier'); +const { + findIntegrationByPortalId, +} = require('../../extensions/webhooks/lookup'); +const { + onHubSpotWebhookReceived, + onHubSpotWebhook, +} = require('../../extensions/webhooks/handlers'); + +const CLIENT_SECRET = 'test-client-secret'; + +const buildSignedRequest = ({ + method = 'POST', + url = '/api/hubspot-integration/webhooks', + host = 'app.example.com', + proto = 'https', + body = [{ portalId: 111, subscriptionType: 'contact.creation', objectId: 1 }], + timestamp = Date.now(), + clientSecret = CLIENT_SECRET, + extraHeaders = {}, + overrideSignature, + overrideTimestamp, + rawBody, +} = {}) => { + const bodyString = rawBody !== undefined + ? rawBody + : (typeof body === 'string' ? body : JSON.stringify(body)); + const fullUrl = `${proto}://${host}${url}`; + const message = `${method}${fullUrl}${bodyString}${timestamp}`; + const signature = crypto + .createHmac('sha256', clientSecret) + .update(message, 'utf8') + .digest('base64'); + + return { + method, + url, + originalUrl: url, + protocol: proto, + body: typeof body === 'string' ? body : body, + rawBody: rawBody, + headers: { + host, + 'x-hubspot-signature-v3': overrideSignature ?? signature, + 'x-hubspot-request-timestamp': + overrideTimestamp ?? String(timestamp), + ...extraHeaders, + }, + }; +}; + +const makeRes = () => { + const res = {}; + res.status = jest.fn((code) => { + res.statusCode = code; + return res; + }); + res.json = jest.fn((payload) => { + res.body = payload; + return res; + }); + return res; +}; + +describe('hubspot-webhooks extension bundle shape', () => { + it('conforms to the Tier 3 contract', () => { + expect(webhooksExtension).toEqual( + expect.objectContaining({ + name: 'hubspot-webhooks', + routes: expect.any(Array), + events: expect.any(Object), + }) + ); + expect(webhooksExtension.routes).toHaveLength(1); + expect(webhooksExtension.routes[0]).toEqual({ + path: '/webhooks', + method: 'POST', + event: 'HUBSPOT_WEBHOOK_RECEIVED', + }); + }); + + it('every route event references a declared event', () => { + for (const route of webhooksExtension.routes) { + expect(webhooksExtension.events).toHaveProperty(route.event); + } + }); + + it('declares both HUBSPOT_WEBHOOK_RECEIVED and HUBSPOT_WEBHOOK with function handlers', () => { + expect(typeof webhooksExtension.events.HUBSPOT_WEBHOOK_RECEIVED.handler).toBe( + 'function' + ); + expect(typeof webhooksExtension.events.HUBSPOT_WEBHOOK.handler).toBe( + 'function' + ); + }); +}); + +describe('verifyHubSpotSignature', () => { + it('returns valid:true for a correctly-signed request', () => { + const req = buildSignedRequest(); + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(true); + }); + + it('returns valid:false on signature mismatch', () => { + const req = buildSignedRequest({ overrideSignature: 'AAAA' }); + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/mismatch/); + }); + + it('returns valid:false when the signature header is missing', () => { + const req = buildSignedRequest(); + delete req.headers['x-hubspot-signature-v3']; + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/x-hubspot-signature-v3/); + }); + + it('returns valid:false when the timestamp header is missing', () => { + const req = buildSignedRequest(); + delete req.headers['x-hubspot-request-timestamp']; + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/timestamp/); + }); + + it('rejects timestamps older than the skew window', () => { + const stale = Date.now() - 10 * 60 * 1000; + const req = buildSignedRequest({ timestamp: stale }); + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/timestamp skew/); + }); + + it('rejects timestamps too far in the future', () => { + const future = Date.now() + 10 * 60 * 1000; + const req = buildSignedRequest({ timestamp: future }); + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/timestamp skew/); + }); + + it('rejects non-numeric timestamp headers', () => { + const req = buildSignedRequest({ overrideTimestamp: 'not-a-number' }); + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/malformed/); + }); + + it('returns valid:false when client secret is missing', () => { + const req = buildSignedRequest(); + const result = verifyHubSpotSignature({ req, clientSecret: undefined }); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/client secret/); + }); + + it('honours X-Forwarded-Proto/Host headers when present', () => { + const timestamp = Date.now(); + const body = [{ portalId: 1 }]; + const bodyString = JSON.stringify(body); + const fullUrl = `https://public.example.com/api/hubspot-integration/webhooks`; + const signature = crypto + .createHmac('sha256', CLIENT_SECRET) + .update(`POST${fullUrl}${bodyString}${timestamp}`, 'utf8') + .digest('base64'); + + const req = { + method: 'POST', + url: '/api/hubspot-integration/webhooks', + originalUrl: '/api/hubspot-integration/webhooks', + protocol: 'http', + body, + headers: { + host: 'internal-lb.local', + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'public.example.com', + 'x-hubspot-signature-v3': signature, + 'x-hubspot-request-timestamp': String(timestamp), + }, + }; + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(true); + }); + + it('uses HUBSPOT_WEBHOOK_BASE_URL override when set', () => { + const previous = process.env.HUBSPOT_WEBHOOK_BASE_URL; + process.env.HUBSPOT_WEBHOOK_BASE_URL = 'https://override.example.com'; + + const timestamp = Date.now(); + const body = [{ portalId: 1 }]; + const bodyString = JSON.stringify(body); + const fullUrl = `https://override.example.com/api/hubspot-integration/webhooks`; + const signature = crypto + .createHmac('sha256', CLIENT_SECRET) + .update(`POST${fullUrl}${bodyString}${timestamp}`, 'utf8') + .digest('base64'); + + const req = { + method: 'POST', + originalUrl: '/api/hubspot-integration/webhooks', + protocol: 'http', + body, + headers: { + host: 'something-else.local', + 'x-hubspot-signature-v3': signature, + 'x-hubspot-request-timestamp': String(timestamp), + }, + }; + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(true); + + if (previous === undefined) { + delete process.env.HUBSPOT_WEBHOOK_BASE_URL; + } else { + process.env.HUBSPOT_WEBHOOK_BASE_URL = previous; + } + }); + + it('uses req.rawBody when present so JSON re-serialization cannot break the HMAC', () => { + const timestamp = Date.now(); + const rawBody = '[{"portalId":1,"foo": "bar" }]'; // intentional weird spacing + const fullUrl = `https://app.example.com/api/hubspot-integration/webhooks`; + const signature = crypto + .createHmac('sha256', CLIENT_SECRET) + .update(`POST${fullUrl}${rawBody}${timestamp}`, 'utf8') + .digest('base64'); + + const req = { + method: 'POST', + originalUrl: '/api/hubspot-integration/webhooks', + protocol: 'https', + rawBody, + body: JSON.parse(rawBody), + headers: { + host: 'app.example.com', + 'x-hubspot-signature-v3': signature, + 'x-hubspot-request-timestamp': String(timestamp), + }, + }; + const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET }); + expect(result.valid).toBe(true); + }); +}); + +describe('signature-verifier helpers', () => { + it('safeCompare returns false on length mismatch', () => { + // 4 chars (3 bytes) vs 8 chars (6 bytes) — clearly different decoded lengths + expect(safeCompare('AAAA', 'AAAAAAAA')).toBe(false); + }); + + it('safeCompare returns true on identical inputs', () => { + expect(safeCompare('hello', 'hello')).toBe(true); + }); + + it('safeCompare returns false on non-string inputs', () => { + expect(safeCompare(null, 'abc')).toBe(false); + expect(safeCompare('abc', undefined)).toBe(false); + }); + + it('extractBodyString returns empty string for null body', () => { + expect(extractBodyString({ body: null })).toBe(''); + }); + + it('extractBodyString prefers rawBody string', () => { + expect(extractBodyString({ rawBody: 'raw', body: { a: 1 } })).toBe('raw'); + }); + + it('extractBodyString prefers rawBody buffer', () => { + expect( + extractBodyString({ rawBody: Buffer.from('hello'), body: {} }) + ).toBe('hello'); + }); + + it('extractBodyString JSON-stringifies object bodies', () => { + expect(extractBodyString({ body: { a: 1 } })).toBe('{"a":1}'); + }); + + it('reconstructUrl falls back to req.protocol + host when no forwarded headers', () => { + const req = { + protocol: 'https', + originalUrl: '/abc', + headers: { host: 'foo.local' }, + }; + expect(reconstructUrl(req)).toBe('https://foo.local/abc'); + }); +}); + +describe('findIntegrationByPortalId wrapper', () => { + it('delegates to commands.findIntegrationByEntityExternalId with the module name from definition.js', async () => { + const integration = { + commands: { + findIntegrationByEntityExternalId: jest + .fn() + .mockResolvedValue('integration-abc'), + }, + }; + const result = await findIntegrationByPortalId(integration, 42); + expect( + integration.commands.findIntegrationByEntityExternalId + ).toHaveBeenCalledWith(42, 'hubspot'); + expect(result).toBe('integration-abc'); + }); + + it('throws when the integration does not expose commands.findIntegrationByEntityExternalId', async () => { + await expect(findIntegrationByPortalId({}, 42)).rejects.toThrow( + /commands\.findIntegrationByEntityExternalId/ + ); + await expect( + findIntegrationByPortalId({ commands: {} }, 42) + ).rejects.toThrow(/commands\.findIntegrationByEntityExternalId/); + }); + + it('propagates ambiguous-resolution errors instead of swallowing them', async () => { + const ambiguous = new Error( + 'ambiguous resolution — externalId=42 matched 2 entities' + ); + const integration = { + commands: { + findIntegrationByEntityExternalId: jest + .fn() + .mockRejectedValue(ambiguous), + }, + }; + await expect( + findIntegrationByPortalId(integration, 42) + ).rejects.toThrow(/ambiguous/); + }); +}); + +describe('onHubSpotWebhookReceived (default receiver handler)', () => { + let previousClientSecret; + + beforeAll(() => { + previousClientSecret = process.env.HUBSPOT_CLIENT_SECRET; + process.env.HUBSPOT_CLIENT_SECRET = CLIENT_SECRET; + }); + + afterAll(() => { + if (previousClientSecret === undefined) { + delete process.env.HUBSPOT_CLIENT_SECRET; + } else { + process.env.HUBSPOT_CLIENT_SECRET = previousClientSecret; + } + }); + + const makeIntegration = (overrides = {}) => { + const commandsOverride = overrides.commands; + delete overrides.commands; + const integration = { + commands: { + findIntegrationByEntityExternalId: jest.fn(async (portalId) => { + if (portalId === 999) return null; // simulate unknown portal + return `integration-for-portal-${portalId}`; + }), + ...commandsOverride, + }, + queueWebhook: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; + return integration; + }; + + it('returns 401 on invalid signature', async () => { + const integration = makeIntegration(); + const req = buildSignedRequest({ overrideSignature: 'AAAA' }); + const res = makeRes(); + await onHubSpotWebhookReceived.call(integration, { req, res }); + expect(res.statusCode).toBe(401); + expect(integration.queueWebhook).not.toHaveBeenCalled(); + }); + + it('returns 401 on missing signature header', async () => { + const integration = makeIntegration(); + const req = buildSignedRequest(); + delete req.headers['x-hubspot-signature-v3']; + const res = makeRes(); + await onHubSpotWebhookReceived.call(integration, { req, res }); + expect(res.statusCode).toBe(401); + }); + + it('iterates events, queues each matched event, and returns 200 with counts', async () => { + const integration = makeIntegration(); + const body = [ + { portalId: 111, subscriptionType: 'contact.creation', objectId: 1 }, + { portalId: 222, subscriptionType: 'deal.creation', objectId: 2 }, + ]; + const req = buildSignedRequest({ body }); + const res = makeRes(); + + await onHubSpotWebhookReceived.call(integration, { req, res }); + + expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledTimes( + 2 + ); + expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledWith( + 111, + 'hubspot' + ); + expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledWith( + 222, + 'hubspot' + ); + expect(integration.queueWebhook).toHaveBeenCalledTimes(2); + expect(integration.queueWebhook).toHaveBeenCalledWith({ + integrationId: 'integration-for-portal-111', + body: body[0], + event: 'HUBSPOT_WEBHOOK', + }); + expect(integration.queueWebhook).toHaveBeenCalledWith({ + integrationId: 'integration-for-portal-222', + body: body[1], + event: 'HUBSPOT_WEBHOOK', + }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ received: 2, queued: 2, skipped: 0 }); + }); + + it('skips events whose portalId does not resolve to an integration', async () => { + const integration = makeIntegration(); + const body = [ + { portalId: 111, subscriptionType: 'contact.creation', objectId: 1 }, + { portalId: 999, subscriptionType: 'contact.creation', objectId: 2 }, // null lookup + ]; + const req = buildSignedRequest({ body }); + const res = makeRes(); + + await onHubSpotWebhookReceived.call(integration, { req, res }); + + expect(integration.queueWebhook).toHaveBeenCalledTimes(1); + expect(integration.queueWebhook).toHaveBeenCalledWith({ + integrationId: 'integration-for-portal-111', + body: body[0], + event: 'HUBSPOT_WEBHOOK', + }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ received: 2, queued: 1, skipped: 1 }); + }); + + it('skips events missing a portalId entirely', async () => { + const integration = makeIntegration(); + const body = [ + { subscriptionType: 'contact.creation', objectId: 1 }, // no portalId + { portalId: 222, subscriptionType: 'deal.creation', objectId: 2 }, + ]; + const req = buildSignedRequest({ body }); + const res = makeRes(); + + await onHubSpotWebhookReceived.call(integration, { req, res }); + + expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledTimes( + 1 + ); + expect(integration.queueWebhook).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ received: 2, queued: 1, skipped: 1 }); + }); + + it('returns 200 with zero counts on an empty event array', async () => { + const integration = makeIntegration(); + const req = buildSignedRequest({ body: [] }); + const res = makeRes(); + await onHubSpotWebhookReceived.call(integration, { req, res }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ received: 0, queued: 0, skipped: 0 }); + expect(integration.queueWebhook).not.toHaveBeenCalled(); + }); + + it('propagates ambiguous-resolution errors instead of catching them', async () => { + const integration = makeIntegration({ + commands: { + findIntegrationByEntityExternalId: jest + .fn() + .mockRejectedValue(new Error('ambiguous resolution')), + }, + }); + const req = buildSignedRequest({ body: [{ portalId: 111 }] }); + const res = makeRes(); + await expect( + onHubSpotWebhookReceived.call(integration, { req, res }) + ).rejects.toThrow(/ambiguous/); + expect(integration.queueWebhook).not.toHaveBeenCalled(); + }); + + it('queues zero events when any one resolution is ambiguous (all-or-nothing)', async () => { + // Multi-event batch where event[1] resolves ambiguous. With the + // two-phase implementation, NO queueWebhook calls should fire for + // any event — including event[0] which would have resolved cleanly. + // Prevents partial-enqueue + HubSpot-retry-duplication. + const integration = makeIntegration({ + commands: { + findIntegrationByEntityExternalId: jest.fn(async (portalId) => { + if (portalId === 222) + throw new Error('ambiguous resolution'); + return `integration-for-portal-${portalId}`; + }), + }, + }); + const req = buildSignedRequest({ + body: [ + { portalId: 111, subscriptionType: 'contact.creation' }, + { portalId: 222, subscriptionType: 'contact.creation' }, + { portalId: 333, subscriptionType: 'contact.creation' }, + ], + }); + const res = makeRes(); + await expect( + onHubSpotWebhookReceived.call(integration, { req, res }) + ).rejects.toThrow(/ambiguous/); + expect(integration.queueWebhook).not.toHaveBeenCalled(); + }); + + it('resolves and queues events in parallel rather than serially', async () => { + // Each lookup takes a tick; if dispatch were sequential, the second + // lookup would only start after the first lookup AND the first + // queueWebhook had resolved. We assert all three lookups have begun + // before any of them complete. + const inFlightLookups = jest.fn(); + const inFlightQueueWrites = jest.fn(); + let outstandingLookups = 0; + let outstandingQueueWrites = 0; + const integration = makeIntegration({ + commands: { + findIntegrationByEntityExternalId: jest.fn(async (portalId) => { + outstandingLookups += 1; + inFlightLookups(outstandingLookups); + await new Promise((resolve) => setImmediate(resolve)); + outstandingLookups -= 1; + return `integration-for-portal-${portalId}`; + }), + }, + queueWebhook: jest.fn(async () => { + outstandingQueueWrites += 1; + inFlightQueueWrites(outstandingQueueWrites); + await new Promise((resolve) => setImmediate(resolve)); + outstandingQueueWrites -= 1; + }), + }); + const req = buildSignedRequest({ + body: [ + { portalId: 111, subscriptionType: 'contact.creation' }, + { portalId: 222, subscriptionType: 'contact.creation' }, + { portalId: 333, subscriptionType: 'contact.creation' }, + ], + }); + const res = makeRes(); + await onHubSpotWebhookReceived.call(integration, { req, res }); + + // At some point all three lookups (and all three queue writes) + // should have been in flight simultaneously — that's what + // distinguishes parallel from serial dispatch. + expect(Math.max(...inFlightLookups.mock.calls.map((c) => c[0]))).toBe( + 3 + ); + expect( + Math.max(...inFlightQueueWrites.mock.calls.map((c) => c[0])) + ).toBe(3); + expect(res.body).toEqual({ received: 3, queued: 3, skipped: 0 }); + }); + + it('rejects with 401 when HUBSPOT_CLIENT_SECRET is not set', async () => { + const saved = process.env.HUBSPOT_CLIENT_SECRET; + delete process.env.HUBSPOT_CLIENT_SECRET; + try { + const integration = makeIntegration(); + const req = buildSignedRequest(); + const res = makeRes(); + await onHubSpotWebhookReceived.call(integration, { req, res }); + expect(res.statusCode).toBe(401); + expect(integration.queueWebhook).not.toHaveBeenCalled(); + } finally { + process.env.HUBSPOT_CLIENT_SECRET = saved; + } + }); +}); + +describe('onHubSpotWebhook (default per-event handler)', () => { + it('is a no-op that does not throw', async () => { + await expect( + onHubSpotWebhook.call({}, { data: { body: { portalId: 1 } } }) + ).resolves.toBeUndefined(); + }); +});