feat(hubspot): add Tier 3 webhooks extension bundle#91
Conversation
Adds `hubspot.extensions.webhooks` matching the Tier 3 Integration Extension contract defined in friggframework/frigg#590. Integration consumers wire it in via `static Definition.extensions`: extensions: { hubspotWebhooks: { extension: hubspot.extensions.webhooks, handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' }, }, }, The bundle exposes one receiver route (POST /webhooks) plus two events. The default `HUBSPOT_WEBHOOK_RECEIVED` handler verifies the HubSpot v3 signature (HMAC SHA-256 over method+uri+body+timestamp, client-secret keyed, base64-encoded, 5-minute skew), iterates the inbound event array, resolves each event's portalId to a Frigg integration via the platform-neutral `findIntegrationByEntityExternalId` helper, and enqueues `HUBSPOT_WEBHOOK` jobs per matched event. A thin `findIntegrationByPortalId(integration, portalId)` wrapper lives inside the extension so HubSpot vocabulary stays in the api-module — core stays platform-neutral. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 46c07d3607
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewFound 2 issues:
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Four targeted fixes from Daniel's review: 1. lookup.js — source the module name from `definition.js` instead of hardcoding `'hubspot'`. A rename of the api-module now flows through automatically via `Definition.moduleName`. 2. lookup.js — switch from `integration.findIntegrationByEntityExternalId` to `integration.commands.findIntegrationByEntityExternalId`. The commands surface is the canonical access pattern for cross-cutting lookups in Frigg; the framework-side PR exposes the same use case there. Error message + tests updated to match the new contract. 3. extensions/webhooks/index.js — replace the vague "EXTENSIONS.md in the framework repo" pointer with a concrete URL to the file on the `next` branch of friggframework/frigg. 4. handlers.js — inline `'HUBSPOT_WEBHOOK'` at the single call site and drop the `EVENT_NAME_DISPATCH` const + export. Unnecessary indirection for a string used once. Tests still green (68 passing across the two affected suites). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed 387ef00 addressing the four review points:
Tests rewritten to mock Note: this PR now depends on the framework-side change in friggframework/frigg#590 — they should land together (or #590 first). |
…ectly
Daniel's follow-up: why not just read process.env.HUBSPOT_CLIENT_SECRET
directly?
There is no good reason. resolveClientSecret was doing a fragile
Object.values(Definition.modules) traversal that:
- silently picked the first module with env.client_secret if more
than one was present — a key-order assumption in a security path
- duplicated the env-var read at the bottom of the function anyway
- had a JSDoc precedence section that didn't actually match the code
Replaced with a direct `process.env.HUBSPOT_CLIENT_SECRET` read at the
single call site. Deleted the helper + its export, simplified the test
integration mock (no more constructor.Definition.modules scaffolding),
and replaced the "falls back to env var" test with one that asserts
401 when the env var is missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the sequential-dispatch finding from the earlier review.
Before: a for-loop awaited findIntegrationByPortalId then queueWebhook
per event. A 50-event HubSpot batch took 50 × (DB lookup + SQS send)
inside the synchronous HTTP receiver — realistic batch sizes could
exceed HubSpot's 5s response budget and trigger a full-batch retry,
duplicating every event that had already been queued before the timeout.
On top of that, if a lookup midway through the batch threw ambiguous,
the events ahead of it were already queued; HubSpot retried, those
events were queued again.
After: two-phase. Phase 1 resolves every portalId in parallel
(Promise.all). Phase 2 enqueues every match in parallel. If any Phase 1
lookup throws (ambiguous resolution → cross-tenant routing risk), it
bubbles before any queueWebhook fires, so the batch is all-or-nothing
on the queue side. Same fail-loud semantics on ambiguity; no partial
state for HubSpot's retry to duplicate.
Side effect: the manual queued/skipped counters are gone — we just
filter + take .length on the resolutions array.
Two new tests lock the behavior in:
- "queues zero events when any one resolution is ambiguous"
- "resolves and queues events in parallel rather than serially"
(asserts all three lookups + all three queue writes are in flight
concurrently at some point)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Graphite review flagged that the empty-array early-return responded
{ received: 0, queued: 0 } while the normal path returned
{ received, queued, skipped } — a client referencing .skipped on the
empty case would see undefined.
Rather than patching the early-return to add skipped: 0, delete the
branch entirely. Promise.all([]) → [], filter(Boolean) → [], so the
normal path handles the empty case as a degenerate run and emits the
same shape ({ received: 0, queued: 0, skipped: 0 }). One less branch,
one less response-shape definition, one less inconsistency to remember.
Updated the empty-batch test to assert the unified shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
seanspeaks
left a comment
There was a problem hiding this comment.
Largely a LGTM based on reviewing the reviews
Expands the api-module README from a stub to explain the Tier 3 webhooks extension added in this PR: - the app-level vs account-level problem it solves - the concern/identity split (signature → app secret, routing → portalId lookup, business logic → per-portal credentials) - how to bind it on an integration (Definition.extensions + commands) - the HUBSPOT_CLIENT_SECRET requirement - what the bundle contributes (routes + the two events) Also closes the unterminated docs-site markdown link in the intro. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The webhooks extension depends on the friggCommands surface added in friggframework/frigg#590 (integration.commands.findIntegrationByEntityExternalId). Pin to the canary published from that PR's commit b2cd5e2 so the api-module installs and tests against the matching framework build: @friggframework/core 2.0.0--canary.590.b2cd5e2.0 Lockfile regenerated — the v2 core pulls in the AWS SDK v3 transitive tree that v1.1.2 did not carry. When #590 lands and a stable @friggframework/core 2.x ships, swap this canary for the released range. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retarget PR #91 from main to next. Conflicts resolved: - packages/v1-ready/hubspot/package.json: kept the canary @friggframework/core 2.0.0--canary.590.b2cd5e2.0 over next's ^2.0.0-next.16 — the canary carries framework PR #590's friggCommands (commands.findIntegrationByEntityExternalId) that this extension's lookup.js depends on; next.16 predates #590. Swap for a stable next core once #590 lands and publishes. - package-lock.json: regenerated from next's lockfile against the merged manifest rather than hand-merged. Side benefit: next already fixes the lerna packages glob (packages/** vs the stale packages/* on main), so `lerna ls` now finds all 26 packages and the release job can publish again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #590 (Tier 3 extensions + friggCommands reverse-lookup) is merged and released as 2.0.0-next.88. Swap the temporary canary pin (2.0.0--canary.590.b2cd5e2.0) for the released range ^2.0.0-next.88. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
🚀 PR was released in |
Adds
hubspot.extensions.webhooks— the api-module half of the Tier 3 Integration Extensions work in friggframework/frigg#590 (ADR-EXTENSIONS in friggframework/frigg#589).What's in the bundle
POST /webhooksreceiver route bound toHUBSPOT_WEBHOOK_RECEIVEDHUBSPOT_WEBHOOK_RECEIVEDhandler: verifies v3 signature, iterates the inbound event array, resolves eachportalIdviafindIntegrationByEntityExternalId(portalId, 'hubspot'), enqueues oneHUBSPOT_WEBHOOKjob per matched event, responds200 { received, queued, skipped }HUBSPOT_WEBHOOKhandler: no-op (integrations override viabinding.handlers)findIntegrationByPortalId(integration, portalId)wrapper kept inside the api-module so HubSpot vocabulary stays out of coreUsage
Signature verification
method + fullUrl + body + timestamp, keyed by the app's client secret, base64-encodedX-HubSpot-Signature-V3andX-HubSpot-Request-Timestamp(ms)X-Forwarded-Proto/X-Forwarded-Hosthonored for LB deployments;HUBSPOT_WEBHOOK_BASE_URLenv var for full overridereq.rawBodywhen middleware preserves it — JSON re-serialization would break the HMAC otherwisecrypto.timingSafeEqualDefinition.modules.hubspot.definition.env.client_secret→process.env.HUBSPOT_CLIENT_SECRETWhat's deferred (judgment calls)
feat/hubspot-webhook-subscriptions) is adding subscription-management primitives toapi.js; once both land, a follow-up can call those primitives from a deploy hook. The receiver/handler/dispatch bundle stands on its own.queuesorworkers— extension-contributed events flow through the existing per-integration queue worker per the Phase 2 TODO inintegration-defined-workers.js.Framework contract note for #590 reviewer
The current
IntegrationBase.queueWebhookin #590 hardcodes the top-level SQS message event to'ON_WEBHOOK'and ignoresdata.event. EXTENSIONS.md and this PR passevent: 'HUBSPOT_WEBHOOK'. For end-to-end delivery to fire the boundHUBSPOT_WEBHOOKhandler (not the defaultonWebhookno-op),queueWebhookneeds to honordata.eventwhen present. Flagging here so it can be addressed in #590 before merge.Files
packages/v1-ready/hubspot/extensions/webhooks/index.js— bundle exportpackages/v1-ready/hubspot/extensions/webhooks/signature-verifier.js— HMAC v3 logicpackages/v1-ready/hubspot/extensions/webhooks/handlers.js— the two event handlerspackages/v1-ready/hubspot/extensions/webhooks/lookup.js—findIntegrationByPortalIdwrapperpackages/v1-ready/hubspot/index.js— addsextensions: { webhooks }alongside existingApi/Config/Definitionpackages/v1-ready/hubspot/tests/extensions/webhooks.test.js— 34 unit testsDoes not touch
packages/v1-ready/hubspot/api.js— PR #89's territory.Test plan
npx jest tests/extensions/webhooks.test.js— 34 passednpx jest tests/auther.test.js— existing 5 passed, 5 skipped (live tests intentionally skipped without HubSpot creds)node -e "require('./index')"— bundle exports cleanlyqueueWebhookhonoringdata.event— see note above)📦 Published PR as canary version:
Canary Versions✨ Test out this PR locally via:
npm install @friggframework/api-module-microsoft-teams@1.1.6-canary.91.3686b18.0 npm install @friggframework/api-module-slack@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-42matters@1.1.5-canary.91.3686b18.0 npm install @friggframework/api-module-asana@1.1.6-canary.91.3686b18.0 npm install @friggframework/api-module-attio@1.0.1-canary.91.3686b18.0 npm install @friggframework/api-module-clio@1.0.1-canary.91.3686b18.0 npm install @friggframework/api-module-connectwise@1.0.5-canary.91.3686b18.0 npm install @friggframework/api-module-contentful@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-contentstack@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-crossbeam@1.0.1-canary.91.3686b18.0 npm install @friggframework/api-module-deel@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-frigg-scale-test@0.1.1-canary.91.3686b18.0 npm install @friggframework/api-module-frontify@1.3.1-canary.91.3686b18.0 npm install @friggframework/api-module-google-calendar@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-google-drive@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-helpscout@0.1.3-canary.91.3686b18.0 npm install @friggframework/api-module-hubspot@1.1.8-canary.91.3686b18.0 npm install @friggframework/api-module-ironclad@1.0.2-canary.91.3686b18.0 npm install @friggframework/api-module-linear@1.1.4-canary.91.3686b18.0 npm install @friggframework/api-module-pipedrive@2.0.1-canary.91.3686b18.0 npm install @friggframework/api-module-salesforce@1.0.3-canary.91.3686b18.0 npm install @friggframework/api-module-stripe@1.0.1-canary.91.3686b18.0 npm install @friggframework/api-module-unbabel@1.1.6-canary.91.3686b18.0 npm install @friggframework/api-module-unbabel-projects@1.0.3-canary.91.3686b18.0 npm install @friggframework/api-module-zoho-crm@1.0.3-canary.91.3686b18.0 npm install @friggframework/api-module-zoom@1.0.1-canary.91.3686b18.0 # or yarn add @friggframework/api-module-microsoft-teams@1.1.6-canary.91.3686b18.0 yarn add @friggframework/api-module-slack@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-42matters@1.1.5-canary.91.3686b18.0 yarn add @friggframework/api-module-asana@1.1.6-canary.91.3686b18.0 yarn add @friggframework/api-module-attio@1.0.1-canary.91.3686b18.0 yarn add @friggframework/api-module-clio@1.0.1-canary.91.3686b18.0 yarn add @friggframework/api-module-connectwise@1.0.5-canary.91.3686b18.0 yarn add @friggframework/api-module-contentful@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-contentstack@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-crossbeam@1.0.1-canary.91.3686b18.0 yarn add @friggframework/api-module-deel@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-frigg-scale-test@0.1.1-canary.91.3686b18.0 yarn add @friggframework/api-module-frontify@1.3.1-canary.91.3686b18.0 yarn add @friggframework/api-module-google-calendar@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-google-drive@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-helpscout@0.1.3-canary.91.3686b18.0 yarn add @friggframework/api-module-hubspot@1.1.8-canary.91.3686b18.0 yarn add @friggframework/api-module-ironclad@1.0.2-canary.91.3686b18.0 yarn add @friggframework/api-module-linear@1.1.4-canary.91.3686b18.0 yarn add @friggframework/api-module-pipedrive@2.0.1-canary.91.3686b18.0 yarn add @friggframework/api-module-salesforce@1.0.3-canary.91.3686b18.0 yarn add @friggframework/api-module-stripe@1.0.1-canary.91.3686b18.0 yarn add @friggframework/api-module-unbabel@1.1.6-canary.91.3686b18.0 yarn add @friggframework/api-module-unbabel-projects@1.0.3-canary.91.3686b18.0 yarn add @friggframework/api-module-zoho-crm@1.0.3-canary.91.3686b18.0 yarn add @friggframework/api-module-zoom@1.0.1-canary.91.3686b18.0Version
Published prerelease version:
@friggframework/api-module-hubspot@2.0.0-next.4Changelog
🚀 Enhancement
@friggframework/api-module-hubspot🐛 Bug Fix
@friggframework/api-module-microsoft-teams,@friggframework/api-module-slack,@friggframework/api-module-42matters,@friggframework/api-module-asana,@friggframework/api-module-attio,@friggframework/api-module-clio,@friggframework/api-module-connectwise,@friggframework/api-module-contentful,@friggframework/api-module-contentstack,@friggframework/api-module-crossbeam,@friggframework/api-module-deel,@friggframework/api-module-frigg-scale-test,@friggframework/api-module-frontify,@friggframework/api-module-google-calendar,@friggframework/api-module-google-drive,@friggframework/api-module-helpscout,@friggframework/api-module-hubspot,@friggframework/api-module-ironclad,@friggframework/api-module-linear,@friggframework/api-module-pipedrive,@friggframework/api-module-salesforce,@friggframework/api-module-stripe,@friggframework/api-module-unbabel-projects,@friggframework/api-module-unbabel,@friggframework/api-module-zoho-crm,@friggframework/api-module-zoomAuthors: 1