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