@alessandro-afloarei/integrations
A server-side connection layer for Node 22 and later. ESM only; install with pnpm add @alessandro-afloarei/[email protected].
Import the module you need through its subpath. There is no root barrel. The TypeScript declarations are the API reference.
errors provides coded errors such as ProviderHttpError and GrantRevoked. Apps map their codes to their own
messages and reconnect behavior; library errors never include provider response bodies or secrets.
ProviderHttpError.retryAfterMs is the final response's diagnostic wait in milliseconds, or null when absent or
invalid. A valid Retry-After header (seconds or HTTP-date) wins over Breakcold's body delay or Fireflies' epoch-ms
timestamp; past dates yield zero and fractional body delays round up. The field does not change retry counts or
wait caps, and an app must still decide whether a call is safe to retry. HubSpot's admitted MISSING_SCOPES and
VALIDATION_ERROR categories appear in detail; other categories are omitted. Error codes and messages stay unchanged.
providers holds HubSpot, Slack, Fireflies, Granola, Breakcold, Unipile, Firecrawl and Exa definitions. Pass an entry
such as hubspot to OAuth or HTTP calls. Provider modules own scopes; workspace-specific base URLs remain app configuration.
vault seals arbitrary JSON with createVault({ tenantKeys, keys }). Call seal and open with the tenant and
connection IDs. It opens gaja's existing format 1 and writes the key source's chosen format; format 2 adds AAD.
Use rewrapTenantKey to move a tenant's data key to the current KEK or format without rewriting its secrets.
oauth builds consent URLs with authorizeUrl and handles exchangeCode, refreshGrant and revokeGrant on
oauth4webapi. pkcePair supplies PKCE where supported. Inject fetch on each network call; the app owns the state
nonce. exchangeCode returns only the token-answer fields the provider's extras names, never the rest.
connect supplies startConnect(provider, options) and finishConnect(provider, input). Starts return a redirect,
a secret-field form or a hosted URL. OAuth completion exchanges the code, identifies the account, and revokes the new
grant best-effort if identity fails; the required onRevokeFailed(error) input reports failed cleanup. Each OAuth module
supplies its revocation callback; providers returning the same live token on re-consent need an adapter before using this flow.
The app owns nonce validation,
the registered redirect URI, verifier storage and sealing the returned secret. Never send ConnectStart to the browser:
keep codeVerifier on the server and redirect with url alone. Blank OAuth states and missing required verifiers are refused.
slack exports slackConnect, SLACK_SCOPES and slackClient({ token, fetch }). The client posts once, lists up to
five pages of 200 channels ({ items, skipped, channelsCut }), and revokes grants. channelsCut says more pages remain.
Token-gone responses from OAuth and the Web API are GrantRevoked, with the
provider code in detail; an already-gone token is a successful revoke. The fake at slack/fake requires the registered
absolute redirectUri, refuses other destinations, and issues one-use codes bound to the authorization's client and
callback. Tests must authorize before exchanging a fake code.
credentials supplies readSecret for API keys and accessToken for OAuth grants. Pass the stores, vault, lock and
a refresh function. Refreshes use a 15-minute margin by default, the provider's existing 30-second lock key, and
compare-and-swap saves. A refused refresh allows a concurrent writer 5 seconds to save, with reads every 500 ms.
A busy lock can serve a still-valid token; RefreshBusy asks the app to retry later.
http creates an authenticated client with createClient({ provider, token, fetch }). Call json for classified
JSON responses or request for raw status, headers and parsed data. Tokens are reused until a 401 requests renewal.
The client applies timeouts, pacing and capped 429 waits; only GETs and explicitly idempotent calls retry, and a
caller's signal disables retry waits. A redirect is followed only on the same origin (307/308, or 301–303 for a
GET, at most five hops), so a credential never reaches another host. Pass the app's guarded fetch to preserve fixtures
and production checks.
webhooks verifies HMAC signatures with verifyWebhook(provider.webhook, request, secret) over the exact raw body
bytes. Use readCappedBody(request, maxBytes) before verification. Routing, replay protection, dedupe and the inbox
remain in the app.
exa, firecrawl and unipile are provider modules: exaClient, firecrawlClient and unipileClient take the key
and the app's fetch and return typed calls, so the app never builds a provider request or reads a provider answer.
The library checks every answer with zod, which no public type names, so an app's own zod version never meets it;
an answer in the wrong shape is a ProviderHttpError with code invalid_response. Firecrawl sends no browser actions
to a document and reads a page as it loads when a render fails. unipileOrigin (in providers, so a boot check loads
no client) reads a workspace address as Unipile's dashboard shows it, and parseAccountNotification reads the hosted sign-in's notice; checking its name is the app's.
unipileConnect({client, link, signal?}) composes the existing V1 hosted sign-in with startConnect and finishConnect.
Start returns {kind:'hosted', url}; finish takes {secret:accountId} and returns that id as both secret and account
name/id, plus {createdAt} details. The optional signal bounds the account lookup. authLink refuses blank state;
nonce validation, account ownership, accepted creation age and persistence stay with the app.
granola and breakcold are the API-key sources' modules. Their clients return the provider's own field names, since
an app stores what they read, and answer a list one Page at a time: { items, skipped, cursor }, where skipped
holds the ids of entries the client could not read and cursor is null on the last page. paginate(load) walks the
pages and refuses a repeated cursor; the app stops when its own budget says so. Broken paging is invalid_response with
no status and the library's own detail (cursor_repeated, cursor_missing), so it never reads as a provider's code. createClient's beforeRequest(url)
runs once per call before anything is sent, for an app's metering, and a refusal's ProviderHttpError.detail carries
the provider's own code where the catalogue reads one (Breakcold's usage_limit).
hubspot provides hubspotClient for object searches, batch reads, deal associations, portal identity, stages and
owners. changedRecords walks changed records in ascending object-id order, resuming from afterId instead of a
search offset, so no query pages past HubSpot's 10,000-result cap. The app supplies the access-token function and
fetch, and can stop when its scan budget is spent.
hubspotConnect from hubspot composes with startConnect and finishConnect, asking for OAuth and read access
to companies, contacts, deals and owners. Identity uses the token answer's hub_id first, otherwise account-info, and returns
account: { id: portalId, name: "Portal <portalId>" }, details: { portalId }, and secret: grant. Both identity sources
require a positive integer or a string matching ^[1-9]\d*$; malformed token identity fails without falling back.
The secret keeps the shared Grant shape. HUBSPOT_SCOPES exports the same ordered scope list used by
hubspotConnect. HubSpot can return the portal's existing refresh token on re-consent, so the connect contract's
reusesGrant: true skips failed-identity cleanup. An orphan grant is returned on the next consent; revoking it
could end a live connection. The app must check ownership before discarding a grant. Explicit revoke still
revokes the refresh token; access-only grants expire naturally.
fakeHubSpot({ client: { clientId, clientSecret }, redirectUri, ...portalData }) from hubspot/fake requires the
registered client and an absolute HTTP(S) callback without userinfo or a fragment. Register the exact callback
used in both startConnect and finishConnect. Feed the real authorize URL to the fake, then extract its redirect's
one-use code; static fixture codes are refused. Authorization requires the registered client ID and every
HUBSPOT_SCOPES scope. Exchange, refresh and revoke require matching client_id and client_secret; invalid
credentials return 401 invalid_client without spending a valid code. Exchange binds codes to the exact callback
and accepts only authorization_code or refresh_token grant types.
One registered client/portal shares one refresh token across consents. Its initial shape is
fixture-refresh-<sha256(JSON.stringify([clientId, hubId]))>; after revocation the next consent adds a UUID suffix
and issues a new token. When a persisted replacement or legacy token resumes before any consent, it becomes the
canonical token. If a live grant already exists, refresh associates the persisted token as a holder of that grant
and returns the current canonical token; subsequent consents keep returning it. Revoking either the canonical token
or any accepted alias revokes all holders of that grant. Known revoked holders remain refused, and repeating their
revocation cannot end a later fresh grant. This alias canonicalization is a synthetic fixture behavior; the shared
production Grant contract is unchanged. Real HubSpot behavior after revocation has not been probed; new-token
issuance is a fixture ruling. Codes and access tokens keep per-instance UUID namespaces, so stale codes cannot cross instances.
Refresh accepts only that client/portal's fixture token shapes and the legacy fixture-refresh seed, including
persisted valid tokens after restart. Codes, access tokens, arbitrary strings and real tokens cannot refresh.
Keep one fake instance per process across authorize, exchange, refresh and revoke. Revocation accepts only
refresh tokens issued or resumed in that instance, succeeds repeatedly, and prevents further refresh there.
Unknown-token revoke remains 400 invalid_token; real HubSpot's response is unprobed. Revocation history is
process-local: other processes do not see it, and a restart forgets it. A persisted token must first resume
or be returned by consent before that instance can revoke it. These are synthetic fixture limitations, not
production revocation guarantees. The app still consumes and validates callback state before finishing.
import { startConnect, finishConnect } from '@alessandro-afloarei/integrations/connect';
import { hubspotConnect } from '@alessandro-afloarei/integrations/hubspot';
import { fakeHubSpot } from '@alessandro-afloarei/integrations/hubspot/fake';
import { fakeFetch } from '@alessandro-afloarei/integrations/testing';
const redirectUri = 'https://app.test/api/integrations/hubspot/callback';
const http = fakeFetch(fakeHubSpot({ ...portalData, client, redirectUri }));
const begun = await startConnect(hubspotConnect, { clientId: client.clientId, redirectUri, state });
if (begun.kind !== 'redirect') throw new Error('expected_redirect');
const consent = await http.fetch(begun.url, { redirect: 'manual' });
const back = new URL(consent.headers.get('location') ?? '');
if (back.searchParams.get('state') !== state) throw new Error('state_mismatch');
const code = back.searchParams.get('code');
if (!code) throw new Error('missing_code');
const connected = await finishConnect(hubspotConnect, {
client, code, redirectUri, fetch: http.fetch, onRevokeFailed,
});
The example uses app-owned portalData, OAuth client, a fresh stored state, and an onRevokeFailed observer.
In the app, consume and validate that state once; keep the returned secret on the server.
testing provides scripted fakeFetch routes, repeating sequence responses, memoryStores, memoryLock and
openLock. Each provider module has a fake beside it (exa/fake, firecrawl/fake, unipile/fake, granola/fake,
breakcold/fake, hubspot/fake): fakeExa, fakeFirecrawl, fakeUnipile, fakeGranola, fakeBreakcold and
fakeHubSpot return routes that answer the calls their clients make, from data the app passes
(each client's own tests pin its exact requests; fakeGranola inlines every transcript, so it never answers 413). respondFrom(routes) answers a request from routes or returns null,
for an app's fixture mode.
fakeBreakcold accepts optional readable: {notes?, meetings?} collections for fixtures that must be available by id
without entering ingestion lists. Normal notes and meetings still supply those lists and take precedence on a
duplicate id. Missing ids remain 404; direct-only meetings use the existing segments collection for transcripts.
fakeUnipile optionally accepts hostedConsent: {consentUrl, successUrl, failureUrl, notifyUrl, notify, accountId}.
Register absolute HTTP(S) URLs without userinfo or fragments. This mode replaces the legacy authLink callback:
the real client's auth-link request must match those destinations, and produces an expiring, single-use consent URL.
Fetching it through the fake's routes posts the V1 CREATION_SUCCESS notification with the bound state and app-supplied
account id. An accepted notification redirects to the registered success URL; a failed or redirected notification
goes to the failure URL. Notification redirects are never followed, and browser parameters cannot override the binding.
Bindings are instance-local: a fixture adapter should obtain and consume a fresh auth link in the same request,
so restarting the dev server does not lose an earlier browser request's binding. Production hosted sign-in keeps no
library-local state. The app still provides the account fixture's createdAt value and enforces its nonce policy.
Run checkCredentialStore against each real store adapter in the app's database test tier, using an
isolated existing connection fixture and a tenant with no key; it restores the connection's original sealed secret.
Apps implement four seams; the library contains no database or Redis client:
SecretStore: read a connection secret and replace it only at the expected version.
TenantKeyStore: read, insert the winning tenant key, and replace at the expected version.
KeySource: expose current KEK version, write format, and get(version).
WithLock: run work under a named lock, or return "busy".
Run pnpm install --frozen-lockfile, then pnpm check for typecheck, ESLint with zero warnings, knip and Vitest.
Run pnpm build for ESM and declarations, or pnpm pack for a local install. Library tests use fake providers and
in-memory stores and need no server or database.
Release tags must match the package version. After review and merge, the maintainer pushes a v* tag; CI repeats
the gates and publishes publicly using the repository's NPM_TOKEN secret. Apps pin exact versions.
Fireflies
firefliesClient({ apiKey, fetch, signal? }) reads the key owner (user), a transcript (transcript),
keyword matches (search), or one 50-record offset page (transcripts). identify(client) is the API-key proof.
changedTranscripts(client, { userId, fromDate, toDate, cursor? }) yields pages through paginate. The window
filters on transcript creation date, not modification date: edits are not re-yielded once creation falls outside
the window. Windows must overlap (gaja uses three days). Fireflies gives no order guarantee; apps own deduplication
and page budgets. Break the iteration to enforce your own run budget.
The cursor is a JSON string binding the offset to the user, date window and fixed 50-record page size, with null
at the end. Resume only with the same query; a fresh window starts without a cursor. Malformed or mismatched cursors
throw TypeError('cursor_invalid'). Invalid dates and search limits throw TypeError('date_invalid') and
TypeError('limit_invalid'); search accepts integer limits from 1 through 50. skipped reports unreadable list
entries, counted toward the offset. Apps validate records against their own stored contract. An unnormalizable
detail record throws FirefliesTranscriptError from fireflies/errors, a ProviderHttpError with code
invalid_response, detail: 'transcript_invalid' and a sanitized path containing only field names and indices.
It is distinct from a missing record, which returns null.
fireflies/fake exports fakeFireflies({ account: apiKey => ({ user, transcripts }) }); return null to reject a
key. Pass its routes to fakeFetch or respondFrom from testing. It covers identity, paging, transcript reads
and keyword search against caller-owned fixture data.
After verifying the raw request with verifyWebhook, Fireflies' parseWebhook(body) reads the V2 event,
meeting_id and timestamp (number, or legacy string). Unknown fields are omitted; malformed events return null.
Breakcold's parseWebhook(headers) reads x-webhook-delivery-id. Apps own deduplication and retain original bytes.
HubSpot stage history
hubspotClient(...).dealStageHistory(ids) returns a Map<string, HubSpotStageRevision[]>, with each revision
shaped as { value: stageId, timestamp, sourceType? }, ordered oldest first. Timestamps are preserved exactly;
equal timestamps retain provider order. A returned deal without history maps to []; a missing deal is absent.
The method reads all supplied IDs in sequential batches using a separate 50-ID history limit (plain reads remain at 100). A failed batch rejects
the call with the usual sanitized provider error, including a provider's batch-limit 400; it never returns a partial map.
History means every revision HubSpot still retains. HubSpot documents
up to 20 revisions per deal property,
with no calendar retention window or documented dealstage exception. The library returns everything supplied and
never synthesizes missing revisions. History reads use a 50-input cap, based on the provider's batch-history 400
reported in PipedreamHQ/pipedream#8462. That report is for
contacts; we apply the same property-history cap to deals. HubSpot's
Deals OpenAPI
warns that requesting history lowers the per-request maximum, without naming the number.
The Deals guide documents
propertiesWithHistory: ["dealstage"]. There is no documented history cursor to recover pruned revisions.
The official OpenAPI schema
describes timestamps as property update times, but provides no explicit guarantee of their immutability across reads.
The limit comes from that observed provider error alongside official documentation; we did not run a real-portal probe.
fakeHubSpot(data) also accepts fakeHubSpot(() => data). The callback runs once at construction and must return
valid data then. It supplies current portal content once per
request that reads it; OAuth registration (client, redirect URI, hubId) is taken at construction, and grant state
survives content changes. Optional dealStageHistory maps deal IDs to revision arrays for known sync and cohort deals.
Optional dealContacts maps sync deal IDs to contact ID arrays, falling back to the cohort map when absent;
an explicit empty array overrides the cohort. Omitting these fields preserves existing behavior.
dealCompanies maps a deal to one company ID, or to { id, primary? }[] served with HubSpot's association types.
dealCompanies(ids) returns each deal's primary company (HubSpot's HUBSPOT_DEFINED type 5), else its lowest
company ID by number, because HubSpot does not order a deal's associations.
The fake returns a 400 for history batches above 50 inputs, so fixture-backed consumers exercise the same limit.