@volter/twin-hubspot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +197 -0
- package/client/hubspot-mirror.css +43 -0
- package/client/hubspot-mirror.tsx +132 -0
- package/dist/client/hubspot-mirror.bundle.js +449 -0
- package/dist/client/hubspot-mirror.css +43 -0
- package/dist/client/hubspot-mirror.d.ts +15 -0
- package/dist/client/hubspot-mirror.js +59 -0
- package/dist/client/hubspot-mirror.tsx +132 -0
- package/dist/src/accounts.d.ts +30 -0
- package/dist/src/accounts.js +122 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/hubspot-areas.d.ts +10 -0
- package/dist/src/hubspot-areas.js +114 -0
- package/dist/src/hubspot-budget.d.ts +58 -0
- package/dist/src/hubspot-budget.js +176 -0
- package/dist/src/hubspot-capabilities.d.ts +3 -0
- package/dist/src/hubspot-capabilities.js +1588 -0
- package/dist/src/hubspot-conformance.d.ts +16 -0
- package/dist/src/hubspot-conformance.js +523 -0
- package/dist/src/hubspot-connector.d.ts +125 -0
- package/dist/src/hubspot-connector.js +390 -0
- package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
- package/dist/src/hubspot-deferred-capabilities.js +64 -0
- package/dist/src/hubspot-mirror-ui.d.ts +62 -0
- package/dist/src/hubspot-mirror-ui.js +152 -0
- package/dist/src/hubspot-oauth.d.ts +8 -0
- package/dist/src/hubspot-oauth.js +291 -0
- package/dist/src/hubspot-server.d.ts +24 -0
- package/dist/src/hubspot-server.js +116 -0
- package/dist/src/hubspot-twin.d.ts +65 -0
- package/dist/src/hubspot-twin.js +1558 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +94 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +68 -0
- package/dist/src/portal.d.ts +20 -0
- package/dist/src/portal.js +30 -0
- package/dist/src/screens/account.d.ts +1 -0
- package/dist/src/screens/account.js +139 -0
- package/dist/src/screens/crm.d.ts +2 -0
- package/dist/src/screens/crm.js +153 -0
- package/dist/src/screens/developer.d.ts +4 -0
- package/dist/src/screens/developer.js +191 -0
- package/dist/src/screens/forms.d.ts +5 -0
- package/dist/src/screens/forms.js +126 -0
- package/dist/src/screens/page.d.ts +21 -0
- package/dist/src/screens/page.js +49 -0
- package/dist/src/screens/session.d.ts +1 -0
- package/dist/src/screens/session.js +32 -0
- package/dist/src/semantics/crm.d.ts +8 -0
- package/dist/src/semantics/crm.js +101 -0
- package/dist/src/webhooks.d.ts +12 -0
- package/dist/src/webhooks.js +77 -0
- package/package.json +75 -0
- package/src/accounts.ts +127 -0
- package/src/cli.ts +29 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/hubspot-areas.ts +155 -0
- package/src/hubspot-budget.ts +202 -0
- package/src/hubspot-capabilities.ts +1523 -0
- package/src/hubspot-conformance.ts +537 -0
- package/src/hubspot-connector.ts +419 -0
- package/src/hubspot-deferred-capabilities.ts +99 -0
- package/src/hubspot-journey.uitest.ts +104 -0
- package/src/hubspot-mirror-ui.ts +166 -0
- package/src/hubspot-oauth.tsx +296 -0
- package/src/hubspot-server.ts +115 -0
- package/src/hubspot-twin.ts +1534 -0
- package/src/index.ts +152 -0
- package/src/manifest.ts +96 -0
- package/src/portal.ts +40 -0
- package/src/screens/account.tsx +129 -0
- package/src/screens/crm.tsx +154 -0
- package/src/screens/developer.tsx +181 -0
- package/src/screens/forms.tsx +117 -0
- package/src/screens/page.tsx +55 -0
- package/src/screens/session.tsx +36 -0
- package/src/semantics/crm.ts +116 -0
- package/src/webhooks.ts +80 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// HubSpot's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the thin typed
|
|
2
|
+
// bindings `liveHubspotExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
|
|
3
|
+
// window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
|
|
4
|
+
// lives ONCE in the vendor-agnostic kernel (`@volter/world-core` → `rateBudget.ts`). Read that module's
|
|
5
|
+
// header for the full rationale AND for the honest list of what the guard does not guarantee (an
|
|
6
|
+
// injected clock or ledger path still defeats it — it guards carelessness, not malice).
|
|
7
|
+
//
|
|
8
|
+
// ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// A real ~4.5-DAY vendor lockout (Figma, 2026-07-25) happened because raw API calls were made
|
|
10
|
+
// outside the pack's connector — no cache, no batching, no ceiling. Discipline only binds the code
|
|
11
|
+
// that follows it; a BUDGET binds the code that does not.
|
|
12
|
+
//
|
|
13
|
+
// ── HOW THE CEILING WAS CHOSEN ──────────────────────────────────────────────────────────────
|
|
14
|
+
// HubSpot publishes scalar limits, so this models the real thing rather than guessing. From
|
|
15
|
+
// https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines (read
|
|
16
|
+
// 2026-08-31), for a privately distributed app:
|
|
17
|
+
//
|
|
18
|
+
// • Free / Starter: 100 requests per 10 seconds per app, 250,000 per account per day
|
|
19
|
+
// • Professional: 190 requests per 10 seconds per app, 625,000 per account per day
|
|
20
|
+
// • Enterprise: 190 requests per 10 seconds per app, 1,000,000 per day
|
|
21
|
+
// • With the API Limit Increase add-on: 250 requests per 10 seconds
|
|
22
|
+
// • Publicly distributed OAuth apps: 110 requests every 10 seconds per installing account
|
|
23
|
+
// (excludes the CRM Search API)
|
|
24
|
+
//
|
|
25
|
+
// and, separately, https://developers.hubspot.com/changelog/crm-search-api-rate-limit-increase
|
|
26
|
+
// (read 2026-08-31): the CRM Search API's burst limit is FIVE REQUESTS PER SECOND.
|
|
27
|
+
//
|
|
28
|
+
// The window is 60 000 ms. HubSpot's own interval is 10 seconds, and matching it exactly would
|
|
29
|
+
// have been tidier — but the kernel's isolation roster refuses any window SHORTER than the
|
|
30
|
+
// fallback's minute (a shorter window at the same ceiling is a silent rate increase), and that
|
|
31
|
+
// gate is not something a pack gets to weaken for cosmetic alignment. So the vendor's figure is
|
|
32
|
+
// converted instead: the TIGHTEST published tier, Free/Starter's 100 requests per 10 seconds for
|
|
33
|
+
// a privately distributed app, is 600 requests a minute.
|
|
34
|
+
//
|
|
35
|
+
// The ceiling is 180 weighted units per minute — 180 ordinary calls a minute, UNDER A THIRD of
|
|
36
|
+
// that 600, because a pack cannot know which subscription the operator's portal is on and must
|
|
37
|
+
// budget for the smallest one. That is more permissive than the kernel's undeclared fallback
|
|
38
|
+
// (30 calls/min) and is therefore licensed ONLY by the documented figure: the hand-written
|
|
39
|
+
// `VENDOR_BURST_ANCHOR` entry for hubspot in `scripts/rate-budget-isolation.test.ts` carries the
|
|
40
|
+
// 600 and its source, and the gate's arithmetic is against that number, not against anything this
|
|
41
|
+
// declaration says about itself.
|
|
42
|
+
//
|
|
43
|
+
// It bounds the average; it does NOT pace (the kernel refuses, it never sleeps — see its header).
|
|
44
|
+
// An intra-second burst can still reach the vendor first; the backstop for that is the cooldown
|
|
45
|
+
// armed from a 429 or an `X-HubSpot-RateLimit-Remaining: 0` / `X-RateLimit-Remaining: 0` signal.
|
|
46
|
+
//
|
|
47
|
+
// ── HOW THE WEIGHTS WERE CHOSEN (documented vs. judged) ─────────────────────────────────────
|
|
48
|
+
// HubSpot counts REQUESTS, so weight 1 is the faithful price and that is what ordinary CRUD gets.
|
|
49
|
+
// Three deliberate exceptions:
|
|
50
|
+
// • CRM SEARCH costs 3. This one is anchored in a published HubSpot figure, not a judgement:
|
|
51
|
+
// search has its OWN five-requests-per-second burst limit, roughly a third of the general
|
|
52
|
+
// allowance, and pricing it at 3 makes the general ceiling bind search at the tighter rate.
|
|
53
|
+
// • BATCH object calls cost 5. One `POST /crm/v3/objects/{type}/batch/create` mutates up to
|
|
54
|
+
// 100 CRM records — the runaway-loop failure mode here writes real customer data into a real
|
|
55
|
+
// portal, which is a HUMAN blast radius, not just a throttle. The rule is anchored on the
|
|
56
|
+
// whole `/batch/` family rather than the write verbs alone, so a 100-record `batch/read` is
|
|
57
|
+
// priced the same; that is the conservative direction and is deliberate. A judgement call.
|
|
58
|
+
// • BATCH association writes cost 3, for the same reason at a smaller scale. A judgement call.
|
|
59
|
+
import { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
|
|
60
|
+
const VENDOR = 'hubspot';
|
|
61
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
62
|
+
export const HUBSPOT_BUDGET_WINDOW_MS = 60_000;
|
|
63
|
+
/**
|
|
64
|
+
* Weighted units allowed inside one window. 180/60s = 180 ordinary calls a minute — under a THIRD
|
|
65
|
+
* of HubSpot's tightest published tier converted to a minute (Free/Starter: 100 requests per 10
|
|
66
|
+
* seconds for a privately distributed app = 600/minute).
|
|
67
|
+
*/
|
|
68
|
+
export const HUBSPOT_BUDGET_CEILING = 180;
|
|
69
|
+
/** Seconds. A `Retry-After` above this means the token is throttled hard — fail loudly, don't sleep. */
|
|
70
|
+
export const HUBSPOT_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
71
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for documented vs. judged. */
|
|
72
|
+
export const HUBSPOT_CALL_WEIGHTS = {
|
|
73
|
+
/** CRM Search — HubSpot's own five-requests-per-second burst limit, ~1/3 the general rate. */
|
|
74
|
+
search: 3,
|
|
75
|
+
/** Batch object calls — one WRITE mutates up to 100 real CRM records (a human blast radius),
|
|
76
|
+
* and the anchored rule prices the whole `/batch/` family, READS included, deliberately: a
|
|
77
|
+
* 100-record read is the heaviest single request this API serves and pricing it as a write is
|
|
78
|
+
* the conservative direction. Named `batchWrite` for the case that motivated the number. */
|
|
79
|
+
batchWrite: 5,
|
|
80
|
+
/** Batch association writes — the same shape at a smaller scale. */
|
|
81
|
+
batchAssociation: 3,
|
|
82
|
+
/** Everything else: single-record CRUD, properties, pipelines, owners, association reads. */
|
|
83
|
+
other: 1,
|
|
84
|
+
};
|
|
85
|
+
/** THE PACK'S DECLARATION — pure data, the only HubSpot-specific thing in the whole budget. */
|
|
86
|
+
export const HUBSPOT_RATE_BUDGET = {
|
|
87
|
+
windowMs: HUBSPOT_BUDGET_WINDOW_MS,
|
|
88
|
+
ceiling: HUBSPOT_BUDGET_CEILING,
|
|
89
|
+
defaultWeight: HUBSPOT_CALL_WEIGHTS.other,
|
|
90
|
+
maxRetryAfterSeconds: HUBSPOT_BUDGET_MAX_RETRY_AFTER_S,
|
|
91
|
+
rules: [
|
|
92
|
+
{ match: '^POST /crm/v3/objects/[^/]+/search$', weight: HUBSPOT_CALL_WEIGHTS.search },
|
|
93
|
+
{ match: '^POST /crm/v3/objects/[^/]+/batch/', weight: HUBSPOT_CALL_WEIGHTS.batchWrite },
|
|
94
|
+
{ match: '^POST /crm/v4/associations/', weight: HUBSPOT_CALL_WEIGHTS.batchAssociation },
|
|
95
|
+
],
|
|
96
|
+
reason: 'HubSpot documents, for a privately distributed app, 100 requests / 10 seconds on Free and ' +
|
|
97
|
+
'Starter (250,000 per account per day), 190 / 10 seconds on Professional (625,000/day) and on ' +
|
|
98
|
+
'Enterprise (1,000,000/day), and 250 / 10 seconds with the API Limit Increase add-on; a ' +
|
|
99
|
+
'publicly distributed OAuth app gets 110 requests every 10 seconds per installing account ' +
|
|
100
|
+
'(developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines, read 2026-08-31). ' +
|
|
101
|
+
'The CRM Search API carries its OWN, tighter burst limit of five requests per second ' +
|
|
102
|
+
'(developers.hubspot.com/changelog/crm-search-api-rate-limit-increase, read 2026-08-31). ' +
|
|
103
|
+
'HubSpot\'s own interval is 10 seconds, but the kernel roster refuses a window shorter than ' +
|
|
104
|
+
'the fallback\'s minute (a shorter window at the same ceiling is a silent rate increase), so ' +
|
|
105
|
+
'the vendor figure is CONVERTED rather than copied: the tightest published tier, 100 requests ' +
|
|
106
|
+
'per 10 seconds, is 600 requests a minute. The ceiling of 180 weighted units per 60s is under ' +
|
|
107
|
+
'a THIRD of that, because a pack cannot know which subscription the operator\'s portal is on ' +
|
|
108
|
+
'and must budget for the smallest. That IS more permissive than the kernel fallback (30 ' +
|
|
109
|
+
'calls/min), and is licensed only by the documented 600 — the hand-written VENDOR_BURST_ANCHOR ' +
|
|
110
|
+
'entry in scripts/rate-budget-isolation.test.ts carries that number and its source. Search costs 3 ' +
|
|
111
|
+
"(anchored in HubSpot's own 5/second search limit, roughly a third of the general rate); " +
|
|
112
|
+
'batch object writes cost 5 and batch association writes 3 — judgement calls, not published ' +
|
|
113
|
+
'costs, because one batch call mutates up to 100 real CRM records. The window bounds the ' +
|
|
114
|
+
'60-second AVERAGE and does not pace; the 429 / `X-HubSpot-RateLimit-Remaining: 0` cooldown ' +
|
|
115
|
+
'is the backstop for a sub-second burst.',
|
|
116
|
+
};
|
|
117
|
+
// Declared at module load, so merely importing this module (which `hubspot-connector.ts` does) is
|
|
118
|
+
// enough to arm the real ceiling.
|
|
119
|
+
declareRateBudget(VENDOR, HUBSPOT_RATE_BUDGET);
|
|
120
|
+
/**
|
|
121
|
+
* Price one call. The key is `"<METHOD> <path>"` (the absolute API path the connector states,
|
|
122
|
+
* without a host) with the query string split off, so a rule can price by method without the
|
|
123
|
+
* kernel knowing anything about HubSpot. An unclassified endpoint still costs `defaultWeight` —
|
|
124
|
+
* nothing is ever free.
|
|
125
|
+
*/
|
|
126
|
+
export function hubspotCallWeight(method, path) {
|
|
127
|
+
const { bare, query } = splitQuery(path);
|
|
128
|
+
// UPPER-CASE the method: `fetch` normalizes a known lowercase method before sending, so
|
|
129
|
+
// `execute('post', …)` really does issue a POST and must be priced as one.
|
|
130
|
+
return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* `/crm/v3/objects/contacts?limit=1` -> `{ bare: '/crm/v3/objects/contacts', query: { limit: '1' } }`.
|
|
134
|
+
*
|
|
135
|
+
* NORMALIZED, because the anchored rules are otherwise trivially evaded: `fetch` upper-cases a
|
|
136
|
+
* known method before sending, so `execute('post', …)` issues a real WRITE that a `^POST ` rule
|
|
137
|
+
* would price as a read; and a trailing slash makes a path miss a `$` anchor while HubSpot's own
|
|
138
|
+
* router treats it as the same endpoint (`/crm/v3/owners` and `/crm/v3/owners/` are both real).
|
|
139
|
+
* Both are input variations, not attacks, and either one silently voids the "expensive endpoints
|
|
140
|
+
* are priced up" claim the ceiling rests on.
|
|
141
|
+
*/
|
|
142
|
+
function splitQuery(path) {
|
|
143
|
+
const at = path.indexOf('?');
|
|
144
|
+
const query = {};
|
|
145
|
+
if (at !== -1)
|
|
146
|
+
for (const [k, v] of new URLSearchParams(path.slice(at + 1)))
|
|
147
|
+
query[k] = v;
|
|
148
|
+
const raw = at === -1 ? path : path.slice(0, at);
|
|
149
|
+
// Collapse a trailing slash, but never turn the root path into the empty string.
|
|
150
|
+
const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
|
|
151
|
+
return { bare, query };
|
|
152
|
+
}
|
|
153
|
+
/** Where HubSpot's ledger lives. Token-keyed and cwd-independent by default (the limit is per app
|
|
154
|
+
* / per private-app token, so a cwd-scoped ledger would hand the same token a fresh allowance in
|
|
155
|
+
* every checkout, worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
|
|
156
|
+
export function hubspotBudgetPath(opts = {}) {
|
|
157
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
158
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
|
|
159
|
+
// excess-property check only catches object literals) must not redirect this pack's ledger to
|
|
160
|
+
// another vendor's file.
|
|
161
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* HubSpot's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
165
|
+
* not an alias, so `budget instanceof HubspotBudget` in `liveHubspotExecute` means "a budget that
|
|
166
|
+
* accounts against HUBSPOT's ledger under HUBSPOT's ceiling": another vendor's `RateBudget` (with
|
|
167
|
+
* its own, possibly larger, ceiling) is NOT assignable there.
|
|
168
|
+
*/
|
|
169
|
+
export class HubspotBudget extends RateBudget {
|
|
170
|
+
constructor(opts = {}) {
|
|
171
|
+
super({ ...opts, vendor: VENDOR });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
|
|
175
|
+
* which one refused, and `err.kind` says why. */
|
|
176
|
+
export { RateBudgetError as HubspotBudgetError } from '@volter/world-core';
|