@volter/twin-hubspot
v0.1.37
Published
Local HubSpot CRM twin — a faithful, stateful local HubSpot v3/v4 CRM API your @hubspot/api-client integration talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-hubspot
The HubSpot CRM twin — a local, stateful, vendor-faithful replica of the
HubSpot CRM v3/v4 API, on the shared
@volter/world-core kernel. An unmodified @hubspot/api-client — pointed here
with its own public basePath option, or routed by the world injector's api.hubapi.com
interception — talks to it and gets vendor-correct responses.
State lives in the kernel's append-only action/event log — a write appends an action that projects
into visible state; reads use the kernel’s tree. No real HubSpot is ever called (the only network path
is the connector, over an injected client). The CRM API accepts a faked-locally Bearer token; an app's
OAuth install is served (src/hubspot-oauth.tsx: the consent at app.hubspot.com/oauth/authorize, the code and
refresh exchanges at POST /oauth/v1/token, token metadata at GET /oauth/v1/access-tokens/{token}). There is NO
signature verification.
Usage
bun run src/cli.ts serve # the HubSpot CRM API twin (writable)
bun run src/cli.ts serve --read-only
bun run src/cli.ts mirror # the React CRM mirror (records + the deal pipeline board)
bun run src/cli.ts conformance # offline, failable endpoint probe over the live routerCollections use HubSpot's envelope — { results, paging: { next: { after } } } — and errors use
the documented { status: "error", message, correlationId, category } shape (with errors[]
where the vendor carries per-field detail).
Two deliberate departures from the live vendor, both required by serve-path determinism
(CLAUDE.md — a served response must be a pure function of (request, stored state) and
byte-identical on replay):
correlationIdis DERIVED, not random. It is a hash of(method, pathname, status, category, message)rendered in HubSpot's UUID shape. Real HubSpot mints a fresh one per request; a twin that did the same could not be replayed.- The rate-limit
-RemainingCOUNTERS are not served. Every response carries HubSpot's documented policy headers with the real Professional-tier figures —X-HubSpot-RateLimit-Max: 190,X-HubSpot-RateLimit-Interval-Milliseconds: 10000,X-HubSpot-RateLimit-Daily: 625000— and deliberately notX-HubSpot-RateLimit-Remaining/-Daily-Remaining: a truthful remaining count is a wall-clock-metered, account-wide number no local twin observes, and fabricating one would both break replayability and tell the caller something untrue. Filed ashubspot.protocol.rate_limit_remaining_headers.
Coverage
The capability manifest (src/hubspot-capabilities.ts) is the real HubSpot surface as the
denominator — coverage is honest and partial. Run bun scripts/manifest-baseline-one.ts hubspot
for the live count. As of writing: done=105 / total=231, regressions=0.
The denominator's source is @hubspot/api-client 14.0.1's own generated client — 936 operations
across eleven top-level API families, enumerated from every lib/codegen/**/apis/*Api.js request
factory. Those eleven families are censused in src/hubspot-areas.ts; the generated request
factories, response processors and models are also the oracle for this twin's paths, status
codes and response shapes (HubSpot publishes no first-party OpenAPI document this repo can vendor,
which is why spec-sources.json records kind: "none" with that as the reason).
Scope: this is the CRM
api.hubapi.com fronts eleven separate HubSpot products. This twin models the CRM — records,
properties, associations, pipelines, owners and the CRM Search API. Two different things follow
from that, and they get different treatment (the §9 round-one ruling):
- Surface INSIDE the CRM that is not built yet is a plain
todo, however much work it is — engagements, commerce, custom-object schemas, lists, imports/exports and CRM extensions all ride the same/crm/routes this twin already serves, so carving them out would be shrinking the denominator rather than describing it (HUBSPOT_UNMODELED_CRM_CAPABILITIES). - A separate HubSpot PRODUCT behind the same host is a deferred area, whose capabilities are
todoand whose area records why it is a separate product (HUBSPOT_DEFERRED_AREA_CAPABILITIES).hubspot.deferred.area_routes_refuseddrives a real request at one route per deferred area — and asserts that route table bijects with the area census — proving the twin refuses each in HubSpot's own error envelope, never a fabricated success.
Deferred (9 areas, all non-CRM): CMS · Marketing · Automation · Conversations · Files · Events · Communication preferences · Settings · Webhooks.
A CRM object type HubSpot really has but this twin does not model — notes, line_items, a
custom object — is refused honestly (404, "does not model"), never with HubSpot's own
Unable to infer object type from wording, which the vendor uses only for a segment it genuinely
cannot resolve. All three spellings are covered: the plural name, the objectTypeId form
(0-46, 2-3453932) and the fullyQualifiedName form (p2953265_car).
Modeled (proven done, each with a failable offline verify):
- CRM objects — contacts / companies / deals / tickets: create, read (with
?properties=and?associations=), list (with the documented 100-per-page cap,?archived=, andpaging.next.aftercursor paging), update, archive, and merge. Record ids are minted asmax + 1over the id set already in state, archived rows included, from a namespaced base (HUBSPOT_LOCAL_ID_BASE, 9e11 — an order of magnitude above the largest HubSpot record id this repo has observed). That covers both collision directions: an archived id is never reused, a local mint never lands on an id a pull observed, and — the direction §9 round one found live — a pull arriving LATER cannot land on a locally minted subject either.mapCrmObjectadditionally stampshsLocalMint: false, so an observed row always wins the addressability question. - Batch —
batch/create(201),batch/read(200, or 207 witherrors[]when an id is missing, andidPropertylookups),batch/update,batch/upsert(new: true|false),batch/archive(204). - CRM Search —
filterGroups(AND within a group, OR across groups) over exactly the 13 documentedFilterOperatorEnumvalues (an operator outside that closed set is a 400), numeric comparison forLT/LTE/GT/GTE/BETWEEN,IN/NOT_IN, presence operators, tokenizedCONTAINS_TOKENwith a trailing-*wildcard, free-textquery,sorts,propertiesprojection, offset paging, and the documented 200-per-page / 10 000-result / 5-group / 6-filter / 18-filter caps. - Properties + property groups — the HubSpot-defined property set per object type, plus create / read / update / archive for both, a 409 on a duplicate name, and a custom property that round-trips on a record and is searchable.
- Pipelines + stages — HubSpot's out-of-the-box deal pipeline (
default, seven stages) and ticket pipeline (0, four stages), create / read / update / replace (PUT, full body required) / archive, and full stage CRUD. Stage ids come from a persisted monotonic counter, so removing a stage never frees its id for the next one. - Owners — list, read,
?email=filter, and owners observed by a connector pull. - Associations (v4) — the
PUTwith anAssociationSpec[]body (201LabelsBetweenObjectPaircarrying the NUMERIC objectTypeIds).AssociationSpecis a closed two-key shape: there is nolabelon the request — HubSpot resolves labels from the association-type definition, which this twin does not model, solabelsis empty and a caller-supplied label is never echoed back. Also thedefaultPUT using HubSpot's documented type ids (contact→company 279 / company→contact 280 / …), bidirectional readback, archive from both directions, theassociationsblock on a create, the closedAssociationSpeccategory set, and batch create/read/archive. - Protocol — the documented error envelope, unmodeled ops failing like the vendor (404),
read-only rejecting writes (405), the rate-limit policy headers, deterministic replay, and the
three kernel-reserved field names (
id/createdAt/updatedAt) surviving the projection. - Connector — pull the four object types plus owners, following
paging.next.after; idempotent re-pull; a refused pull throws rather than folding an empty account over real state (including a failure delivered as HTTP 200 with an error envelope); push confirms only on a genuine success, and never addresses a locally minted record id at a real portal. - UI mirror — the contacts list, the deal pipeline board (grouping asserted per column,
not just card counts), the stage pills, the record property sheet, the nav (bijected against
MIRROR_SECTIONS, so deleting a destination reddens it), the empty state, and the write path — a UI write goes through the vendor's own endpoint on the same handler, and an A→B→A revert through it still lands. All data-coupled (seed through the twin's write path → fetch the SAME vendor endpoint the screen reads → render the mirror's OWN component → assert the seeded value survives), plus two Playwright journeys.
Todo (real surface, not yet modeled): the six unbuilt CRM families above (engagements,
commerce, custom-object schemas, lists, imports/exports, extensions), ?idProperty= reads,
propertiesWithHistory,
GDPR delete, per-object required-property and enumeration validation, unique-value conflicts,
HubSpot's automatic de-duplication, batch size caps and partial-207 creates, search sort direction
and association filters, calculated properties, pipeline audit trails
and stage validation on records, association label definitions and cardinality limits, 401/403
auth refusals, the 429 body, connector pulls of properties/pipelines/associations and push id
reconciliation, and the board drag-to-stage / saved-views / search-bar / inline-edit UI screens.
Also planned: Breeze / AI content assistant endpoints — the documented request/response envelope with a deterministic, labeled twin-stub result — plus the deferred product areas above.
The human surface: this vendor gets a mirror
Settled with the recipe's rule, not by "does a UI exist": when someone does HubSpot's core job,
do they open a browser or write code? A sales rep works a deal by dragging it across the pipeline
board and a support agent works a ticket in the ticket view — the CRM screen is the product, the
same shape as Jira's board. So this pack ships a mirror, and it is archetype A (API
passthrough): non-asset requests fall through to handleHubspotTwinRequest and the browser
client fetches HubSpot's real API paths, so API↔UI parity cannot drift — there is only one code
path.
Files
src/hubspot-twin.ts— the CRM v3/v4 request handler (routes → kernel writes/reads) and the hand-authored endpoint census.src/hubspot-server.ts—createHubspotTwinServer({ root?, port?, readOnly? }).src/hubspot-mirror-ui.ts+client/hubspot-mirror.tsx— the React CRM mirror.src/hubspot-connector.ts— pull/push over an injected executor (liveHubspotExecutefor real I/O).src/hubspot-conformance.ts— one live request per claimed endpoint, plus a router census checked in both directions (dev-only; lazy-imported by the CLI).src/hubspot-areas.ts/src/hubspot-deferred-capabilities.ts— the top-down product-area census and the deferred areas' todos.src/hubspot-capabilities.ts— the capability manifest (the real vendor surface).src/hubspot-sdk.integration.test.ts— the unmodified@hubspot/api-client14.0.1 driving the twin over its own publicbasePathoption (a devDependency; never in the runtime graph).
Rate budget — the fail-closed backstop on live calls
liveHubspotExecute is the one place this pack issues a live request, so every call it makes
is charged against a persistent, fail-closed spend ledger before the request goes out. Past the
ceiling, or while a Retry-After/429 cooldown is armed, it throws instead of calling. The
ledger is keyed by vendor and a hash of the credential (limits are per app/token, so it is
deliberately not cwd-scoped) and persists across processes, so a fresh process does not get a
fresh allowance; a corrupt ledger counts as a full window rather than zero spend. There is no
option to disable it, and no value you can pass for budget that yields an unguarded client — an
injected budget is validated by method identity, so a subclass or a Proxy that replaces
checkBudget is refused.
The declared numbers: 30 weighted units / 10 s — HubSpot's own 10-second interval, at under a third of its tightest published tier (Free/Starter: 100 requests per 10 seconds for a privately distributed app; Professional and Enterprise get 190, the API-add-on 250, a public OAuth app 110). CRM search costs 3, anchored in HubSpot's own separate five-requests-per-second search limit; batch object writes cost 5 and batch association writes 3 — judgement calls, because one batch call mutates up to 100 real CRM records.
The mechanism is shared and vendor-agnostic — it lives in the kernel (@volter/world-core →
packages/world-core/src/rateBudget.ts); what lives here in
src/hubspot-budget.ts is this vendor's declaration (window, ceiling,
per-endpoint weights, and a reason citing the limits above) plus the vendor-bound
HubspotBudget. The rule is ratified as ../../../docs/contributing/architecture.md D8, and
the kernel module's header documents what the guard does not guarantee — read that before
trusting it.
