@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.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +197 -0
  3. package/client/hubspot-mirror.css +43 -0
  4. package/client/hubspot-mirror.tsx +132 -0
  5. package/dist/client/hubspot-mirror.bundle.js +449 -0
  6. package/dist/client/hubspot-mirror.css +43 -0
  7. package/dist/client/hubspot-mirror.d.ts +15 -0
  8. package/dist/client/hubspot-mirror.js +59 -0
  9. package/dist/client/hubspot-mirror.tsx +132 -0
  10. package/dist/src/accounts.d.ts +30 -0
  11. package/dist/src/accounts.js +122 -0
  12. package/dist/src/cli.d.ts +2 -0
  13. package/dist/src/cli.js +31 -0
  14. package/dist/src/generated/surface.gen.json +1 -0
  15. package/dist/src/generated/ui.gen.json +1 -0
  16. package/dist/src/hubspot-areas.d.ts +10 -0
  17. package/dist/src/hubspot-areas.js +114 -0
  18. package/dist/src/hubspot-budget.d.ts +58 -0
  19. package/dist/src/hubspot-budget.js +176 -0
  20. package/dist/src/hubspot-capabilities.d.ts +3 -0
  21. package/dist/src/hubspot-capabilities.js +1588 -0
  22. package/dist/src/hubspot-conformance.d.ts +16 -0
  23. package/dist/src/hubspot-conformance.js +523 -0
  24. package/dist/src/hubspot-connector.d.ts +125 -0
  25. package/dist/src/hubspot-connector.js +390 -0
  26. package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
  27. package/dist/src/hubspot-deferred-capabilities.js +64 -0
  28. package/dist/src/hubspot-mirror-ui.d.ts +62 -0
  29. package/dist/src/hubspot-mirror-ui.js +152 -0
  30. package/dist/src/hubspot-oauth.d.ts +8 -0
  31. package/dist/src/hubspot-oauth.js +291 -0
  32. package/dist/src/hubspot-server.d.ts +24 -0
  33. package/dist/src/hubspot-server.js +116 -0
  34. package/dist/src/hubspot-twin.d.ts +65 -0
  35. package/dist/src/hubspot-twin.js +1558 -0
  36. package/dist/src/index.d.ts +11 -0
  37. package/dist/src/index.js +94 -0
  38. package/dist/src/manifest.d.ts +2 -0
  39. package/dist/src/manifest.js +68 -0
  40. package/dist/src/portal.d.ts +20 -0
  41. package/dist/src/portal.js +30 -0
  42. package/dist/src/screens/account.d.ts +1 -0
  43. package/dist/src/screens/account.js +139 -0
  44. package/dist/src/screens/crm.d.ts +2 -0
  45. package/dist/src/screens/crm.js +153 -0
  46. package/dist/src/screens/developer.d.ts +4 -0
  47. package/dist/src/screens/developer.js +191 -0
  48. package/dist/src/screens/forms.d.ts +5 -0
  49. package/dist/src/screens/forms.js +126 -0
  50. package/dist/src/screens/page.d.ts +21 -0
  51. package/dist/src/screens/page.js +49 -0
  52. package/dist/src/screens/session.d.ts +1 -0
  53. package/dist/src/screens/session.js +32 -0
  54. package/dist/src/semantics/crm.d.ts +8 -0
  55. package/dist/src/semantics/crm.js +101 -0
  56. package/dist/src/webhooks.d.ts +12 -0
  57. package/dist/src/webhooks.js +77 -0
  58. package/package.json +75 -0
  59. package/src/accounts.ts +127 -0
  60. package/src/cli.ts +29 -0
  61. package/src/generated/surface.gen.json +1 -0
  62. package/src/generated/ui.gen.json +1 -0
  63. package/src/hubspot-areas.ts +155 -0
  64. package/src/hubspot-budget.ts +202 -0
  65. package/src/hubspot-capabilities.ts +1523 -0
  66. package/src/hubspot-conformance.ts +537 -0
  67. package/src/hubspot-connector.ts +419 -0
  68. package/src/hubspot-deferred-capabilities.ts +99 -0
  69. package/src/hubspot-journey.uitest.ts +104 -0
  70. package/src/hubspot-mirror-ui.ts +166 -0
  71. package/src/hubspot-oauth.tsx +296 -0
  72. package/src/hubspot-server.ts +115 -0
  73. package/src/hubspot-twin.ts +1534 -0
  74. package/src/index.ts +152 -0
  75. package/src/manifest.ts +96 -0
  76. package/src/portal.ts +40 -0
  77. package/src/screens/account.tsx +129 -0
  78. package/src/screens/crm.tsx +154 -0
  79. package/src/screens/developer.tsx +181 -0
  80. package/src/screens/forms.tsx +117 -0
  81. package/src/screens/page.tsx +55 -0
  82. package/src/screens/session.tsx +36 -0
  83. package/src/semantics/crm.ts +116 -0
  84. package/src/webhooks.ts +80 -0
@@ -0,0 +1,419 @@
1
+ // HubSpot CONNECTOR — the live-vendor pull/push path that gives the HubSpot twin the full
2
+ // "git for SaaS" lifecycle.
3
+ //
4
+ // PULL (real → twin): page real CRM records (contacts, companies, deals, tickets) plus the
5
+ // portal's owners out of api.hubapi.com, map them → SyncResource[], and
6
+ // fold into the observed-event log via observeResources (the kernel dedupes — a
7
+ // re-pull of identical state appends nothing).
8
+ // PUSH (twin → real): for every PENDING local action, call the real HubSpot CRM API and
9
+ // confirmAction on success.
10
+ //
11
+ // The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO
12
+ // HubSpot token and import NO network client. Offline/tests pass a fake executor; live runs pass
13
+ // `liveHubspotExecute(accessToken)`. Same code path either way.
14
+ import { assertBudgetGuardIntact, confirmAction, deployableEntries, observeResources } from '@volter/world-core';
15
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
16
+ import { HubspotBudget, HubspotBudgetError, hubspotCallWeight, type HubspotBudgetOptions } from './hubspot-budget.ts';
17
+ import { OBJECT_TYPES, type ObjectType } from './hubspot-twin.ts';
18
+
19
+ const SERVICE = 'hubspot';
20
+
21
+ /** Subject types that are twin-internal state and are never pushed to a real portal. */
22
+ const INTERNAL_SUBJECT_TYPES = new Set(['owner']);
23
+
24
+ /** The HubSpot error envelope, as the vendor documents it. */
25
+ export type HubspotErrorBody = { status?: string; message?: string; category?: string; correlationId?: string; errors?: unknown[] };
26
+
27
+ /**
28
+ * The injected real-HubSpot boundary. `execute` issues ONE HubSpot REST call and returns the
29
+ * parsed JSON body together with the HTTP status, because HubSpot signals refusal with the STATUS
30
+ * (a 4xx carrying `{status:'error', …}`), and a body-only contract cannot tell a refusal from an
31
+ * empty account. A real client (a raw fetch wrapper) is structurally assignable; tests pass a fake.
32
+ */
33
+ export type HubspotExecute = (
34
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
35
+ path: string,
36
+ body?: Record<string, unknown>,
37
+ ) => Promise<{ httpStatus: number; body: unknown }>;
38
+
39
+ /** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
40
+ export type LiveHubspotOptions = {
41
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
42
+ fetchImpl?: typeof fetch;
43
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
44
+ budget?: HubspotBudget;
45
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
46
+ budgetOptions?: HubspotBudgetOptions;
47
+ };
48
+
49
+ /**
50
+ * A live executor against the real HubSpot CRM API (the operator's own private-app token).
51
+ *
52
+ * THIS IS THE ONE PLACE this pack issues a live `api.hubapi.com` request, and therefore the one
53
+ * place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
54
+ * the request goes out (`checkBudget`, which THROWS `HubspotBudgetError` instead of returning when
55
+ * the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
56
+ * `Retry-After` / 429 / `X-HubSpot-RateLimit-Remaining: 0` signal becomes a persisted cooldown that
57
+ * makes every later call fail fast WITHOUT touching HubSpot. There is deliberately no OPTION to
58
+ * disable the guard, and no value a caller can pass for `budget` that yields an unguarded client.
59
+ * That is NOT immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction,
60
+ * or an injected clock, restores the allowance, because the seam tests need cannot be denied to a
61
+ * determined caller in the same process. See `hubspot-budget.ts` and the kernel's own header.
62
+ */
63
+ export function liveHubspotExecute(
64
+ accessToken: string,
65
+ base = 'https://api.hubapi.com',
66
+ opts: LiveHubspotOptions = {},
67
+ ): HubspotExecute {
68
+ const doFetch = opts.fetchImpl ?? fetch;
69
+ // `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
70
+ // UNMODIFIED HubspotBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and
71
+ // a Proxy that traps it are all refused, because all three are one-liners that would otherwise
72
+ // hand back a client with no ceiling at all. The default ledger is keyed by a hash of THIS
73
+ // token — HubSpot limits per app/token, so a cwd-scoped ledger would hand the same token a fresh
74
+ // allowance per checkout/worktree/CI leg.
75
+ const budget = opts.budget !== undefined && opts.budget !== null
76
+ ? assertBudgetGuardIntact(opts.budget, HubspotBudget, 'liveHubspotExecute')
77
+ : new HubspotBudget({ token: accessToken, ...(opts.budgetOptions ?? {}) });
78
+ return async (method, path, body) => {
79
+ const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
80
+ const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
81
+ if (method !== 'GET' && body !== undefined) {
82
+ headers['Content-Type'] = 'application/json';
83
+ init.body = JSON.stringify(body);
84
+ }
85
+ const weight = hubspotCallWeight(method, path);
86
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
87
+ const reservation = budget.checkBudget(weight);
88
+ const res = await doFetch(`${base}${path}`, init);
89
+ const resHeaders: Record<string, string> = {};
90
+ res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
91
+ const text = await res.text();
92
+ let parsed: unknown = null;
93
+ if (text) { try { parsed = JSON.parse(text) as unknown; } catch { parsed = { status: 'error', message: text }; } }
94
+ // Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
95
+ // `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
96
+ // first either way, so the refusal survives the throw.
97
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
98
+ // call that louder refusal wins; an answer HubSpot ACCEPTED is kept, so a write that landed is
99
+ // never recorded as failed and performed again on retry.
100
+ try {
101
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
102
+ } catch (error) {
103
+ if (!(error instanceof HubspotBudgetError) || !res.ok) throw error;
104
+ }
105
+ return { httpStatus: res.status, body: parsed };
106
+ };
107
+ }
108
+
109
+ // ── mapping ────────────────────────────────────────────────────────────────────────────────
110
+ /**
111
+ * Map a HubSpot SimplePublicObject → a SyncResource the kernel can fold.
112
+ *
113
+ * The renames are load-bearing, not cosmetic: the kernel's META set (`control-plane/src/
114
+ * actions.ts`) silently DROPS resource fields named `type`, `id` or `updatedAt`, and HubSpot's
115
+ * record carries all three. A field literally named `id` would vanish from the projection with no
116
+ * error at all.
117
+ *
118
+ * `hsLocalMint: false` is equally load-bearing. The twin's own create handler stamps
119
+ * `hsLocalMint: true`, and `hubspotRequestForAction` reads that stamp to decide whether a record
120
+ * id addresses anything in the real portal. `observeResources` MERGES observed fields onto an existing
121
+ * subject, so if a portal record ever landed on a subject a local create had already minted, an
122
+ * absent stamp here would leave the stale `true` in place — and the now-genuinely-real record
123
+ * would be permanently unaddressable, pushing every later edit as a duplicate CREATE. Stamping it
124
+ * false makes the OBSERVED row win. (`HUBSPOT_LOCAL_ID_BASE` is what makes that collision
125
+ * implausible in the first place; this is the belt to its braces.)
126
+ */
127
+ export function mapCrmObject(objectType: ObjectType, o: Record<string, unknown>): SyncResource {
128
+ return {
129
+ type: 'crm_object',
130
+ id: `${objectType}_${String(o.id)}`,
131
+ fields: {
132
+ objectType,
133
+ hsId: String(o.id),
134
+ hsCreatedAt: String(o.createdAt ?? ''),
135
+ hsUpdatedAt: String(o.updatedAt ?? ''),
136
+ archived: o.archived === true,
137
+ hsLocalMint: false,
138
+ props: (o.properties ?? {}) as Record<string, unknown>,
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * The properties a pull ASKS FOR, per object type.
145
+ *
146
+ * HubSpot's list endpoints return only a small default property set unless `?properties=` names
147
+ * more, so a pull that omitted it would land records with near-empty property maps against a real
148
+ * portal — while a fixture handing back rich properties would hide that. These are the
149
+ * HubSpot-defined names the twin models for each type; the portal's CUSTOM properties still need
150
+ * the schema pull filed as `hubspot.connector.pull_properties`.
151
+ */
152
+ export const PULL_PROPERTIES: Record<ObjectType, string[]> = {
153
+ contacts: ['email', 'firstname', 'lastname', 'phone', 'company', 'lifecyclestage', 'hubspot_owner_id', 'createdate', 'lastmodifieddate', 'hs_object_id'],
154
+ companies: ['name', 'domain', 'city', 'industry', 'phone', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', 'hs_object_id'],
155
+ deals: ['dealname', 'amount', 'dealstage', 'pipeline', 'closedate', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', 'hs_object_id'],
156
+ tickets: ['subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', 'hs_ticket_priority', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', 'hs_object_id'],
157
+ };
158
+
159
+ /** Map a HubSpot PublicOwner → a SyncResource. */
160
+ export function mapOwner(o: Record<string, unknown>): SyncResource {
161
+ return {
162
+ type: 'owner',
163
+ id: `owner_${String(o.id)}`,
164
+ fields: {
165
+ ownerId: String(o.id), email: o.email ?? null, firstName: o.firstName ?? null,
166
+ lastName: o.lastName ?? null, userId: o.userId ?? null, ownerType: o.type ?? 'PERSON',
167
+ archived: o.archived === true, teams: o.teams ?? [],
168
+ hsCreatedAt: String(o.createdAt ?? ''), hsUpdatedAt: String(o.updatedAt ?? ''),
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * A STRICTLY INCREASING poll timestamp — the connector's `occurredAt` default.
175
+ *
176
+ * NOT a pinned constant, deliberately. The kernel hashes an observed event over
177
+ * (`occurredAt` + post-state), so under a fixed poll time a vendor value that REVERTS
178
+ * (A → B → A across polls) collides with its own earlier observation: `observeResources` reports
179
+ * `deltasAppended: 1` while nothing lands, and the projection keeps serving the stale value.
180
+ * Forced strictly increasing within the process so two polls in the same millisecond still get
181
+ * distinct stamps (`dynadot-connector.ts` is the precedent).
182
+ */
183
+ let lastPollMs = 0;
184
+ export function pollTimestamp(): string {
185
+ const now = Date.now();
186
+ lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
187
+ return new Date(lastPollMs).toISOString();
188
+ }
189
+
190
+ /**
191
+ * Read one vendor reply, or THROW.
192
+ *
193
+ * A refused pull is NOT an empty account: a connector that maps a failure to an empty resource
194
+ * list hands the kernel an empty account to fold OVER real observed state, silently deleting it.
195
+ * HubSpot answers a refusal with a 4xx/5xx AND a `{status:'error', category, message}` body, so
196
+ * both are checked — a body-only check would miss a bare 401 and a status-only check would miss
197
+ * an error envelope that arrived with a 2xx.
198
+ */
199
+ function unwrap(res: { httpStatus: number; body: unknown }, what: string): Record<string, unknown> {
200
+ const body = (res.body ?? {}) as HubspotErrorBody & Record<string, unknown>;
201
+ if (res.httpStatus < 200 || res.httpStatus >= 300) {
202
+ throw new Error(`hubspot ${what} refused: HTTP ${res.httpStatus} ${body.category ?? ''} ${body.message ?? ''}`.trim());
203
+ }
204
+ if (body.status === 'error') {
205
+ throw new Error(`hubspot ${what} refused: ${body.category ?? 'error'} ${body.message ?? ''}`.trim());
206
+ }
207
+ return body;
208
+ }
209
+
210
+ /** Page through one CRM object type, following `paging.next.after` until it is absent. */
211
+ async function collectObjects(execute: HubspotExecute, objectType: ObjectType, pageSize = 100): Promise<SyncResource[]> {
212
+ const out: SyncResource[] = [];
213
+ let after: string | null = null;
214
+ // Bounded so a vendor (or a fake) that never stops advancing cannot loop forever.
215
+ for (let page = 0; page < 100; page += 1) {
216
+ const qs = `limit=${pageSize}&properties=${encodeURIComponent(PULL_PROPERTIES[objectType].join(','))}${after === null ? '' : `&after=${encodeURIComponent(after)}`}`;
217
+ const body = unwrap(await execute('GET', `/crm/v3/objects/${objectType}?${qs}`), `pull ${objectType}`);
218
+ const results = Array.isArray(body.results) ? (body.results as Record<string, unknown>[]) : [];
219
+ for (const r of results) out.push(mapCrmObject(objectType, r));
220
+ const paging = body.paging as { next?: { after?: unknown } } | undefined;
221
+ const next = paging?.next?.after;
222
+ if (next === undefined || next === null || String(next) === after) break;
223
+ after = String(next);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /** Read the portal's owners. */
229
+ async function collectOwners(execute: HubspotExecute): Promise<SyncResource[]> {
230
+ const body = unwrap(await execute('GET', '/crm/v3/owners'), 'pull owners');
231
+ const results = Array.isArray(body.results) ? (body.results as Record<string, unknown>[]) : [];
232
+ return results.map(mapOwner);
233
+ }
234
+
235
+ /** PULL one CRM object type into the twin's observed log (idempotent). */
236
+ export async function pullHubspotObjects(execute: HubspotExecute, objectType: ObjectType, root?: string, occurredAt?: string): Promise<number> {
237
+ const resources = await collectObjects(execute, objectType);
238
+ ((__at) => observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt ?? pollTimestamp());
239
+ return resources.length;
240
+ }
241
+
242
+ /** PULL the portal's owners into the twin's observed log (idempotent). */
243
+ export async function pullHubspotOwners(execute: HubspotExecute, root?: string, occurredAt?: string): Promise<number> {
244
+ const resources = await collectOwners(execute);
245
+ ((__at) => observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt ?? pollTimestamp());
246
+ return resources.length;
247
+ }
248
+
249
+ /** Full pull: all four standard CRM object types + owners, folded record-type by record-type. */
250
+ export async function pullHubspotAll(execute: HubspotExecute, root?: string, occurredAt?: string): Promise<Record<string, number>> {
251
+ const at = occurredAt ?? pollTimestamp();
252
+ const counts: Record<string, number> = {};
253
+ for (const objectType of OBJECT_TYPES) counts[objectType] = await pullHubspotObjects(execute, objectType, root, at);
254
+ counts.owners = await pullHubspotOwners(execute, root, at);
255
+ return counts;
256
+ }
257
+
258
+ /**
259
+ * D7 consumer-facing pull entry point: pull from real HubSpot (the four standard CRM objects +
260
+ * owners) and fold into the twin in ONE observation, returning the standard
261
+ * `{ observed, deltasAppended }` result. Idempotent — a re-pull of identical state appends
262
+ * nothing (deltasAppended drops to 0).
263
+ */
264
+ export async function syncHubspotFromReal(
265
+ execute: HubspotExecute,
266
+ opts: { root?: string; occurredAt?: string } = {},
267
+ ): Promise<{ observed: number; deltasAppended: number }> {
268
+ const occurredAt = opts.occurredAt ?? pollTimestamp();
269
+ const resources: SyncResource[] = [];
270
+ for (const objectType of OBJECT_TYPES) resources.push(...(await collectObjects(execute, objectType)));
271
+ resources.push(...(await collectOwners(execute)));
272
+ const result = ((__at) => observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt);
273
+ return { observed: resources.length, deltasAppended: result.appended };
274
+ }
275
+
276
+ // ── push ───────────────────────────────────────────────────────────────────────────────────
277
+ /** Translate one pending local action into the real HubSpot REST call it represents, or null. */
278
+ export function hubspotRequestForAction(action: TwinAction): { method: 'POST' | 'PATCH' | 'PUT' | 'DELETE'; path: string; body?: Record<string, unknown> } | null {
279
+ if (INTERNAL_SUBJECT_TYPES.has(action.subject.type)) return null;
280
+ const fields = (action.fields ?? {}) as Record<string, unknown>;
281
+
282
+ if (action.subject.type === 'crm_object') {
283
+ const objectType = String(fields.objectType ?? '') as ObjectType;
284
+ if (!(OBJECT_TYPES as readonly string[]).includes(objectType)) return null;
285
+ const hsId = fields.hsId === undefined ? null : String(fields.hsId);
286
+ const props = (fields.props ?? {}) as Record<string, unknown>;
287
+ // A LOCALLY MINTED id must never be sent to the real portal as a real record id. The twin
288
+ // mints from `HUBSPOT_LOCAL_ID_BASE` (9e11) upward — an order of magnitude above the largest
289
+ // HubSpot record id this repo has observed — so such an id addresses NOTHING in a real
290
+ // account. A local create is therefore a CREATE upstream, and only a record whose id came
291
+ // from the real portal may be addressed by id. The two sides are told apart by an explicit
292
+ // stamp, in both directions: the twin's create handler sets `hsLocalMint: true` and every
293
+ // later write carries it forward, while `mapCrmObject` above sets it FALSE on anything a pull
294
+ // observed. Without this guard the archive branch below would fire
295
+ // `DELETE /crm/v3/objects/<type>/<a twin-minted id>` at the operator's real portal — the
296
+ // exact class of defect groq's round-two review found, one twin-minted id fired at a live
297
+ // account.
298
+ const addressable = fields.hsLocalMint !== true && hsId !== null;
299
+ if (fields.archived === true) {
300
+ if (!addressable) return null;
301
+ return { method: 'DELETE', path: `/crm/v3/objects/${objectType}/${hsId}` };
302
+ }
303
+ // Strip the system properties HubSpot mints itself — sending them back is rejected upstream.
304
+ const payload: Record<string, unknown> = {};
305
+ for (const [k, v] of Object.entries(props)) {
306
+ if (k === 'hs_object_id' || k === 'createdate' || k === 'lastmodifieddate' || k === 'hs_lastmodifieddate') continue;
307
+ payload[k] = v;
308
+ }
309
+ if (addressable) return { method: 'PATCH', path: `/crm/v3/objects/${objectType}/${hsId}`, body: { properties: payload } };
310
+ return { method: 'POST', path: `/crm/v3/objects/${objectType}`, body: { properties: payload } };
311
+ }
312
+
313
+ if (action.subject.type === 'property') {
314
+ const objectType = String(fields.objectType ?? '');
315
+ const name = String(fields.name ?? '');
316
+ if (!objectType || !name) return null;
317
+ if (fields.archived === true) return { method: 'DELETE', path: `/crm/v3/properties/${objectType}/${name}` };
318
+ return {
319
+ method: 'POST', path: `/crm/v3/properties/${objectType}`,
320
+ body: { name, label: fields.label, type: fields.propType, fieldType: fields.fieldType, groupName: fields.groupName, description: fields.description ?? '' },
321
+ };
322
+ }
323
+
324
+ if (action.subject.type === 'property_group') {
325
+ const objectType = String(fields.objectType ?? '');
326
+ const name = String(fields.name ?? '');
327
+ if (!objectType || !name) return null;
328
+ if (fields.archived === true) return { method: 'DELETE', path: `/crm/v3/properties/${objectType}/groups/${name}` };
329
+ return { method: 'POST', path: `/crm/v3/properties/${objectType}/groups`, body: { name, label: fields.label, displayOrder: fields.displayOrder ?? -1 } };
330
+ }
331
+
332
+ if (action.subject.type === 'pipeline') {
333
+ const objectType = String(fields.objectType ?? '');
334
+ if (!objectType) return null;
335
+ // Pipeline ids are minted locally (`pipeline_N`) and name nothing upstream, so a local
336
+ // pipeline is only ever a CREATE; an archive of one has no real target to address.
337
+ if (fields.archived === true) return null;
338
+ return { method: 'POST', path: `/crm/v3/pipelines/${objectType}`, body: { label: fields.label, displayOrder: fields.displayOrder ?? 0, stages: fields.stages ?? [] } };
339
+ }
340
+
341
+ // Associations address BOTH sides by id, so the same locally-minted-id rule applies: a twin
342
+ // association between two locally created records names nothing in the real portal.
343
+ if (action.subject.type === 'association') return null;
344
+ return null;
345
+ }
346
+
347
+ /** PUSH every pending local action to the real portal; confirm each on success. */
348
+ export async function pushPendingHubspotActions(execute: HubspotExecute, root?: string, occurredAt?: string): Promise<number> {
349
+ const pending = deployableEntries(SERVICE, root);
350
+ const at = occurredAt ?? pollTimestamp();
351
+ let pushed = 0;
352
+ for (const action of pending) {
353
+ const req = hubspotRequestForAction(action);
354
+ // An action with no upstream representation is SKIPPED, never aborted — but it is also never
355
+ // confirmed, so it stays pending and visible rather than silently disappearing.
356
+ if (!req) continue;
357
+ const res = await execute(req.method, req.path, req.body);
358
+ const body = (res.body ?? {}) as HubspotErrorBody;
359
+ const succeeded = res.httpStatus >= 200 && res.httpStatus < 300 && body.status !== 'error';
360
+ if (!succeeded) continue;
361
+ confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: (action.fields ?? {}) as Record<string, unknown>, occurredAt: at, ...(root !== undefined ? { root } : {}) });
362
+ pushed += 1;
363
+ }
364
+ return pushed;
365
+ }
366
+
367
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
368
+
369
+ /** A `HubspotExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
370
+ * credential over these headers (executor.ts); at the twin's own wire any credential is one. */
371
+ export function hubspotExecuteOver(execute: RemoteExecute): HubspotExecute {
372
+ return async (method, path, body) => {
373
+ const res = await execute({
374
+ method, path,
375
+ headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer twin' },
376
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
377
+ });
378
+ let parsed: unknown = {};
379
+ try { parsed = JSON.parse(res.body || '{}'); } catch { parsed = {}; }
380
+ return { httpStatus: res.status, body: parsed };
381
+ };
382
+ }
383
+
384
+ /** The refresh adapter: HubSpot enumerates every CRM object type this twin holds, so the whole
385
+ * portal comes back without the world having to say what it has. */
386
+ export async function syncHubspotFromRemote(
387
+ execute: RemoteExecute,
388
+ opts: { root?: string; origin?: string; occurredAt?: string } = {},
389
+ ): Promise<{ observed: number; deltasAppended: number }> {
390
+ return syncHubspotFromReal(hubspotExecuteOver(execute), {
391
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
392
+ occurredAt: opts.occurredAt ?? pollTimestamp(),
393
+ });
394
+ }
395
+
396
+ /**
397
+ * The perform adapter.
398
+ *
399
+ * `hubspotRequestForAction` already carries this pack's hard-won rule: a record stamped
400
+ * `hsLocalMint` has never been seen by the portal, so it CREATES rather than patching an id that
401
+ * account does not have. Protocol 2 adds the other half — `ctx.resolve` gives the id HubSpot minted
402
+ * for a subject this world already deployed, so the patch addresses the real record.
403
+ */
404
+ export async function performHubspotAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
405
+ const req = hubspotRequestForAction(action);
406
+ if (!req) {
407
+ const op = action.operation ?? `${action.subject.type}.update`;
408
+ return { externalId: action.subject.id, data: { performed: false, reason: `${op} has no HubSpot mapping — it is this world's own record` } };
409
+ }
410
+ const resolved = ctx.resolve(action.subject.type, action.subject.id);
411
+ const path = resolved === action.subject.id ? req.path : req.path.replace(new RegExp(`/${action.subject.id}(?=$|[/?])`), `/${resolved}`);
412
+ const answered = await hubspotExecuteOver(execute)(req.method, path, req.body);
413
+ if (answered.httpStatus < 200 || answered.httpStatus >= 300) {
414
+ throw new Error(`hubspot ${req.method} ${path} refused: HTTP ${answered.httpStatus} ${JSON.stringify(answered.body).slice(0, 200)}`);
415
+ }
416
+ const body = (answered.body ?? {}) as Record<string, unknown>;
417
+ const externalId = typeof body.id === 'string' ? body.id : resolved;
418
+ return { externalId, data: body };
419
+ }
@@ -0,0 +1,99 @@
1
+ // THE OTHER HALF OF HUBSPOT'S DENOMINATOR — split, deliberately, into two very different things.
2
+ //
3
+ // `api.hubapi.com` fronts eleven top-level API families and 936 generated operations in
4
+ // @hubspot/api-client 14.0.1. This twin's scope is the CRM (see hubspot-areas.ts), and the rest of
5
+ // the host is split here into two lists that read differently even though both are `todo`:
6
+ //
7
+ // HUBSPOT_UNMODELED_CRM_CAPABILITIES — CRM surface this twin has not built. Engagements,
8
+ // commerce, custom-object schemas, lists, imports/exports and extensions all ride the same
9
+ // /crm/ routes this twin already serves.
10
+ //
11
+ // HUBSPOT_DEFERRED_AREA_CAPABILITIES — separate HubSpot PRODUCTS behind the same host, each
12
+ // with its own object model and no handler here yet.
13
+ //
14
+ // Three things this file is deliberately NOT:
15
+ // • It is not denominator padding. Every entry names a SPECIFIC operation HubSpot really
16
+ // exposes, taken from the generated request factory that issues it, at the same granularity
17
+ // the modeled areas use.
18
+ // • It is not a promise dressed as coverage. Every deferred entry is a `todo`: nothing answers
19
+ // for it today, and `hubspot-areas.ts` records why the area is a separate product.
20
+ // • It is not unchecked. `hubspot.deferred.area_routes_refused` in the main manifest drives a
21
+ // real request at one route from each DEFERRED family and asserts the twin REFUSES it in
22
+ // HubSpot's own error envelope — never a fabricated success. It probes no /crm/ route,
23
+ // because the CRM is this pack's own declared scope.
24
+ import type { CapabilitySpec } from '@volter/world-tooling';
25
+
26
+ /** Not built yet. A plain todo. */
27
+ const unbuilt = (id: string, area: string, title: string, tier: CapabilitySpec['tier']): CapabilitySpec => ({
28
+ id, area, title, dimension: 'api', tier, expected: 'todo',
29
+ });
30
+
31
+ /** CRM surface this twin has not built yet — inside the declared scope. */
32
+ export const HUBSPOT_UNMODELED_CRM_CAPABILITIES: CapabilitySpec[] = [
33
+ // ── CRM engagements ───────────────────────────────────────────────────────────────────────
34
+ unbuilt('hubspot.engagements.notes', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/notes — note engagements', 'common'),
35
+ unbuilt('hubspot.engagements.tasks', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/tasks — task engagements', 'common'),
36
+ unbuilt('hubspot.engagements.calls', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/calls — call engagements', 'common'),
37
+ unbuilt('hubspot.engagements.emails', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/emails — logged email engagements', 'common'),
38
+ unbuilt('hubspot.engagements.meetings', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/meetings — meeting engagements', 'common'),
39
+ unbuilt('hubspot.engagements.leads', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/leads — the leads object', 'niche'),
40
+ unbuilt('hubspot.engagements.feedback_submissions', 'crm-engagements', 'GET /crm/v3/objects/feedback_submissions — NPS/CSAT submissions (read-only)', 'niche'),
41
+
42
+ // ── CRM commerce ──────────────────────────────────────────────────────────────────────────
43
+ unbuilt('hubspot.commerce.products', 'crm-commerce', 'POST/GET/PATCH/DELETE /crm/v3/objects/products — the product library', 'common'),
44
+ unbuilt('hubspot.commerce.line_items', 'crm-commerce', 'POST/GET/PATCH/DELETE /crm/v3/objects/line_items — deal line items', 'common'),
45
+ unbuilt('hubspot.commerce.quotes', 'crm-commerce', 'POST/GET/PATCH/DELETE /crm/v3/objects/quotes — quotes and their approval state', 'niche'),
46
+ unbuilt('hubspot.commerce.invoices', 'crm-commerce', 'GET /crm/v3/objects/invoices — invoices (read-only)', 'niche'),
47
+ unbuilt('hubspot.commerce.deal_splits', 'crm-commerce', 'POST /crm/v3/objects/deals/splits/batch/upsert — deal revenue splits', 'niche'),
48
+
49
+ // ── CRM custom object schemas ─────────────────────────────────────────────────────────────
50
+ unbuilt('hubspot.schemas.create', 'crm-schemas', 'POST /crm-object-schemas/v3/schemas — define a custom object type', 'common'),
51
+ unbuilt('hubspot.schemas.list', 'crm-schemas', 'GET /crm-object-schemas/v3/schemas — list the portal\'s object type definitions', 'common'),
52
+ unbuilt('hubspot.schemas.custom_object_records', 'crm-schemas', 'CRUD on /crm/v3/objects/{objectTypeId} records for a custom type (2-3453932 / p{portalId}_name)', 'common'),
53
+
54
+ // ── CRM lists ─────────────────────────────────────────────────────────────────────────────
55
+ unbuilt('hubspot.lists.create', 'crm-lists', 'POST /crm/v3/lists — create a static or dynamic list', 'common'),
56
+ unbuilt('hubspot.lists.memberships', 'crm-lists', 'GET/PUT/DELETE /crm/v3/lists/{listId}/memberships — list membership', 'common'),
57
+ unbuilt('hubspot.lists.search', 'crm-lists', 'POST /crm/v3/lists/search — search list definitions', 'niche'),
58
+
59
+ // ── CRM imports / exports ─────────────────────────────────────────────────────────────────
60
+ unbuilt('hubspot.imports.create', 'crm-imports-exports', 'POST /crm/v3/imports — start a bulk CSV import job', 'niche'),
61
+ unbuilt('hubspot.imports.status', 'crm-imports-exports', 'GET /crm/v3/imports/{importId} — poll an import job', 'niche'),
62
+ unbuilt('hubspot.exports.create', 'crm-imports-exports', 'POST /crm/v3/exports/export/async — start a bulk export job', 'niche'),
63
+
64
+ // ── CRM extensions ────────────────────────────────────────────────────────────────────────
65
+ unbuilt('hubspot.extensions.cards', 'crm-extensions', 'POST/GET/PATCH/DELETE /crm/v3/extensions/cards-dev/{appId} — CRM card definitions', 'niche'),
66
+ unbuilt('hubspot.extensions.calling', 'crm-extensions', 'GET/POST /crm/v3/extensions/calling/{appId}/settings — calling provider registration', 'niche'),
67
+ unbuilt('hubspot.extensions.videoconferencing', 'crm-extensions', 'PUT /crm/v3/extensions/videoconferencing/settings/{appId}', 'niche'),
68
+ unbuilt('hubspot.extensions.timeline', 'crm-extensions', 'POST /integrators/timeline/v3/events — timeline event ingest + templates', 'niche'),
69
+
70
+ ];
71
+
72
+ /** Separate HubSpot PRODUCTS behind the same host — outside this pack's declared CRM scope, and
73
+ * not built here; `hubspot-areas.ts` records why each is a separate product. */
74
+ export const HUBSPOT_DEFERRED_AREA_CAPABILITIES: CapabilitySpec[] = [
75
+ // ── CMS ───────────────────────────────────────────────────────────────────────────────────
76
+ unbuilt('hubspot.cms.pages', 'cms', 'CRUD on /cms/v3/pages/site-pages and /landing-pages', 'niche'),
77
+ unbuilt('hubspot.cms.blog_posts', 'cms', 'CRUD on /cms/v3/blogs/posts', 'niche'),
78
+ unbuilt('hubspot.cms.hubdb', 'cms', 'CRUD on /cms/v3/hubdb/tables and their rows', 'niche'),
79
+ unbuilt('hubspot.cms.url_redirects', 'cms', 'CRUD on /cms/v3/url-redirects', 'niche'),
80
+ unbuilt('hubspot.cms.source_code', 'cms', 'GET/PUT/DELETE /cms/v3/source-code/{environment}/content/{path}', 'niche'),
81
+ unbuilt('hubspot.cms.domains', 'cms', 'GET /cms/v3/domains — connected domains', 'niche'),
82
+
83
+ // ── Marketing ─────────────────────────────────────────────────────────────────────────────
84
+ unbuilt('hubspot.marketing.emails', 'marketing', 'CRUD on /marketing/v3/emails — marketing email assets', 'niche'),
85
+ unbuilt('hubspot.marketing.forms', 'marketing', 'CRUD on /marketing/v3/forms — HubSpot forms', 'common'),
86
+ unbuilt('hubspot.marketing.events', 'marketing', 'CRUD on /marketing/v3/marketing-events — marketing event objects', 'niche'),
87
+ unbuilt('hubspot.marketing.transactional_send', 'marketing', 'POST /marketing/v3/transactional/single-email/send — send a real transactional email', 'niche'),
88
+
89
+ // ── the remaining families, one representative operation each ─────────────────────────────
90
+ unbuilt('hubspot.automation.custom_actions', 'automation', 'CRUD on /automation/v4/actions/{appId} — custom workflow action definitions', 'niche'),
91
+ unbuilt('hubspot.conversations.visitor_identification', 'conversations', 'POST /conversations/v3/visitor-identification/tokens/create', 'niche'),
92
+ unbuilt('hubspot.files.upload', 'files', 'POST /files/v3/files — upload a file to the file manager', 'niche'),
93
+ unbuilt('hubspot.analytics.send_event', 'analytics-events', 'POST /events/v3/send — send a custom behavioral event', 'niche'),
94
+ unbuilt('hubspot.analytics.event_definitions', 'analytics-events', 'CRUD on /events/v3/event-definitions', 'niche'),
95
+ unbuilt('hubspot.communication_preferences.status', 'communication-preferences', 'GET/POST /communication-preferences/v3/status/email/{emailAddress}', 'niche'),
96
+ unbuilt('hubspot.settings.users', 'settings', 'CRUD on /settings/v3/users — portal user seats', 'niche'),
97
+ unbuilt('hubspot.settings.business_units', 'settings', 'GET /settings/v3/business-units/user/{userId}', 'niche'),
98
+ unbuilt('hubspot.webhooks.subscriptions', 'webhooks', 'CRUD on /webhooks/v3/{appId}/subscriptions — app event subscriptions', 'niche'),
99
+ ];
@@ -0,0 +1,104 @@
1
+ // HUBSPOT UI JOURNEY — the mirror-navigability rung, driven by real headless Playwright
2
+ // chromium with role/text locators only.
3
+ //
4
+ // Seeds records through the pack's OWN write path (the real vendor endpoints,
5
+ // `POST /crm/v3/objects/{objectType}` — the same handler every other suite uses, never a
6
+ // hand-written events.jsonl), boots the real mirror server on an ephemeral port, and drives it.
7
+ //
8
+ // Proves: the CRM opens on Contacts showing both seeded contacts; clicking a nav item (a real
9
+ // <button>, role-reachable) switches the section and the previous section's records are GONE
10
+ // from the DOM (a real re-fetch, not a hidden panel); and the Deals section's Board toggle
11
+ // renders the seeded deals as cards grouped into their stage columns — the screen a sales rep
12
+ // actually works in.
13
+ //
14
+ // Must never hard-fail on a browserless machine: `requireBrowser` skips cleanly with a loud
15
+ // console line rather than reporting a pass over a rung that ran nothing.
16
+ import { describe, test } from 'bun:test';
17
+ import { JOURNEY_TIMEOUT_MS, gone, requireBrowser, runUiJourney, visible } from '@volter/world-tooling';
18
+ import { createHubspotMirrorServer } from './hubspot-mirror-ui.ts';
19
+ import { handleHubspotTwinRequest } from './hubspot-twin.ts';
20
+
21
+ const JOURNEY_LABEL = 'hubspot UI journey';
22
+
23
+ async function seedCrm(root: string) {
24
+ const create = (objectType: string, properties: Record<string, string>, at: string) =>
25
+ handleHubspotTwinRequest({ method: 'POST', path: `/crm/v3/objects/${objectType}`, body: JSON.stringify({ properties }), root, occurredAt: at });
26
+ await create('contacts', { email: 'ada@journey.test', firstname: 'Ada', lastname: 'JourneyContact' }, '2026-05-01T00:00:00.000Z');
27
+ await create('contacts', { email: 'bo@journey.test', firstname: 'Bo', lastname: 'SecondContact' }, '2026-05-01T00:00:01.000Z');
28
+ await create('companies', { name: 'JourneyCompany Ltd', domain: 'journey.test' }, '2026-05-01T00:00:02.000Z');
29
+ await create('deals', { dealname: 'JourneyWonDeal', amount: '7400', dealstage: 'closedwon' }, '2026-05-01T00:00:03.000Z');
30
+ await create('deals', { dealname: 'JourneyOpenDeal', amount: '1150', dealstage: 'qualifiedtobuy' }, '2026-05-01T00:00:04.000Z');
31
+ }
32
+
33
+ const serve = async (root: string) => {
34
+ const server = await createHubspotMirrorServer({ root, port: 0 });
35
+ return { url: server.url, stop: () => server.stop() };
36
+ };
37
+
38
+ describe('hubspot UI journey', () => {
39
+ test('the CRM opens on Contacts and the nav switches sections against live twin data', async () => {
40
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
41
+
42
+ await runUiJourney({
43
+ label: 'hubspot the CRM opens on Contacts and the nav switches sections',
44
+ seed: seedCrm,
45
+ serve,
46
+ journey: async (page) => {
47
+ // 1. The default section is Contacts, and BOTH seeded contacts are on screen — data
48
+ // that only exists because the seed above wrote it through the vendor's own API.
49
+ await visible(page, 'Ada JourneyContact');
50
+ await visible(page, 'Bo SecondContact');
51
+ await visible(page, 'ada@journey.test');
52
+ // A company seeded in the same root is NOT on the contacts screen.
53
+ await gone(page, 'JourneyCompany Ltd');
54
+
55
+ // 2. Click the Companies nav item — the company appears and the contacts UNMOUNT,
56
+ // which is what distinguishes a real re-fetch from a hidden panel.
57
+ await page.getByRole('button', { name: /^Companies$/ }).click();
58
+ await visible(page, 'JourneyCompany Ltd');
59
+ await visible(page, 'journey.test');
60
+ await gone(page, 'Ada JourneyContact');
61
+
62
+ // 3. Click Deals — both deals render, with the mirror's own money formatting.
63
+ await page.getByRole('button', { name: /^Deals$/ }).click();
64
+ await visible(page, 'JourneyWonDeal');
65
+ await visible(page, 'JourneyOpenDeal');
66
+ await visible(page, '$7,400');
67
+ await gone(page, 'JourneyCompany Ltd');
68
+ },
69
+ });
70
+ }, JOURNEY_TIMEOUT_MS);
71
+
72
+ test('the deal board groups the seeded deals into their pipeline stage columns', async () => {
73
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
74
+
75
+ await runUiJourney({
76
+ label: 'hubspot the deal board groups the seeded deals into stage columns',
77
+ seed: seedCrm,
78
+ serve,
79
+ journey: async (page) => {
80
+ await page.getByRole('button', { name: /^Deals$/ }).click();
81
+ await visible(page, 'JourneyWonDeal');
82
+
83
+ // The Board toggle only exists on the Deals section — click it and the same live rows
84
+ // re-render as cards under their dealstage columns.
85
+ await page.getByRole('button', { name: /^Board$/ }).click();
86
+ await visible(page, 'closedwon');
87
+ await visible(page, 'qualifiedtobuy');
88
+ await visible(page, 'JourneyWonDeal');
89
+ await visible(page, 'JourneyOpenDeal');
90
+ await visible(page, '$1,150');
91
+
92
+ // …and back to the table view, which the board REPLACED rather than overlaid. The check
93
+ // is a landmark only the table renders — its `Record ID` column header — because a text
94
+ // check on a stage name proves nothing here: `closedwon` is rendered in BOTH views (the
95
+ // table shows `dealstage` as a column) and `closedlost` is seeded in NEITHER, so the
96
+ // original assertion could not fail in either direction (§9 round two).
97
+ await gone(page, 'Record ID');
98
+ await page.getByRole('button', { name: /^Table$/ }).click();
99
+ await visible(page, 'JourneyWonDeal');
100
+ await visible(page, 'Record ID');
101
+ },
102
+ });
103
+ }, JOURNEY_TIMEOUT_MS);
104
+ });