@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,1558 @@
1
+ // HubSpot twin REQUEST HANDLER — the CRM v3/v4 API surface this twin models.
2
+ // Contract: handleHubspotTwinRequest({ method, path, body }) -> { status, body, headers }.
3
+ // Backed by the @volter/world-core event/action-log kernel; response SHAPES are taken from the
4
+ // GENERATED types in `@hubspot/api-client` (lib/codegen/crm/**), which is the oracle here:
5
+ // SimplePublicObject, SimplePublicObjectWithAssociations, CollectionResponse*ForwardPaging,
6
+ // BatchResponseSimplePublicObject, Property, PropertyGroup, Pipeline, PipelineStage,
7
+ // PublicOwner, LabelsBetweenObjectPair, MultiAssociatedObjectWithLabel — plus the documented
8
+ // error envelope { status:'error', message, correlationId, category, errors? }.
9
+ // HTTP wrapper: hubspot-server.ts -> createHubspotTwinServer.
10
+ //
11
+ // State lives in the action log (R18): writes are local actions, reads fold the projection.
12
+ // No real HubSpot is ever called. Auth is a faked-locally Bearer private-app token; there is
13
+ // no OAuth exchange and no signature verification.
14
+ //
15
+ // ── THREE FIDELITY NOTES THAT ARE EASY TO GET WRONG ───────────────────────────────────────
16
+ // 1. The kernel's META set silently DROPS resource fields named `type`, `id` or `updatedAt`
17
+ // (control-plane/src/actions.ts). HubSpot's record carries ALL THREE of `id`/`createdAt`/
18
+ // `updatedAt`, so every stored record renames them (`hsId`, `hsCreatedAt`, `hsUpdatedAt`)
19
+ // and `renderObject` maps them back. A stored field literally named `id` would vanish with
20
+ // no error.
21
+ // 2. `correlationId` is DERIVED, not random. Serve-path determinism (CLAUDE.md) requires a
22
+ // served response to be a pure function of (request, stored state) and byte-identical on
23
+ // replay, so the twin hashes (method, pathname, status, message) into a UUID-shaped id
24
+ // instead of minting entropy. Real HubSpot mints a fresh one per request; a twin that did
25
+ // the same would not be replayable.
26
+ // 3. Record ids are minted as MAX(existing numeric id) + 1 over the WHOLE projection for that
27
+ // object type, archived rows included — never a row count. A count-mint reuses an archived
28
+ // record's id after a DELETE and silently clobbers it on the next create.
29
+ // The mint also starts from a NAMESPACED base far above HubSpot's own id range, which is what
30
+ // makes the OTHER collision direction impossible rather than merely unlikely: `max + 1` keeps
31
+ // a local id clear of every id a pull has ALREADY observed, but says nothing about a pull that
32
+ // arrives LATER — and `mapCrmObject` keys its subject as `<objectType>_<portal id>`, so a
33
+ // portal record numbered 1001 would fold straight onto a locally minted 1001, replacing its
34
+ // properties. datadog's `EVENT_ID_BASE` is the precedent (§9). `mapCrmObject` additionally
35
+ // stamps `hsLocalMint: false`, so if such a collision ever did occur the OBSERVED row wins the
36
+ // addressability question and later edits push as a PATCH rather than a duplicate CREATE.
37
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
38
+ import { currentPortal, scopedId, TWIN_HUB_ID, unscoped } from "./portal.js";
39
+ import { ownersOf } from "./accounts.js";
40
+ import { announceWrite } from "./webhooks.js";
41
+ import { createHash } from 'node:crypto';
42
+ const SERVICE = 'hubspot';
43
+ // ── Rate-limit response headers ────────────────────────────────────────────────────────────
44
+ // HubSpot documents X-HubSpot-RateLimit-Daily / -Daily-Remaining / -Interval-Milliseconds /
45
+ // -Max / -Remaining on every response (developers.hubspot.com/docs/developer-tooling/platform/
46
+ // usage-guidelines, read 2026-08-31). The twin serves the three POLICY headers with the
47
+ // documented Professional-tier figures — 190 requests per 10 000 ms, 625 000 per day — and
48
+ // deliberately does NOT serve the two `-Remaining` COUNTERS: a truthful remaining count is a
49
+ // wall-clock-metered account-wide number that no local twin observes, and fabricating one would
50
+ // break serve-path determinism (the same request would answer differently on replay) while
51
+ // telling the caller a number that is not true. This is the figma precedent applied to a header
52
+ // the vendor DOES document: publish what is a fact, refuse to invent what is not.
53
+ // `hubspot.protocol.rate_limit_remaining_headers` carries that refusal as a filed todo.
54
+ export const HUBSPOT_RATE_LIMIT_HEADERS = Object.freeze({
55
+ 'x-hubspot-ratelimit-max': '190',
56
+ 'x-hubspot-ratelimit-interval-milliseconds': '10000',
57
+ 'x-hubspot-ratelimit-daily': '625000',
58
+ });
59
+ // ── Object types ───────────────────────────────────────────────────────────────────────────
60
+ // The four standard CRM objects this twin models, with the numeric objectTypeIds HubSpot also
61
+ // accepts in the {objectType} path segment.
62
+ export const OBJECT_TYPES = ['contacts', 'companies', 'deals', 'tickets'];
63
+ const OBJECT_TYPE_IDS = {
64
+ '0-1': 'contacts', '0-2': 'companies', '0-3': 'deals', '0-5': 'tickets',
65
+ };
66
+ /** Singular form HubSpot uses in association payloads (`associations: { companies: … }` is
67
+ * keyed plural, but `AssociatedId.type` reads `company`). */
68
+ const SINGULAR = { contacts: 'contact', companies: 'company', deals: 'deal', tickets: 'ticket' };
69
+ /** The numeric objectTypeId HubSpot reports for each modeled type — what `LabelsBetweenObjectPair`
70
+ * and friends carry in their `fromObjectTypeId`/`toObjectTypeId` fields (NOT the plural name). */
71
+ const OBJECT_TYPE_ID = { contacts: '0-1', companies: '0-2', deals: '0-3', tickets: '0-5' };
72
+ /**
73
+ * CRM object types HubSpot really has and this twin does NOT model — the engagement, commerce and
74
+ * activity families, taken from @hubspot/api-client's own discovery tree (every `{objectType}`
75
+ * path segment under `lib/codegen/crm/**`, minus the four modeled here).
76
+ *
77
+ * These must be refused DIFFERENTLY from a type nobody has. HubSpot answers `GET /crm/v3/objects/
78
+ * wombats` with `Unable to infer object type from: wombats` because it genuinely cannot resolve
79
+ * it — but it resolves `notes` perfectly well, so borrowing that message here would be putting a
80
+ * vendor sentence in the vendor's mouth about a type the vendor knows. This is the ahrefs
81
+ * precedent (`ahrefs-twin.ts`: only an option the endpoint actually DECLARES can be an unmodeled
82
+ * option). A real-but-unmodeled type gets an honest 404 that says the twin does not model it —
83
+ * a refusal, never a fabricated success.
84
+ */
85
+ const KNOWN_UNMODELED_OBJECT_TYPES = new Set([
86
+ 'calls', 'communications', 'emails', 'feedback_submissions', 'goal_targets', 'invoices',
87
+ 'leads', 'line_items', 'meetings', 'notes', 'postal_mail', 'products', 'quotes', 'taxes', 'tasks',
88
+ ]);
89
+ /**
90
+ * The two NON-NAME spellings HubSpot accepts in a `{objectType}` segment, as GRAMMARS rather than
91
+ * as an enumerated id table — because enumerating them would mean writing down numeric ids this
92
+ * repo has no first-party source for, which is the invention the honest-refusal rule exists to
93
+ * stop. §9 round two caught the first version matching names only, so `/crm/v3/objects/0-46`
94
+ * (notes) and `/crm/v3/objects/p2953265_car` (a custom object) still received the vendor's
95
+ * cannot-infer sentence — the alternate spellings of exactly the types the fix was written for.
96
+ *
97
+ * • `objectTypeId` — `<n>-<m>`, e.g. `0-46`, `2-3453932` (the shape `ObjectSchema
98
+ * .objectTypeId` carries).
99
+ * • `fullyQualifiedName` — `p<portalId>_<name>`, e.g. `p2953265_car`.
100
+ *
101
+ * A segment matching either is WELL-FORMED HubSpot addressing, so the twin says it does not model
102
+ * it rather than pretending it cannot parse it.
103
+ */
104
+ const OBJECT_TYPE_ID_FORM = /^\d+-\d+$/;
105
+ const FULLY_QUALIFIED_NAME_FORM = /^p\d+_[a-z0-9_]+$/i;
106
+ /** Refuse an {objectType} segment: honestly for a type HubSpot has or could have, in the vendor's
107
+ * own grammar only for one it genuinely cannot resolve. */
108
+ function refuseObjectType(ctx, raw) {
109
+ const lower = raw.toLowerCase();
110
+ const wellFormed = KNOWN_UNMODELED_OBJECT_TYPES.has(lower)
111
+ || OBJECT_TYPE_ID_FORM.test(lower)
112
+ || FULLY_QUALIFIED_NAME_FORM.test(lower);
113
+ if (wellFormed) {
114
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `this twin models the contacts, companies, deals and tickets object types; it does not model '${raw}'`);
115
+ }
116
+ return fail(ctx, 400, 'VALIDATION_ERROR', `Unable to infer object type from: ${raw}`);
117
+ }
118
+ /** Resolve a {objectType} path segment to a modeled object type, or null. */
119
+ export function resolveObjectType(raw) {
120
+ const lower = raw.toLowerCase();
121
+ if (OBJECT_TYPES.includes(lower))
122
+ return lower;
123
+ return OBJECT_TYPE_IDS[lower] ?? null;
124
+ }
125
+ // The `lastmodifieddate` system property differs on contacts (HubSpot's oldest object) from
126
+ // every other CRM object, which use `hs_lastmodifieddate`.
127
+ const LAST_MODIFIED_PROP = {
128
+ contacts: 'lastmodifieddate', companies: 'hs_lastmodifieddate', deals: 'hs_lastmodifieddate', tickets: 'hs_lastmodifieddate',
129
+ };
130
+ const DEFAULT_PROPERTIES = {
131
+ contacts: [
132
+ { name: 'email', label: 'Email', type: 'string', fieldType: 'text', groupName: 'contactinformation' },
133
+ { name: 'firstname', label: 'First Name', type: 'string', fieldType: 'text', groupName: 'contactinformation' },
134
+ { name: 'lastname', label: 'Last Name', type: 'string', fieldType: 'text', groupName: 'contactinformation' },
135
+ { name: 'phone', label: 'Phone Number', type: 'string', fieldType: 'phonenumber', groupName: 'contactinformation' },
136
+ { name: 'company', label: 'Company Name', type: 'string', fieldType: 'text', groupName: 'contactinformation' },
137
+ { name: 'lifecyclestage', label: 'Lifecycle Stage', type: 'enumeration', fieldType: 'radio', groupName: 'contactinformation' },
138
+ { name: 'hubspot_owner_id', label: 'Contact owner', type: 'enumeration', fieldType: 'select', groupName: 'contactinformation' },
139
+ { name: 'createdate', label: 'Create Date', type: 'datetime', fieldType: 'date', groupName: 'contactinformation' },
140
+ { name: 'lastmodifieddate', label: 'Last Modified Date', type: 'datetime', fieldType: 'date', groupName: 'contactinformation' },
141
+ { name: 'hs_object_id', label: 'Record ID', type: 'number', fieldType: 'number', groupName: 'contactinformation' },
142
+ ],
143
+ companies: [
144
+ { name: 'name', label: 'Name', type: 'string', fieldType: 'text', groupName: 'companyinformation' },
145
+ { name: 'domain', label: 'Company Domain Name', type: 'string', fieldType: 'text', groupName: 'companyinformation' },
146
+ { name: 'city', label: 'City', type: 'string', fieldType: 'text', groupName: 'companyinformation' },
147
+ { name: 'industry', label: 'Industry', type: 'enumeration', fieldType: 'select', groupName: 'companyinformation' },
148
+ { name: 'phone', label: 'Phone Number', type: 'string', fieldType: 'phonenumber', groupName: 'companyinformation' },
149
+ { name: 'hubspot_owner_id', label: 'Company owner', type: 'enumeration', fieldType: 'select', groupName: 'companyinformation' },
150
+ { name: 'createdate', label: 'Create Date', type: 'datetime', fieldType: 'date', groupName: 'companyinformation' },
151
+ { name: 'hs_lastmodifieddate', label: 'Last Modified Date', type: 'datetime', fieldType: 'date', groupName: 'companyinformation' },
152
+ { name: 'hs_object_id', label: 'Record ID', type: 'number', fieldType: 'number', groupName: 'companyinformation' },
153
+ ],
154
+ deals: [
155
+ { name: 'dealname', label: 'Deal Name', type: 'string', fieldType: 'text', groupName: 'dealinformation' },
156
+ { name: 'amount', label: 'Amount', type: 'number', fieldType: 'number', groupName: 'dealinformation' },
157
+ { name: 'dealstage', label: 'Deal Stage', type: 'enumeration', fieldType: 'radio', groupName: 'dealinformation' },
158
+ { name: 'pipeline', label: 'Pipeline', type: 'enumeration', fieldType: 'radio', groupName: 'dealinformation' },
159
+ { name: 'closedate', label: 'Close Date', type: 'datetime', fieldType: 'date', groupName: 'dealinformation' },
160
+ { name: 'hubspot_owner_id', label: 'Deal owner', type: 'enumeration', fieldType: 'select', groupName: 'dealinformation' },
161
+ { name: 'createdate', label: 'Create Date', type: 'datetime', fieldType: 'date', groupName: 'dealinformation' },
162
+ { name: 'hs_lastmodifieddate', label: 'Last Modified Date', type: 'datetime', fieldType: 'date', groupName: 'dealinformation' },
163
+ { name: 'hs_object_id', label: 'Record ID', type: 'number', fieldType: 'number', groupName: 'dealinformation' },
164
+ ],
165
+ tickets: [
166
+ { name: 'subject', label: 'Ticket name', type: 'string', fieldType: 'text', groupName: 'ticketinformation' },
167
+ { name: 'content', label: 'Ticket description', type: 'string', fieldType: 'textarea', groupName: 'ticketinformation' },
168
+ { name: 'hs_pipeline', label: 'Pipeline', type: 'enumeration', fieldType: 'select', groupName: 'ticketinformation' },
169
+ { name: 'hs_pipeline_stage', label: 'Ticket status', type: 'enumeration', fieldType: 'select', groupName: 'ticketinformation' },
170
+ { name: 'hs_ticket_priority', label: 'Priority', type: 'enumeration', fieldType: 'select', groupName: 'ticketinformation' },
171
+ { name: 'hubspot_owner_id', label: 'Ticket owner', type: 'enumeration', fieldType: 'select', groupName: 'ticketinformation' },
172
+ { name: 'createdate', label: 'Create Date', type: 'datetime', fieldType: 'date', groupName: 'ticketinformation' },
173
+ { name: 'hs_lastmodifieddate', label: 'Last Modified Date', type: 'datetime', fieldType: 'date', groupName: 'ticketinformation' },
174
+ { name: 'hs_object_id', label: 'Record ID', type: 'number', fieldType: 'number', groupName: 'ticketinformation' },
175
+ ],
176
+ };
177
+ const DEFAULT_PROPERTY_GROUPS = {
178
+ contacts: [{ name: 'contactinformation', label: 'Contact information', displayOrder: -1 }],
179
+ companies: [{ name: 'companyinformation', label: 'Company information', displayOrder: -1 }],
180
+ deals: [{ name: 'dealinformation', label: 'Deal information', displayOrder: -1 }],
181
+ tickets: [{ name: 'ticketinformation', label: 'Ticket information', displayOrder: -1 }],
182
+ };
183
+ // HubSpot's out-of-the-box deal + ticket pipelines. Only these two object types have pipelines.
184
+ const PIPELINE_OBJECT_TYPES = new Set(['deals', 'tickets']);
185
+ const DEFAULT_PIPELINES = {
186
+ deals: {
187
+ id: 'default', label: 'Sales Pipeline', displayOrder: 0,
188
+ stages: [
189
+ { id: 'appointmentscheduled', label: 'Appointment Scheduled', displayOrder: 0, metadata: { isClosed: 'false', probability: '0.2' } },
190
+ { id: 'qualifiedtobuy', label: 'Qualified To Buy', displayOrder: 1, metadata: { isClosed: 'false', probability: '0.4' } },
191
+ { id: 'presentationscheduled', label: 'Presentation Scheduled', displayOrder: 2, metadata: { isClosed: 'false', probability: '0.6' } },
192
+ { id: 'decisionmakerboughtin', label: 'Decision Maker Bought-In', displayOrder: 3, metadata: { isClosed: 'false', probability: '0.8' } },
193
+ { id: 'contractsent', label: 'Contract Sent', displayOrder: 4, metadata: { isClosed: 'false', probability: '0.9' } },
194
+ { id: 'closedwon', label: 'Closed Won', displayOrder: 5, metadata: { isClosed: 'true', probability: '1.0' } },
195
+ { id: 'closedlost', label: 'Closed Lost', displayOrder: 6, metadata: { isClosed: 'true', probability: '0.0' } },
196
+ ],
197
+ },
198
+ tickets: {
199
+ id: '0', label: 'Support Pipeline', displayOrder: 0,
200
+ stages: [
201
+ { id: '1', label: 'New', displayOrder: 0, metadata: { isClosed: 'false', ticketState: 'OPEN' } },
202
+ { id: '2', label: 'Waiting on contact', displayOrder: 1, metadata: { isClosed: 'false', ticketState: 'OPEN' } },
203
+ { id: '3', label: 'Waiting on us', displayOrder: 2, metadata: { isClosed: 'false', ticketState: 'OPEN' } },
204
+ { id: '4', label: 'Closed', displayOrder: 3, metadata: { isClosed: 'true', ticketState: 'CLOSED' } },
205
+ ],
206
+ },
207
+ };
208
+ // The portal's seeded owner — HubSpot's GET /crm/v3/owners.
209
+ const SEED_OWNER = {
210
+ id: '1', email: 'owner@hubspot.twin', firstName: 'Twin', lastName: 'Owner',
211
+ userId: 1, userIdIncludingInactive: 1, type: 'PERSON', archived: false,
212
+ teams: [{ id: '1', name: 'Twin Team', primary: true }],
213
+ };
214
+ // HubSpot-defined DEFAULT association type ids, from the associations v4 reference
215
+ // (developers.hubspot.com/docs/guides/api/crm/associations/associations-v4, read 2026-08-31).
216
+ // Only the pairs this table names have a modeled default; every other pair is refused rather
217
+ // than given a fabricated id (`hubspot.associations.create_default_unmodeled_pair`).
218
+ const DEFAULT_ASSOCIATION_TYPE_IDS = {
219
+ 'contacts>companies': 279, 'companies>contacts': 280,
220
+ 'deals>contacts': 3, 'contacts>deals': 4,
221
+ 'deals>companies': 341, 'companies>deals': 342,
222
+ 'contacts>tickets': 15, 'tickets>contacts': 16,
223
+ };
224
+ // The pinned timestamp a twin write stamps onto a record when the caller supplies none. Kept
225
+ // fixed so a fresh-root create is byte-identical on replay (serve-path determinism).
226
+ const EPOCH = '2026-01-01T00:00:00.000Z';
227
+ /**
228
+ * The floor this twin mints record ids from — NAMESPACED out of HubSpot's own range.
229
+ *
230
+ * HubSpot record ids are opaque numeric strings; the largest this repo has observed in the wild
231
+ * are ~2.7e10 (`26879063901`-shaped), and contact ids in particular are routinely in the low
232
+ * thousands. 9e11 is more than an order of magnitude above the largest of those and far below
233
+ * `Number.MAX_SAFE_INTEGER`, so a locally minted id and a real portal id cannot meet — in EITHER
234
+ * direction. See header note 3.
235
+ */
236
+ export const HUBSPOT_LOCAL_ID_BASE = 900_000_000_000;
237
+ // The closed FilterOperatorEnum from @hubspot/api-client's generated
238
+ // crm/objects/models/Filter.d.ts. A literal allowlist, deliberately NOT derived from anything
239
+ // the handler itself serves — an operator outside this set is a 400, and the bijection between
240
+ // this constant and the generated enum is what makes that refusal an oracle rather than a
241
+ // tautology (§6, "closed parameter set").
242
+ export const SEARCH_FILTER_OPERATORS = [
243
+ 'EQ', 'NEQ', 'LT', 'LTE', 'GT', 'GTE', 'BETWEEN', 'IN', 'NOT_IN',
244
+ 'HAS_PROPERTY', 'NOT_HAS_PROPERTY', 'CONTAINS_TOKEN', 'NOT_CONTAINS_TOKEN',
245
+ ];
246
+ const OPERATOR_SET = new Set(SEARCH_FILTER_OPERATORS);
247
+ /** Documented caps. List: 100/page. Search: 200/page, 10 000 total results, 5 filterGroups of
248
+ * 6 filters (18 filters overall). All from HubSpot's own reference pages, read 2026-08-31. */
249
+ export const LIST_LIMIT_MAX = 100;
250
+ export const SEARCH_LIMIT_MAX = 200;
251
+ export const SEARCH_RESULT_CAP = 10_000;
252
+ export const SEARCH_MAX_FILTER_GROUPS = 5;
253
+ export const SEARCH_MAX_FILTERS_PER_GROUP = 6;
254
+ export const SEARCH_MAX_FILTERS_TOTAL = 18;
255
+ /** A DETERMINISTIC correlation id (see note 2 in the header). Shaped like the UUID real
256
+ * HubSpot returns, derived from the request + outcome so a replay is byte-identical. */
257
+ export function correlationIdFor(seed) {
258
+ const h = createHash('sha256').update(seed).digest('hex');
259
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
260
+ }
261
+ function ok(data, status = 200) {
262
+ return { status, body: data, headers: { ...HUBSPOT_RATE_LIMIT_HEADERS } };
263
+ }
264
+ function noContent() {
265
+ return { status: 204, body: null, headers: { ...HUBSPOT_RATE_LIMIT_HEADERS } };
266
+ }
267
+ function fail(ctx, status, category, message, errors) {
268
+ const correlationId = correlationIdFor(`${ctx.method} ${ctx.pathname} ${status} ${category} ${message}`);
269
+ return {
270
+ status,
271
+ body: { status: 'error', message, correlationId, category, ...(errors && errors.length ? { errors } : {}) },
272
+ headers: { ...HUBSPOT_RATE_LIMIT_HEADERS },
273
+ };
274
+ }
275
+ // ── kernel access ──────────────────────────────────────────────────────────────────────────
276
+ // Every subject of these types belongs to one account (portal.ts): read in the request's account, its id as that account
277
+ // sees it; written under the account.
278
+ /** Every stored subject of a type in the request's account, tombstones included, ids as the account sees them. */
279
+ function rowsAll(type, root) {
280
+ const hub = currentPortal().hub;
281
+ const out = [];
282
+ for (const r of projectResources(SERVICE, root)) {
283
+ if (r.type !== type)
284
+ continue;
285
+ const u = unscoped(String(r.id ?? ''));
286
+ if (u.hub === hub)
287
+ out.push({ ...r, id: u.id });
288
+ }
289
+ return out;
290
+ }
291
+ function rows(type, root) {
292
+ return rowsAll(type, root).filter((r) => r.deleted !== true);
293
+ }
294
+ function byId(type, id, root) {
295
+ return rows(type, root).find((r) => r.id === id);
296
+ }
297
+ async function write(type, id, fields, root, occurredAt) {
298
+ await applyTwinWrite(SERVICE, { operation: `set.${type}`, subjectType: type, subjectId: scopedId(currentPortal().hub, id), fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
299
+ }
300
+ /**
301
+ * Parse a request body. An ARRAY is a legitimate top-level body on HubSpot's v4 association
302
+ * endpoints (`PUT …/associations/{toObjectType}/{toObjectId}` takes `AssociationSpec[]`, per
303
+ * @hubspot/api-client's generated `BasicApi.create`), so arrays are accepted and handed on; a
304
+ * route that expected an object simply finds its required keys absent and answers its own 400.
305
+ * Only genuinely malformed JSON, or a scalar, is refused here.
306
+ */
307
+ function parseBody(body) {
308
+ if (!body)
309
+ return { ok: true, value: {} };
310
+ try {
311
+ const parsed = JSON.parse(body);
312
+ if (parsed === null || typeof parsed !== 'object')
313
+ return { ok: false };
314
+ return { ok: true, value: parsed };
315
+ }
316
+ catch {
317
+ return { ok: false };
318
+ }
319
+ }
320
+ // ── record ids ─────────────────────────────────────────────────────────────────────────────
321
+ /** Kernel subject id for a CRM record. Namespaced by object type so `contacts` 1 and `deals` 1
322
+ * are distinct subjects even though the kernel already resolves by (type, id). */
323
+ function recordKey(objectType, id) {
324
+ return `${objectType}_${id}`;
325
+ }
326
+ /**
327
+ * Mint the next record id: MAX over the numeric ids ALREADY IN STATE for this object type + 1,
328
+ * archived and tombstoned rows included (see header note 3). Never a count — a count reuses an
329
+ * archived id and collides with any larger id a connector pull observed.
330
+ */
331
+ function nextRecordId(objectType, root) {
332
+ const prefix = `${objectType}_`;
333
+ let max = HUBSPOT_LOCAL_ID_BASE;
334
+ for (const r of rowsAll('crm_object', root)) {
335
+ const id = String(r.id ?? '');
336
+ if (!id.startsWith(prefix))
337
+ continue;
338
+ const n = Number(id.slice(prefix.length));
339
+ if (Number.isFinite(n) && n > max)
340
+ max = n;
341
+ }
342
+ return String(max + 1);
343
+ }
344
+ function readRecord(objectType, id, root) {
345
+ const r = byId('crm_object', recordKey(objectType, id), root);
346
+ if (!r)
347
+ return undefined;
348
+ return {
349
+ id: String(r.id), objectType,
350
+ hsId: String(r.hsId), hsCreatedAt: String(r.hsCreatedAt), hsUpdatedAt: String(r.hsUpdatedAt),
351
+ archived: r.archived === true, ...(r.hsArchivedAt ? { hsArchivedAt: String(r.hsArchivedAt) } : {}),
352
+ props: (r.props ?? {}),
353
+ hsLocalMint: r.hsLocalMint === true,
354
+ };
355
+ }
356
+ function allRecords(objectType, root) {
357
+ const prefix = `${objectType}_`;
358
+ return rows('crm_object', root)
359
+ .filter((r) => String(r.id ?? '').startsWith(prefix) && r.objectType === objectType)
360
+ .map((r) => ({
361
+ id: String(r.id), objectType,
362
+ hsId: String(r.hsId), hsCreatedAt: String(r.hsCreatedAt), hsUpdatedAt: String(r.hsUpdatedAt),
363
+ archived: r.archived === true, ...(r.hsArchivedAt ? { hsArchivedAt: String(r.hsArchivedAt) } : {}),
364
+ props: (r.props ?? {}),
365
+ hsLocalMint: r.hsLocalMint === true,
366
+ }))
367
+ .sort((a, b) => Number(a.hsId) - Number(b.hsId));
368
+ }
369
+ /** SimplePublicObject / SimplePublicObjectWithAssociations, per the generated models. */
370
+ function renderObject(rec, opts, unresolved = []) {
371
+ const properties = {};
372
+ const requested = opts.properties;
373
+ const always = ['hs_object_id', 'createdate', LAST_MODIFIED_PROP[rec.objectType]];
374
+ const keys = requested && requested.length
375
+ ? [...new Set([...always, ...requested])]
376
+ : Object.keys(rec.props);
377
+ for (const k of keys)
378
+ properties[k] = rec.props[k] ?? null;
379
+ const out = {
380
+ id: rec.hsId,
381
+ properties,
382
+ createdAt: rec.hsCreatedAt,
383
+ updatedAt: rec.hsUpdatedAt,
384
+ archived: rec.archived,
385
+ ...(rec.archived && rec.hsArchivedAt ? { archivedAt: rec.hsArchivedAt } : {}),
386
+ };
387
+ if (opts.associations && opts.associations.length) {
388
+ const associations = {};
389
+ for (const raw of opts.associations) {
390
+ const to = resolveObjectType(raw);
391
+ // An unmodeled type here used to be skipped, which made `?associations=notes` a SILENT
392
+ // partial success — a 200 whose associations block simply lacked what was asked for, where
393
+ // the path form `…/associations/notes` is an honest 404 (§9 round two). The caller is told
394
+ // instead; `renderObject`'s caller turns this into the same refusal.
395
+ if (!to) {
396
+ unresolved.push(raw);
397
+ continue;
398
+ }
399
+ const results = readAssociations(rec.objectType, rec.hsId, to, opts.root)
400
+ .map((a) => ({ id: a.toId, type: `${SINGULAR[rec.objectType]}_to_${SINGULAR[to]}` }));
401
+ if (results.length)
402
+ associations[to] = { results };
403
+ }
404
+ if (Object.keys(associations).length)
405
+ out.associations = associations;
406
+ }
407
+ return out;
408
+ }
409
+ function seededProperties(objectType) {
410
+ return DEFAULT_PROPERTIES[objectType].map((p) => ({
411
+ ...p, description: '', displayOrder: -1, hidden: false, hubspotDefined: true, formField: false,
412
+ options: [], calculated: false, externalOptions: false, hasUniqueValue: p.name === 'hs_object_id', archived: false,
413
+ }));
414
+ }
415
+ function propertyKey(objectType, name) { return `${objectType}_${name}`; }
416
+ /** All properties for an object type: the seeded HubSpot-defined ones overlaid with any the
417
+ * caller created/updated/archived through the twin's own write path. */
418
+ function listProperties(objectType, root) {
419
+ const merged = new Map();
420
+ for (const p of seededProperties(objectType))
421
+ merged.set(p.name, p);
422
+ const prefix = `${objectType}_`;
423
+ for (const r of rows('property', root)) {
424
+ const id = String(r.id ?? '');
425
+ if (!id.startsWith(prefix) || r.objectType !== objectType)
426
+ continue;
427
+ const name = String(r.name);
428
+ const base = merged.get(name);
429
+ merged.set(name, {
430
+ ...(base ?? { name, label: name, type: 'string', fieldType: 'text', groupName: '', description: '', displayOrder: -1, hidden: false, hubspotDefined: false, formField: false, options: [], calculated: false, externalOptions: false, hasUniqueValue: false, archived: false }),
431
+ ...(r.label !== undefined ? { label: String(r.label) } : {}),
432
+ ...(r.type !== undefined ? { type: String(r.propType ?? r.type) } : {}),
433
+ ...(r.propType !== undefined ? { type: String(r.propType) } : {}),
434
+ ...(r.fieldType !== undefined ? { fieldType: String(r.fieldType) } : {}),
435
+ ...(r.groupName !== undefined ? { groupName: String(r.groupName) } : {}),
436
+ ...(r.description !== undefined ? { description: String(r.description) } : {}),
437
+ ...(r.options !== undefined ? { options: r.options } : {}),
438
+ ...(r.formField !== undefined ? { formField: r.formField === true } : {}),
439
+ ...(r.hasUniqueValue !== undefined ? { hasUniqueValue: r.hasUniqueValue === true } : {}),
440
+ hubspotDefined: base?.hubspotDefined ?? false,
441
+ archived: r.archived === true,
442
+ });
443
+ }
444
+ return [...merged.values()];
445
+ }
446
+ function renderProperty(p) {
447
+ return {
448
+ name: p.name, label: p.label, type: p.type, fieldType: p.fieldType, groupName: p.groupName,
449
+ description: p.description, options: p.options, displayOrder: p.displayOrder, hidden: p.hidden,
450
+ hubspotDefined: p.hubspotDefined, formField: p.formField, calculated: p.calculated,
451
+ externalOptions: p.externalOptions, hasUniqueValue: p.hasUniqueValue, archived: p.archived,
452
+ };
453
+ }
454
+ function listPropertyGroups(objectType, root) {
455
+ const merged = new Map();
456
+ for (const g of DEFAULT_PROPERTY_GROUPS[objectType])
457
+ merged.set(g.name, { ...g, archived: false });
458
+ const prefix = `${objectType}_`;
459
+ for (const r of rows('property_group', root)) {
460
+ const id = String(r.id ?? '');
461
+ if (!id.startsWith(prefix) || r.objectType !== objectType)
462
+ continue;
463
+ const name = String(r.name);
464
+ const base = merged.get(name);
465
+ merged.set(name, {
466
+ name,
467
+ label: r.label !== undefined ? String(r.label) : (base?.label ?? name),
468
+ displayOrder: r.displayOrder !== undefined ? Number(r.displayOrder) : (base?.displayOrder ?? -1),
469
+ archived: r.archived === true,
470
+ });
471
+ }
472
+ return [...merged.values()];
473
+ }
474
+ function pipelineKey(objectType, id) { return `${objectType}_${id}`; }
475
+ function listPipelines(objectType, root) {
476
+ const merged = new Map();
477
+ const seed = DEFAULT_PIPELINES[objectType];
478
+ if (seed)
479
+ merged.set(seed.id, { ...seed, archived: false, createdAt: EPOCH, updatedAt: EPOCH, stageSeq: 0, stages: seed.stages.map((s) => ({ ...s })) });
480
+ const prefix = `${objectType}_`;
481
+ for (const r of rows('pipeline', root)) {
482
+ const id = String(r.id ?? '');
483
+ if (!id.startsWith(prefix) || r.objectType !== objectType)
484
+ continue;
485
+ const pid = String(r.pipelineId);
486
+ const base = merged.get(pid);
487
+ merged.set(pid, {
488
+ id: pid,
489
+ label: r.label !== undefined ? String(r.label) : (base?.label ?? pid),
490
+ displayOrder: r.displayOrder !== undefined ? Number(r.displayOrder) : (base?.displayOrder ?? 0),
491
+ archived: r.archived === true,
492
+ stages: r.stages ?? base?.stages ?? [],
493
+ createdAt: base?.createdAt ?? String(r.hsCreatedAt ?? EPOCH),
494
+ updatedAt: String(r.hsUpdatedAt ?? base?.updatedAt ?? EPOCH),
495
+ stageSeq: r.stageSeq !== undefined ? Number(r.stageSeq) : (base?.stageSeq ?? 0),
496
+ });
497
+ }
498
+ return [...merged.values()].filter((p) => !p.archived);
499
+ }
500
+ function renderPipeline(p) {
501
+ return {
502
+ id: p.id, label: p.label, displayOrder: p.displayOrder, archived: p.archived,
503
+ createdAt: p.createdAt, updatedAt: p.updatedAt,
504
+ stages: p.stages.map((s) => renderStage(s, p)),
505
+ };
506
+ }
507
+ function renderStage(s, p) {
508
+ return { id: s.id, label: s.label, displayOrder: s.displayOrder, metadata: s.metadata, archived: false, createdAt: p.createdAt, updatedAt: p.updatedAt, writePermissions: 'CRM_PERMISSIONS_ENFORCEMENT' };
509
+ }
510
+ function associationKey(fromType, fromId, toType, toId) {
511
+ return `${fromType}:${fromId}>${toType}:${toId}`;
512
+ }
513
+ function readAssociations(fromType, fromId, toType, root) {
514
+ return rows('association', root)
515
+ .filter((r) => r.fromType === fromType && r.fromId === fromId && r.toType === toType)
516
+ .map((r) => ({
517
+ fromType, fromId, toType, toId: String(r.toId),
518
+ labels: r.labels ?? [],
519
+ }))
520
+ .sort((a, b) => Number(a.toId) - Number(b.toId));
521
+ }
522
+ function matchesFilter(rec, f) {
523
+ const raw = rec.props[f.propertyName];
524
+ const present = raw !== undefined && raw !== null && raw !== '';
525
+ switch (f.operator) {
526
+ case 'HAS_PROPERTY': return present;
527
+ case 'NOT_HAS_PROPERTY': return !present;
528
+ case 'EQ': return present && String(raw) === String(f.value);
529
+ case 'NEQ': return !present || String(raw) !== String(f.value);
530
+ case 'LT': return present && numOr(raw) < numOr(f.value);
531
+ case 'LTE': return present && numOr(raw) <= numOr(f.value);
532
+ case 'GT': return present && numOr(raw) > numOr(f.value);
533
+ case 'GTE': return present && numOr(raw) >= numOr(f.value);
534
+ case 'BETWEEN': return present && numOr(raw) >= numOr(f.value) && numOr(raw) <= numOr(f.highValue);
535
+ case 'IN': return present && (f.values ?? []).map(String).includes(String(raw));
536
+ case 'NOT_IN': return !present || !(f.values ?? []).map(String).includes(String(raw));
537
+ case 'CONTAINS_TOKEN': return present && tokenMatch(String(raw), String(f.value ?? ''));
538
+ case 'NOT_CONTAINS_TOKEN': return !present || !tokenMatch(String(raw), String(f.value ?? ''));
539
+ default: return false;
540
+ }
541
+ }
542
+ function numOr(v) {
543
+ const n = Number(v);
544
+ return Number.isFinite(n) ? n : Number.NaN;
545
+ }
546
+ /** HubSpot's CONTAINS_TOKEN is a tokenized match with an optional trailing `*` wildcard. */
547
+ function tokenMatch(haystack, needle) {
548
+ const tokens = haystack.toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean);
549
+ const want = needle.toLowerCase();
550
+ if (want.endsWith('*')) {
551
+ const stem = want.slice(0, -1);
552
+ return tokens.some((t) => t.startsWith(stem));
553
+ }
554
+ return tokens.includes(want);
555
+ }
556
+ // ── the handler ────────────────────────────────────────────────────────────────────────────
557
+ export async function handleHubspotTwinRequest(req) {
558
+ const { method, root, occurredAt } = req;
559
+ const readOnly = req.readOnly ?? false;
560
+ const isWrite = method !== 'GET' && method !== 'HEAD';
561
+ const at = occurredAt ?? EPOCH;
562
+ let url;
563
+ try {
564
+ url = new URL(req.path, 'http://twin.local');
565
+ }
566
+ catch {
567
+ return fail({ method, pathname: req.path }, 400, 'VALIDATION_ERROR', 'invalid request path');
568
+ }
569
+ const ctx = { method, pathname: url.pathname };
570
+ const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
571
+ const query = url.searchParams;
572
+ if (readOnly && isWrite) {
573
+ // NOT a HubSpot category — the twin's own read-only refusal, named so a reader can never
574
+ // mistake it for one of the vendor's documented categories.
575
+ return fail(ctx, 405, 'TWIN_READ_ONLY', 'this twin was started read-only; omit readOnly to accept writes');
576
+ }
577
+ const parsed = parseBody(req.body);
578
+ if (!parsed.ok)
579
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'Invalid input JSON');
580
+ const body = parsed.value;
581
+ // ── /crm/v3/objects/... ──────────────────────────────────────────────────────────────────
582
+ if (parts[0] === 'crm' && parts[1] === 'v3' && parts[2] === 'objects') {
583
+ const rawType = parts[3];
584
+ if (rawType === undefined)
585
+ return notFound(ctx);
586
+ const objectType = resolveObjectType(rawType);
587
+ if (!objectType)
588
+ return refuseObjectType(ctx, rawType);
589
+ // POST /crm/v3/objects/{objectType}
590
+ if (method === 'POST' && parts.length === 4)
591
+ return createRecord(ctx, objectType, body, root, at);
592
+ // GET /crm/v3/objects/{objectType}
593
+ if (method === 'GET' && parts.length === 4)
594
+ return listRecords(ctx, objectType, query, root);
595
+ // POST /crm/v3/objects/{objectType}/search
596
+ if (method === 'POST' && parts.length === 5 && parts[4] === 'search')
597
+ return searchRecords(ctx, objectType, body, root);
598
+ // POST /crm/v3/objects/{objectType}/merge
599
+ if (method === 'POST' && parts.length === 5 && parts[4] === 'merge')
600
+ return mergeRecords(ctx, objectType, body, root, at);
601
+ // POST /crm/v3/objects/{objectType}/batch/{op}
602
+ if (method === 'POST' && parts.length === 6 && parts[4] === 'batch')
603
+ return batchOp(ctx, objectType, parts[5], body, root, at);
604
+ // /crm/v3/objects/{objectType}/{objectId}
605
+ if (parts.length === 5) {
606
+ const id = parts[4];
607
+ if (method === 'GET')
608
+ return getRecord(ctx, objectType, id, query, root);
609
+ if (method === 'PATCH')
610
+ return updateRecord(ctx, objectType, id, body, root, at);
611
+ if (method === 'DELETE')
612
+ return archiveRecord(ctx, objectType, id, root, at);
613
+ }
614
+ return notFound(ctx);
615
+ }
616
+ // ── /crm/v3/properties/{objectType}... ───────────────────────────────────────────────────
617
+ if (parts[0] === 'crm' && parts[1] === 'v3' && parts[2] === 'properties') {
618
+ const rawType = parts[3];
619
+ if (rawType === undefined)
620
+ return notFound(ctx);
621
+ const objectType = resolveObjectType(rawType);
622
+ if (!objectType)
623
+ return refuseObjectType(ctx, rawType);
624
+ // groups FIRST: /properties/{objectType}/groups collides with /{propertyName}
625
+ if (parts[4] === 'groups') {
626
+ if (method === 'GET' && parts.length === 5)
627
+ return ok({ results: listPropertyGroups(objectType, root).filter((g) => !g.archived).map((g) => ({ name: g.name, label: g.label, displayOrder: g.displayOrder, archived: g.archived })) });
628
+ if (method === 'POST' && parts.length === 5)
629
+ return createPropertyGroup(ctx, objectType, body, root, at);
630
+ if (parts.length === 6) {
631
+ const name = parts[5];
632
+ const found = listPropertyGroups(objectType, root).find((g) => g.name === name && !g.archived);
633
+ if (method === 'GET') {
634
+ if (!found)
635
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Group ${name} not found`);
636
+ return ok({ name: found.name, label: found.label, displayOrder: found.displayOrder, archived: found.archived });
637
+ }
638
+ if (method === 'PATCH') {
639
+ if (!found)
640
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Group ${name} not found`);
641
+ const patch = { objectType, name };
642
+ if (body.label !== undefined)
643
+ patch.label = body.label;
644
+ if (body.displayOrder !== undefined)
645
+ patch.displayOrder = body.displayOrder;
646
+ await write('property_group', `${objectType}_${name}`, patch, root, at);
647
+ const after = listPropertyGroups(objectType, root).find((g) => g.name === name);
648
+ return ok({ name: after.name, label: after.label, displayOrder: after.displayOrder, archived: after.archived });
649
+ }
650
+ if (method === 'DELETE') {
651
+ if (!found)
652
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Group ${name} not found`);
653
+ await write('property_group', `${objectType}_${name}`, { objectType, name, archived: true }, root, at);
654
+ return noContent();
655
+ }
656
+ }
657
+ return notFound(ctx);
658
+ }
659
+ if (method === 'GET' && parts.length === 4)
660
+ return ok({ results: listProperties(objectType, root).filter((p) => !p.archived).map(renderProperty) });
661
+ // POST /crm/v3/properties/{objectType}/batch/create — "Create a batch of properties": each of `inputs` created as
662
+ // POST /crm/v3/properties/{objectType} creates one, answered in the batch envelope the record batches use
663
+ if (method === 'POST' && parts.length === 6 && parts[4] === 'batch' && parts[5] === 'create') {
664
+ const inputs = Array.isArray(body.inputs) ? body.inputs : null;
665
+ if (inputs === null)
666
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'inputs must be an array');
667
+ // every input is checked before any is created, so a refused batch changes nothing (the twin's decision: the
668
+ // reference does not say whether a batch is all-or-nothing, and a refusal that left writes behind would lie)
669
+ const seen = new Set();
670
+ for (const input of inputs) {
671
+ const refused = propertyRefusal(ctx, objectType, input, root);
672
+ if (refused)
673
+ return refused;
674
+ if (seen.has(String(input.name)))
675
+ return fail(ctx, 409, 'CONFLICT', `Property ${String(input.name)} already exists`);
676
+ seen.add(String(input.name));
677
+ }
678
+ const results = [];
679
+ for (const input of inputs) {
680
+ const created = await createProperty(ctx, objectType, input, root, at);
681
+ if (created.status !== 201)
682
+ return created;
683
+ results.push(created.body);
684
+ }
685
+ return ok(batchEnvelope(results, at), 201);
686
+ }
687
+ if (method === 'POST' && parts.length === 4)
688
+ return createProperty(ctx, objectType, body, root, at);
689
+ if (parts.length === 5) {
690
+ const name = parts[4];
691
+ const found = listProperties(objectType, root).find((p) => p.name === name && !p.archived);
692
+ if (method === 'GET') {
693
+ if (!found)
694
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Property ${name} not found`);
695
+ return ok(renderProperty(found));
696
+ }
697
+ if (method === 'PATCH') {
698
+ if (!found)
699
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Property ${name} not found`);
700
+ const patch = { objectType, name };
701
+ for (const k of ['label', 'description', 'groupName', 'fieldType', 'options'])
702
+ if (body[k] !== undefined)
703
+ patch[k] = body[k];
704
+ if (body.type !== undefined)
705
+ patch.propType = body.type; // `type` is a kernel META key
706
+ await write('property', propertyKey(objectType, name), patch, root, at);
707
+ return ok(renderProperty(listProperties(objectType, root).find((p) => p.name === name)));
708
+ }
709
+ if (method === 'DELETE') {
710
+ if (!found)
711
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Property ${name} not found`);
712
+ await write('property', propertyKey(objectType, name), { objectType, name, archived: true }, root, at);
713
+ return noContent();
714
+ }
715
+ }
716
+ return notFound(ctx);
717
+ }
718
+ // ── /crm/v3/pipelines/{objectType}... ────────────────────────────────────────────────────
719
+ if (parts[0] === 'crm' && parts[1] === 'v3' && parts[2] === 'pipelines') {
720
+ const rawType = parts[3];
721
+ if (rawType === undefined)
722
+ return notFound(ctx);
723
+ const objectType = resolveObjectType(rawType);
724
+ if (!objectType)
725
+ return refuseObjectType(ctx, rawType);
726
+ if (!PIPELINE_OBJECT_TYPES.has(objectType))
727
+ return fail(ctx, 400, 'VALIDATION_ERROR', `Object type ${objectType} does not support pipelines`);
728
+ if (method === 'GET' && parts.length === 4)
729
+ return ok({ results: listPipelines(objectType, root).map(renderPipeline) });
730
+ if (method === 'POST' && parts.length === 4)
731
+ return createPipeline(ctx, objectType, body, root, at);
732
+ if (parts.length >= 5) {
733
+ const pipelineId = parts[4];
734
+ const pipeline = listPipelines(objectType, root).find((p) => p.id === pipelineId);
735
+ if (parts.length === 5) {
736
+ if (method === 'GET') {
737
+ if (!pipeline)
738
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Pipeline ${pipelineId} not found`);
739
+ return ok(renderPipeline(pipeline));
740
+ }
741
+ if (method === 'PATCH' || method === 'PUT') {
742
+ if (!pipeline)
743
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Pipeline ${pipelineId} not found`);
744
+ if (method === 'PUT' && (body.label === undefined || body.displayOrder === undefined || !Array.isArray(body.stages))) {
745
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'label, displayOrder and stages are required to replace a pipeline');
746
+ }
747
+ const patch = { objectType, pipelineId, hsUpdatedAt: at };
748
+ if (body.label !== undefined)
749
+ patch.label = body.label;
750
+ if (body.displayOrder !== undefined)
751
+ patch.displayOrder = body.displayOrder;
752
+ if (Array.isArray(body.stages)) {
753
+ const staged = normalizeStages(body.stages, pipeline.stageSeq);
754
+ if ('error' in staged)
755
+ return fail(ctx, 400, 'VALIDATION_ERROR', staged.error);
756
+ patch.stages = staged.stages;
757
+ patch.stageSeq = staged.seq;
758
+ }
759
+ await write('pipeline', pipelineKey(objectType, pipelineId), patch, root, at);
760
+ return ok(renderPipeline(listPipelines(objectType, root).find((p) => p.id === pipelineId)));
761
+ }
762
+ if (method === 'DELETE') {
763
+ if (!pipeline)
764
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Pipeline ${pipelineId} not found`);
765
+ await write('pipeline', pipelineKey(objectType, pipelineId), { objectType, pipelineId, archived: true, hsUpdatedAt: at }, root, at);
766
+ return noContent();
767
+ }
768
+ }
769
+ if (parts[5] === 'stages') {
770
+ if (!pipeline)
771
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Pipeline ${pipelineId} not found`);
772
+ if (method === 'GET' && parts.length === 6)
773
+ return ok({ results: pipeline.stages.map((s) => renderStage(s, pipeline)) });
774
+ if (method === 'POST' && parts.length === 6) {
775
+ if (body.label === undefined || body.displayOrder === undefined)
776
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'label and displayOrder are required');
777
+ const seq = pipeline.stageSeq + 1;
778
+ const stage = { id: `stage_${seq}`, label: String(body.label), displayOrder: Number(body.displayOrder), metadata: body.metadata ?? {} };
779
+ await write('pipeline', pipelineKey(objectType, pipelineId), { objectType, pipelineId, label: pipeline.label, displayOrder: pipeline.displayOrder, stages: [...pipeline.stages, stage], stageSeq: seq, hsUpdatedAt: at }, root, at);
780
+ return ok(renderStage(stage, pipeline), 201);
781
+ }
782
+ if (parts.length === 7) {
783
+ const stageId = parts[6];
784
+ const stage = pipeline.stages.find((s) => s.id === stageId);
785
+ if (method === 'GET') {
786
+ if (!stage)
787
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Stage ${stageId} not found`);
788
+ return ok(renderStage(stage, pipeline));
789
+ }
790
+ if (method === 'PATCH' || method === 'PUT') {
791
+ if (!stage)
792
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Stage ${stageId} not found`);
793
+ if (method === 'PUT' && (body.label === undefined || body.displayOrder === undefined))
794
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'label and displayOrder are required to replace a stage');
795
+ const updated = {
796
+ id: stage.id,
797
+ label: body.label !== undefined ? String(body.label) : stage.label,
798
+ displayOrder: body.displayOrder !== undefined ? Number(body.displayOrder) : stage.displayOrder,
799
+ metadata: body.metadata ?? stage.metadata,
800
+ };
801
+ await write('pipeline', pipelineKey(objectType, pipelineId), { objectType, pipelineId, label: pipeline.label, displayOrder: pipeline.displayOrder, stages: pipeline.stages.map((s) => (s.id === stageId ? updated : s)), stageSeq: pipeline.stageSeq, hsUpdatedAt: at }, root, at);
802
+ return ok(renderStage(updated, pipeline));
803
+ }
804
+ if (method === 'DELETE') {
805
+ if (!stage)
806
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Stage ${stageId} not found`);
807
+ await write('pipeline', pipelineKey(objectType, pipelineId), { objectType, pipelineId, label: pipeline.label, displayOrder: pipeline.displayOrder, stages: pipeline.stages.filter((s) => s.id !== stageId), stageSeq: pipeline.stageSeq, hsUpdatedAt: at }, root, at);
808
+ return noContent();
809
+ }
810
+ }
811
+ }
812
+ }
813
+ return notFound(ctx);
814
+ }
815
+ // ── /crm/v3/owners ───────────────────────────────────────────────────────────────────────
816
+ if (parts[0] === 'crm' && parts[1] === 'v3' && parts[2] === 'owners') {
817
+ const owners = listOwners(root);
818
+ if (method === 'GET' && parts.length === 3) {
819
+ const email = query.get('email');
820
+ const filtered = email ? owners.filter((o) => o.email === email) : owners;
821
+ return ok({ results: filtered });
822
+ }
823
+ if (method === 'GET' && parts.length === 4) {
824
+ const found = owners.find((o) => o.id === parts[3]);
825
+ if (!found)
826
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `Owner ${parts[3]} not found`);
827
+ return ok(found);
828
+ }
829
+ return notFound(ctx);
830
+ }
831
+ // ── /crm/v4/objects/{objectType}/{objectId}/associations/... ─────────────────────────────
832
+ if (parts[0] === 'crm' && parts[1] === 'v4' && parts[2] === 'objects' && parts[4] !== undefined && parts[5] === 'associations') {
833
+ const fromType = resolveObjectType(parts[3]);
834
+ if (!fromType)
835
+ return refuseObjectType(ctx, parts[3]);
836
+ const fromId = parts[4];
837
+ if (!readRecord(fromType, fromId, root))
838
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${fromType} ${fromId} not found`);
839
+ // `default` FIRST — it occupies the {toObjectType} slot of the labeled route.
840
+ if (parts[6] === 'default' && parts.length === 9) {
841
+ if (method !== 'PUT')
842
+ return notFound(ctx);
843
+ return createDefaultAssociation(ctx, fromType, fromId, parts[7], parts[8], root, at);
844
+ }
845
+ if (parts.length === 7) {
846
+ const toType = resolveObjectType(parts[6]);
847
+ if (!toType)
848
+ return refuseObjectType(ctx, parts[6]);
849
+ if (method === 'GET') {
850
+ const results = readAssociations(fromType, fromId, toType, root).map((a) => ({
851
+ toObjectId: a.toId,
852
+ associationTypes: a.labels.map((l) => ({ category: l.associationCategory, typeId: l.associationTypeId })),
853
+ }));
854
+ return ok({ results });
855
+ }
856
+ return notFound(ctx);
857
+ }
858
+ if (parts.length === 8) {
859
+ const toType = resolveObjectType(parts[6]);
860
+ if (!toType)
861
+ return refuseObjectType(ctx, parts[6]);
862
+ const toId = parts[7];
863
+ if (method === 'PUT')
864
+ return createLabeledAssociation(ctx, fromType, fromId, toType, toId, body, root, at);
865
+ if (method === 'DELETE') {
866
+ if (!readRecord(toType, toId, root))
867
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${toType} ${toId} not found`);
868
+ const key = associationKey(fromType, fromId, toType, toId);
869
+ if (byId('association', key, root))
870
+ await write('association', key, { deleted: true }, root, at);
871
+ const mirror = associationKey(toType, toId, fromType, fromId);
872
+ if (byId('association', mirror, root))
873
+ await write('association', mirror, { deleted: true }, root, at);
874
+ return noContent();
875
+ }
876
+ }
877
+ return notFound(ctx);
878
+ }
879
+ // ── /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/{op} ──────────────────────
880
+ if (parts[0] === 'crm' && parts[1] === 'v4' && parts[2] === 'associations' && parts[5] === 'batch' && parts.length === 7) {
881
+ const fromType = resolveObjectType(parts[3]);
882
+ const toType = resolveObjectType(parts[4]);
883
+ if (!fromType)
884
+ return refuseObjectType(ctx, parts[3]);
885
+ if (!toType)
886
+ return refuseObjectType(ctx, parts[4]);
887
+ if (method !== 'POST')
888
+ return notFound(ctx);
889
+ return associationBatch(ctx, fromType, toType, parts[6], body, root, at);
890
+ }
891
+ // Unmodeled op → fails like the vendor, never a fake success.
892
+ return notFound(ctx);
893
+ }
894
+ function notFound(ctx) {
895
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `resource not found: ${ctx.method} ${ctx.pathname}`);
896
+ }
897
+ /** What the twin answers a request for no operation it serves (the derived dispatch's gap, hubspot-server.ts): an
898
+ * object type HubSpot cannot resolve under /crm/v3/objects/ is refused as the vendor refuses it, a type it has and
899
+ * the twin does not model says so (`refuseObjectType`), and anything else is the twin's 404 in HubSpot's error body.
900
+ * Where the documentation stops: no page prints HubSpot's answer to an unknown path; the 404 is the twin's. */
901
+ export function hubspotUnknownRequest(method, pathname) {
902
+ const ctx = { method, pathname };
903
+ const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
904
+ if (parts[0] === 'crm' && parts[1] === 'v3' && parts[2] === 'objects' && parts[3] !== undefined && !resolveObjectType(parts[3]))
905
+ return refuseObjectType(ctx, parts[3]);
906
+ return notFound(ctx);
907
+ }
908
+ /** The segment the spec's own paths give a modelled object type under /crm/v3/objects/: the Deals document publishes
909
+ * its paths with the objectTypeId (`/crm/v3/objects/0-3`), the others with the name. HubSpot takes either in that
910
+ * segment ("You can always use the numerical type ID value, but for contacts, companies, deals, tickets, or notes, in
911
+ * some cases you can also use the object's fully qualified name", https://developers.hubspot.com/docs/api-reference/latest/crm/understanding-the-crm;
912
+ * Dub reads a contact at /crm/v3/objects/contacts/{id} and a deal at /crm/v3/objects/0-3/{id}), so a request naming
913
+ * the other spelling is answered by the same operation. */
914
+ const SPEC_OBJECT_SEGMENT = { contacts: 'contacts', companies: 'companies', deals: '0-3', tickets: 'tickets' };
915
+ /** A /crm/v3/objects/ path with its object type spelled as the spec's paths spell it; any other path unchanged. */
916
+ export function specObjectPath(pathname) {
917
+ const m = /^\/crm\/v3\/objects\/([^/]+)(\/.*)?$/.exec(pathname);
918
+ const type = m ? resolveObjectType(m[1]) : null;
919
+ return m && type ? `/crm/v3/objects/${SPEC_OBJECT_SEGMENT[type]}${m[2] ?? ''}` : pathname;
920
+ }
921
+ // ── record operations ──────────────────────────────────────────────────────────────────────
922
+ function normalizeProperties(input) {
923
+ if (input === undefined || input === null)
924
+ return null;
925
+ if (typeof input !== 'object' || Array.isArray(input))
926
+ return null;
927
+ const out = {};
928
+ for (const [k, v] of Object.entries(input)) {
929
+ if (v === null || v === undefined) {
930
+ out[k] = '';
931
+ continue;
932
+ }
933
+ if (typeof v === 'object')
934
+ return null;
935
+ out[k] = String(v);
936
+ }
937
+ return out;
938
+ }
939
+ async function createRecord(ctx, objectType, body, root, at) {
940
+ const props = normalizeProperties(body.properties);
941
+ if (props === null)
942
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'Property values were not valid', [{ message: 'properties must be an object of string values', code: 'INVALID_PROPERTY_MAP', context: { properties: ['properties'] } }]);
943
+ const id = nextRecordId(objectType, root);
944
+ const record = {
945
+ objectType, hsId: id, hsCreatedAt: at, hsUpdatedAt: at, archived: false, hsLocalMint: true,
946
+ props: { ...props, hs_object_id: id, createdate: at, [LAST_MODIFIED_PROP[objectType]]: at },
947
+ };
948
+ await write('crm_object', recordKey(objectType, id), record, root, at);
949
+ // `associations` on a create body (PublicAssociationsForObject[]) is part of the create call.
950
+ const assocs = body.associations;
951
+ if (Array.isArray(assocs)) {
952
+ for (const a of assocs) {
953
+ const to = a.to;
954
+ const toId = to?.id === undefined ? undefined : String(to.id);
955
+ const types = Array.isArray(a.types) ? a.types : [];
956
+ if (!toId || types.length === 0)
957
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'each association requires `to.id` and a non-empty `types`');
958
+ const toType = inferAssociationTarget(objectType, types);
959
+ if (!toType)
960
+ return fail(ctx, 400, 'VALIDATION_ERROR', `Unknown associationTypeId for a ${objectType} association`);
961
+ if (!readRecord(toType, toId, root))
962
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${toType} ${toId} not found`);
963
+ await storeAssociation(objectType, id, toType, toId, types.map((t) => ({
964
+ associationCategory: String(t.associationCategory ?? 'HUBSPOT_DEFINED'),
965
+ associationTypeId: Number(t.associationTypeId),
966
+ })), root, at);
967
+ }
968
+ }
969
+ // announced once the create, its associations included, is stored: an app reading the new record finds them
970
+ await announceWrite({ objectType, objectId: id, kind: 'creation' }, root, at);
971
+ return ok(renderObject(readRecord(objectType, id, root), { root }), 201);
972
+ }
973
+ /** Resolve the target object type of an association spec by its HubSpot-defined type id. */
974
+ function inferAssociationTarget(fromType, types) {
975
+ for (const t of types) {
976
+ const typeId = Number(t.associationTypeId);
977
+ for (const [pair, id] of Object.entries(DEFAULT_ASSOCIATION_TYPE_IDS)) {
978
+ const [from, to] = pair.split('>');
979
+ if (from === fromType && id === typeId)
980
+ return to;
981
+ }
982
+ }
983
+ return null;
984
+ }
985
+ function listRecords(ctx, objectType, query, root) {
986
+ const limitRaw = query.get('limit');
987
+ const limit = limitRaw === null ? 10 : Number(limitRaw);
988
+ if (!Number.isInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) {
989
+ return fail(ctx, 400, 'VALIDATION_ERROR', `limit must be an integer between 1 and ${LIST_LIMIT_MAX}`);
990
+ }
991
+ const archived = query.get('archived') === 'true';
992
+ const properties = splitCsv(query.getAll('properties'));
993
+ const associations = splitCsv(query.getAll('associations'));
994
+ const all = allRecords(objectType, root).filter((r) => r.archived === archived);
995
+ const after = query.get('after');
996
+ // HubSpot's `after` is the Record ID of the next record — results begin AT that id.
997
+ const start = after === null ? 0 : all.findIndex((r) => Number(r.hsId) >= Number(after));
998
+ const from = start === -1 ? all.length : start;
999
+ const unresolved = [];
1000
+ const page = all.slice(from, from + limit);
1001
+ for (const raw of associations)
1002
+ if (!resolveObjectType(raw))
1003
+ unresolved.push(raw);
1004
+ if (unresolved.length)
1005
+ return refuseObjectType(ctx, unresolved[0]);
1006
+ const nextIndex = from + limit;
1007
+ // `NextPage.link` is OPTIONAL in the generated model, and HubSpot's own value is an ABSOLUTE
1008
+ // URL rooted at its public host. A twin serving traffic on an ephemeral loopback port does not
1009
+ // know that host, and a bare `?after=…` fragment is not what the vendor returns — so the field
1010
+ // is omitted rather than filled with a lookalike (`hubspot.protocol.paging_next_link`).
1011
+ const paging = nextIndex < all.length ? { next: { after: all[nextIndex].hsId } } : undefined;
1012
+ return ok({
1013
+ results: page.map((r) => renderObject(r, { ...(properties.length ? { properties } : {}), ...(associations.length ? { associations } : {}), root })),
1014
+ ...(paging ? { paging } : {}),
1015
+ });
1016
+ }
1017
+ function splitCsv(values) {
1018
+ return values.flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean);
1019
+ }
1020
+ function getRecord(ctx, objectType, id, query, root) {
1021
+ const rec = readRecord(objectType, id, root);
1022
+ const wantArchived = query.get('archived') === 'true';
1023
+ if (!rec || rec.archived !== wantArchived)
1024
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${objectType} ${id} not found`);
1025
+ const properties = splitCsv(query.getAll('properties'));
1026
+ const associations = splitCsv(query.getAll('associations'));
1027
+ const unresolved = [];
1028
+ const rendered = renderObject(rec, { ...(properties.length ? { properties } : {}), ...(associations.length ? { associations } : {}), root }, unresolved);
1029
+ if (unresolved.length)
1030
+ return refuseObjectType(ctx, unresolved[0]);
1031
+ return ok(rendered);
1032
+ }
1033
+ async function updateRecord(ctx, objectType, id, body, root, at) {
1034
+ const rec = readRecord(objectType, id, root);
1035
+ if (!rec || rec.archived)
1036
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${objectType} ${id} not found`);
1037
+ const props = normalizeProperties(body.properties);
1038
+ if (props === null)
1039
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'Property values were not valid', [{ message: 'properties must be an object of string values', code: 'INVALID_PROPERTY_MAP', context: { properties: ['properties'] } }]);
1040
+ const merged = { ...rec.props, ...props, [LAST_MODIFIED_PROP[objectType]]: at };
1041
+ await write('crm_object', recordKey(objectType, id), { objectType, hsId: id, props: merged, hsUpdatedAt: at, archived: false, hsLocalMint: rec.hsLocalMint }, root, at);
1042
+ // a property change is an event only when the value changed
1043
+ for (const [name, value] of Object.entries(props)) {
1044
+ if ((rec.props[name] ?? null) !== (value ?? null))
1045
+ await announceWrite({ objectType, objectId: id, kind: 'propertyChange', propertyName: name, propertyValue: value }, root, at);
1046
+ }
1047
+ return ok(renderObject(readRecord(objectType, id, root), { root }));
1048
+ }
1049
+ async function archiveRecord(ctx, objectType, id, root, at) {
1050
+ const rec = readRecord(objectType, id, root);
1051
+ if (!rec || rec.archived)
1052
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${objectType} ${id} not found`);
1053
+ await write('crm_object', recordKey(objectType, id), { objectType, hsId: id, archived: true, hsArchivedAt: at, hsUpdatedAt: at, props: rec.props, hsLocalMint: rec.hsLocalMint }, root, at);
1054
+ return noContent();
1055
+ }
1056
+ async function mergeRecords(ctx, objectType, body, root, at) {
1057
+ const primaryId = body.primaryObjectId === undefined ? undefined : String(body.primaryObjectId);
1058
+ const mergeId = body.objectIdToMerge === undefined ? undefined : String(body.objectIdToMerge);
1059
+ if (!primaryId || !mergeId)
1060
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'primaryObjectId and objectIdToMerge are required');
1061
+ if (primaryId === mergeId)
1062
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'primaryObjectId and objectIdToMerge must differ');
1063
+ const primary = readRecord(objectType, primaryId, root);
1064
+ const secondary = readRecord(objectType, mergeId, root);
1065
+ if (!primary || primary.archived)
1066
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${objectType} ${primaryId} not found`);
1067
+ if (!secondary || secondary.archived)
1068
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${objectType} ${mergeId} not found`);
1069
+ // HubSpot keeps the PRIMARY record's values and fills only its blanks from the secondary.
1070
+ const merged = { ...secondary.props, ...primary.props };
1071
+ for (const [k, v] of Object.entries(primary.props))
1072
+ if (v === '' || v === null)
1073
+ merged[k] = secondary.props[k] ?? v;
1074
+ merged[LAST_MODIFIED_PROP[objectType]] = at;
1075
+ merged.hs_object_id = primaryId;
1076
+ await write('crm_object', recordKey(objectType, primaryId), { objectType, hsId: primaryId, props: merged, hsUpdatedAt: at, archived: false, hsLocalMint: primary.hsLocalMint }, root, at);
1077
+ await write('crm_object', recordKey(objectType, mergeId), { objectType, hsId: mergeId, archived: true, hsArchivedAt: at, hsUpdatedAt: at, hsMergedInto: primaryId, props: secondary.props, hsLocalMint: secondary.hsLocalMint }, root, at);
1078
+ return ok(renderObject(readRecord(objectType, primaryId, root), { root }));
1079
+ }
1080
+ // ── batch ──────────────────────────────────────────────────────────────────────────────────
1081
+ function batchEnvelope(results, at, extra = {}) {
1082
+ return { status: 'COMPLETE', results, startedAt: at, completedAt: at, ...extra };
1083
+ }
1084
+ async function batchOp(ctx, objectType, op, body, root, at) {
1085
+ const inputs = Array.isArray(body.inputs) ? body.inputs : null;
1086
+ if (inputs === null)
1087
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'inputs must be an array');
1088
+ if (op === 'create') {
1089
+ const results = [];
1090
+ for (const input of inputs) {
1091
+ const created = await createRecord(ctx, objectType, input, root, at);
1092
+ if (created.status !== 201)
1093
+ return created;
1094
+ results.push(created.body);
1095
+ }
1096
+ return ok(batchEnvelope(results, at), 201);
1097
+ }
1098
+ if (op === 'read') {
1099
+ const properties = Array.isArray(body.properties) ? body.properties : [];
1100
+ const idProperty = body.idProperty === undefined ? null : String(body.idProperty);
1101
+ const results = [];
1102
+ const errors = [];
1103
+ for (const input of inputs) {
1104
+ const wanted = String(input.id ?? '');
1105
+ const rec = idProperty === null
1106
+ ? readRecord(objectType, wanted, root)
1107
+ : allRecords(objectType, root).find((r) => !r.archived && r.props[idProperty] === wanted);
1108
+ if (!rec || rec.archived) {
1109
+ // A complete `StandardError` — the generated model declares status/category/message/
1110
+ // context/links/errors as REQUIRED, so a partial one would not survive the SDK's own
1111
+ // deserializer.
1112
+ errors.push({
1113
+ status: 'error', category: 'OBJECT_NOT_FOUND',
1114
+ message: `Could not get some ${objectType}. Some of the requested records were not found`,
1115
+ context: { ids: [wanted] }, links: {}, errors: [],
1116
+ });
1117
+ continue;
1118
+ }
1119
+ results.push(renderObject(rec, { ...(properties.length ? { properties } : {}), root }));
1120
+ }
1121
+ // HubSpot answers a partially-successful batch read with 207 MULTI-STATUS and a
1122
+ // BatchResponseSimplePublicObjectWithErrors (the generated processor accepts 200 or 207).
1123
+ if (errors.length > 0) {
1124
+ return ok({ ...batchEnvelope(results, at, { numErrors: errors.length, errors }), status: 'COMPLETE' }, 207);
1125
+ }
1126
+ return ok(batchEnvelope(results, at));
1127
+ }
1128
+ if (op === 'update') {
1129
+ const results = [];
1130
+ for (const input of inputs) {
1131
+ const id = String(input.id ?? '');
1132
+ const updated = await updateRecord(ctx, objectType, id, input, root, at);
1133
+ if (updated.status !== 200)
1134
+ return updated;
1135
+ results.push(updated.body);
1136
+ }
1137
+ return ok(batchEnvelope(results, at));
1138
+ }
1139
+ if (op === 'upsert') {
1140
+ // "idProperty query param refers to a property whose values are unique for the object" (the spec's
1141
+ // batch/upsert description): a property that is not unique, or not the object's, cannot identify a record. Where
1142
+ // the documentation stops: HubSpot's words for the refusal are not published; the twin's name the rule.
1143
+ for (const input of inputs) {
1144
+ if (input.idProperty === undefined)
1145
+ continue;
1146
+ const name = String(input.idProperty);
1147
+ const property = listProperties(objectType, root).find((p) => p.name === name && !p.archived);
1148
+ if (!property || !property.hasUniqueValue)
1149
+ return fail(ctx, 400, 'VALIDATION_ERROR', `${name} is not a unique-value property of ${objectType}, so it cannot be an idProperty`);
1150
+ }
1151
+ const results = [];
1152
+ for (const input of inputs) {
1153
+ const idProperty = input.idProperty === undefined ? null : String(input.idProperty);
1154
+ const wanted = String(input.id ?? '');
1155
+ const props = normalizeProperties(input.properties) ?? {};
1156
+ const existing = idProperty === null
1157
+ ? readRecord(objectType, wanted, root)
1158
+ : allRecords(objectType, root).find((r) => !r.archived && r.props[idProperty] === wanted);
1159
+ if (existing && !existing.archived) {
1160
+ const updated = await updateRecord(ctx, objectType, existing.hsId, { properties: props }, root, at);
1161
+ if (updated.status !== 200)
1162
+ return updated;
1163
+ results.push({ ...updated.body, new: false });
1164
+ }
1165
+ else {
1166
+ const seed = idProperty === null ? props : { ...props, [idProperty]: wanted };
1167
+ const created = await createRecord(ctx, objectType, { properties: seed }, root, at);
1168
+ if (created.status !== 201)
1169
+ return created;
1170
+ results.push({ ...created.body, new: true });
1171
+ }
1172
+ }
1173
+ return ok(batchEnvelope(results, at));
1174
+ }
1175
+ if (op === 'archive') {
1176
+ for (const input of inputs) {
1177
+ const id = String(input.id ?? '');
1178
+ const archived = await archiveRecord(ctx, objectType, id, root, at);
1179
+ if (archived.status !== 204)
1180
+ return archived;
1181
+ }
1182
+ return noContent();
1183
+ }
1184
+ return notFound(ctx);
1185
+ }
1186
+ // ── search ─────────────────────────────────────────────────────────────────────────────────
1187
+ function searchRecords(ctx, objectType, body, root) {
1188
+ const limitRaw = body.limit;
1189
+ const limit = limitRaw === undefined ? 10 : Number(limitRaw);
1190
+ if (!Number.isInteger(limit) || limit < 1 || limit > SEARCH_LIMIT_MAX) {
1191
+ return fail(ctx, 400, 'VALIDATION_ERROR', `The limit must be a positive integer no greater than ${SEARCH_LIMIT_MAX}`);
1192
+ }
1193
+ const groups = Array.isArray(body.filterGroups) ? body.filterGroups : [];
1194
+ if (groups.length > SEARCH_MAX_FILTER_GROUPS)
1195
+ return fail(ctx, 400, 'VALIDATION_ERROR', `A maximum of ${SEARCH_MAX_FILTER_GROUPS} filterGroups is supported`);
1196
+ let totalFilters = 0;
1197
+ const parsedGroups = [];
1198
+ for (const g of groups) {
1199
+ const filters = Array.isArray(g.filters) ? g.filters : [];
1200
+ if (filters.length > SEARCH_MAX_FILTERS_PER_GROUP)
1201
+ return fail(ctx, 400, 'VALIDATION_ERROR', `A maximum of ${SEARCH_MAX_FILTERS_PER_GROUP} filters per filterGroup is supported`);
1202
+ totalFilters += filters.length;
1203
+ const out = [];
1204
+ for (const f of filters) {
1205
+ const operator = String(f.operator ?? '');
1206
+ // The CLOSED FilterOperatorEnum is the oracle — an operator outside it is refused.
1207
+ if (!OPERATOR_SET.has(operator))
1208
+ return fail(ctx, 400, 'VALIDATION_ERROR', `Unsupported filter operator: ${operator || '(missing)'}`);
1209
+ if (f.propertyName === undefined)
1210
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'propertyName is required on every filter');
1211
+ out.push({
1212
+ propertyName: String(f.propertyName), operator,
1213
+ ...(f.value !== undefined ? { value: String(f.value) } : {}),
1214
+ ...(Array.isArray(f.values) ? { values: f.values.map(String) } : {}),
1215
+ ...(f.highValue !== undefined ? { highValue: String(f.highValue) } : {}),
1216
+ });
1217
+ }
1218
+ parsedGroups.push(out);
1219
+ }
1220
+ if (totalFilters > SEARCH_MAX_FILTERS_TOTAL)
1221
+ return fail(ctx, 400, 'VALIDATION_ERROR', `A maximum of ${SEARCH_MAX_FILTERS_TOTAL} filters is supported`);
1222
+ const afterRaw = body.after === undefined ? 0 : Number(body.after);
1223
+ if (!Number.isInteger(afterRaw) || afterRaw < 0)
1224
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'after must be a non-negative integer offset');
1225
+ if (afterRaw + limit > SEARCH_RESULT_CAP)
1226
+ return fail(ctx, 400, 'VALIDATION_ERROR', `The search endpoint returns a maximum of ${SEARCH_RESULT_CAP} results; narrow your query`);
1227
+ const query = body.query === undefined ? null : String(body.query);
1228
+ let matched = allRecords(objectType, root).filter((r) => !r.archived);
1229
+ if (parsedGroups.length) {
1230
+ matched = matched.filter((r) => parsedGroups.some((g) => g.every((f) => matchesFilter(r, f))));
1231
+ }
1232
+ if (query) {
1233
+ const needle = query.toLowerCase();
1234
+ matched = matched.filter((r) => Object.values(r.props).some((v) => String(v ?? '').toLowerCase().includes(needle)));
1235
+ }
1236
+ const sorts = Array.isArray(body.sorts) ? body.sorts.map(String) : [];
1237
+ if (sorts.length) {
1238
+ // HubSpot accepts a single sort as `{propertyName, direction}` or a bare property name; the
1239
+ // generated PublicObjectSearchRequest types `sorts` as string[], which is what this models.
1240
+ const key = sorts[0];
1241
+ matched = [...matched].sort((a, b) => String(a.props[key] ?? '').localeCompare(String(b.props[key] ?? '')));
1242
+ }
1243
+ const properties = Array.isArray(body.properties) ? body.properties.map(String) : [];
1244
+ const page = matched.slice(afterRaw, afterRaw + limit);
1245
+ const nextOffset = afterRaw + limit;
1246
+ const paging = nextOffset < matched.length && nextOffset < SEARCH_RESULT_CAP ? { next: { after: String(nextOffset) } } : undefined;
1247
+ return ok({
1248
+ total: matched.length,
1249
+ results: page.map((r) => renderObject(r, { ...(properties.length ? { properties } : {}), root })),
1250
+ ...(paging ? { paging } : {}),
1251
+ });
1252
+ }
1253
+ // ── properties / groups / pipelines writes ─────────────────────────────────────────────────
1254
+ /** Why a property create would be refused, answered as HubSpot answers it; undefined when it would succeed. */
1255
+ function propertyRefusal(ctx, objectType, body, root) {
1256
+ for (const required of ['name', 'label', 'type', 'fieldType', 'groupName']) {
1257
+ if (body[required] === undefined || String(body[required]) === '') {
1258
+ return fail(ctx, 400, 'VALIDATION_ERROR', `${required} is required`, [{ message: `${required} is required`, code: 'PROPERTY_MISSING_FIELD', context: { field: [required] } }]);
1259
+ }
1260
+ }
1261
+ const name = String(body.name);
1262
+ if (listProperties(objectType, root).some((p) => p.name === name && !p.archived))
1263
+ return fail(ctx, 409, 'CONFLICT', `Property ${name} already exists`);
1264
+ return undefined;
1265
+ }
1266
+ async function createProperty(ctx, objectType, body, root, at) {
1267
+ const refused = propertyRefusal(ctx, objectType, body, root);
1268
+ if (refused)
1269
+ return refused;
1270
+ const name = String(body.name);
1271
+ await write('property', propertyKey(objectType, name), {
1272
+ objectType, name, label: String(body.label), propType: String(body.type), fieldType: String(body.fieldType),
1273
+ groupName: String(body.groupName), description: body.description === undefined ? '' : String(body.description),
1274
+ options: Array.isArray(body.options) ? body.options : [], formField: body.formField === true,
1275
+ // a property whose values are unique is one a record can be identified by (batch upsert's `idProperty`)
1276
+ hasUniqueValue: body.hasUniqueValue === true, archived: false,
1277
+ }, root, at);
1278
+ return ok(renderProperty(listProperties(objectType, root).find((p) => p.name === name)), 201);
1279
+ }
1280
+ async function createPropertyGroup(ctx, objectType, body, root, at) {
1281
+ if (body.name === undefined || body.label === undefined)
1282
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'name and label are required');
1283
+ const name = String(body.name);
1284
+ if (listPropertyGroups(objectType, root).some((g) => g.name === name && !g.archived))
1285
+ return fail(ctx, 409, 'CONFLICT', `Group ${name} already exists`);
1286
+ await write('property_group', `${objectType}_${name}`, { objectType, name, label: String(body.label), displayOrder: body.displayOrder === undefined ? -1 : Number(body.displayOrder), archived: false }, root, at);
1287
+ const g = listPropertyGroups(objectType, root).find((x) => x.name === name);
1288
+ return ok({ name: g.name, label: g.label, displayOrder: g.displayOrder, archived: g.archived }, 201);
1289
+ }
1290
+ /**
1291
+ * Assign ids to a stage list, drawing every new id from the pipeline's MONOTONIC counter.
1292
+ * Returns the next counter value alongside the stages so the caller persists it — an id a
1293
+ * removed stage once held is never handed out again.
1294
+ */
1295
+ function normalizeStages(input, startSeq) {
1296
+ const stages = [];
1297
+ let seq = startSeq;
1298
+ for (const s of input) {
1299
+ if (s.label === undefined || s.displayOrder === undefined)
1300
+ return { error: 'each stage requires label and displayOrder' };
1301
+ let id = s.id === undefined ? '' : String(s.id);
1302
+ if (!id) {
1303
+ seq += 1;
1304
+ id = `stage_${seq}`;
1305
+ }
1306
+ stages.push({ id, label: String(s.label), displayOrder: Number(s.displayOrder), metadata: s.metadata ?? {} });
1307
+ }
1308
+ return { stages, seq };
1309
+ }
1310
+ async function createPipeline(ctx, objectType, body, root, at) {
1311
+ if (body.label === undefined || body.displayOrder === undefined)
1312
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'label and displayOrder are required');
1313
+ const stagesInput = Array.isArray(body.stages) ? body.stages : [];
1314
+ const staged = normalizeStages(stagesInput, 0);
1315
+ if ('error' in staged)
1316
+ return fail(ctx, 400, 'VALIDATION_ERROR', staged.error);
1317
+ // Pipeline ids come from the id SET of every pipeline EVER stored — archived ones included,
1318
+ // so an archived pipeline's id is never handed to a new one (the same rule record ids follow).
1319
+ const used = new Set();
1320
+ for (const r of rowsAll('pipeline', root)) {
1321
+ if (r.objectType === objectType)
1322
+ used.add(String(r.pipelineId));
1323
+ }
1324
+ let n = 1;
1325
+ while (used.has(`pipeline_${n}`))
1326
+ n += 1;
1327
+ const id = `pipeline_${n}`;
1328
+ await write('pipeline', pipelineKey(objectType, id), { objectType, pipelineId: id, label: String(body.label), displayOrder: Number(body.displayOrder), stages: staged.stages, stageSeq: staged.seq, archived: false, hsCreatedAt: at, hsUpdatedAt: at }, root, at);
1329
+ return ok(renderPipeline(listPipelines(objectType, root).find((p) => p.id === id)), 201);
1330
+ }
1331
+ // ── owners ─────────────────────────────────────────────────────────────────────────────────
1332
+ function listOwners(root) {
1333
+ const hub = currentPortal().hub;
1334
+ // an account the World made: its owners are its users ("owners are portal users", the index's resourcesUnreachable)
1335
+ if (hub !== TWIN_HUB_ID)
1336
+ return ownersOf(hub, root);
1337
+ const merged = new Map();
1338
+ merged.set(SEED_OWNER.id, { ...SEED_OWNER, createdAt: EPOCH, updatedAt: EPOCH });
1339
+ for (const r of rows('owner', root)) {
1340
+ const id = String(r.ownerId ?? r.id);
1341
+ merged.set(id, {
1342
+ id, email: r.email ?? null, firstName: r.firstName ?? null, lastName: r.lastName ?? null,
1343
+ userId: r.userId ?? null, userIdIncludingInactive: r.userId ?? null, type: r.ownerType ?? 'PERSON',
1344
+ archived: r.archived === true, teams: r.teams ?? [],
1345
+ createdAt: String(r.hsCreatedAt ?? EPOCH), updatedAt: String(r.hsUpdatedAt ?? EPOCH),
1346
+ });
1347
+ }
1348
+ return [...merged.values()].filter((o) => o.archived !== true);
1349
+ }
1350
+ // ── association writes ─────────────────────────────────────────────────────────────────────
1351
+ /**
1352
+ * Parse an `AssociationSpec[]` body — a CLOSED TWO-KEY shape.
1353
+ *
1354
+ * `crm/associations/v4/models/AssociationSpec.d.ts` declares exactly `associationCategory` and
1355
+ * `associationTypeId`, and nothing else. In particular there is NO `label` on the REQUEST: a
1356
+ * label is a property of the association-type DEFINITION (`/crm/v4/associations/{from}/{to}/
1357
+ * labels`, which this twin files as `hubspot.associations.label_definitions` and does not model),
1358
+ * and HubSpot RESOLVES it onto the response from that definition. Accepting a caller-supplied
1359
+ * `label` and echoing it back would be inventing a request parameter the vendor does not have —
1360
+ * the §6 "serving surface the vendor doesn't have" class — so the key is read from nothing here
1361
+ * and every stored label is null until definitions are modeled.
1362
+ */
1363
+ function parseAssociationSpecs(ctx, specs) {
1364
+ const labels = [];
1365
+ for (const s of specs) {
1366
+ const category = String(s.associationCategory ?? '');
1367
+ // The closed AssociationSpecAssociationCategoryEnum from the generated model.
1368
+ if (!['HUBSPOT_DEFINED', 'USER_DEFINED', 'INTEGRATOR_DEFINED'].includes(category)) {
1369
+ return { error: fail(ctx, 400, 'VALIDATION_ERROR', `Unsupported associationCategory: ${category || '(missing)'}`) };
1370
+ }
1371
+ const typeId = Number(s.associationTypeId);
1372
+ if (!Number.isInteger(typeId))
1373
+ return { error: fail(ctx, 400, 'VALIDATION_ERROR', 'associationTypeId must be an integer') };
1374
+ labels.push({ associationCategory: category, associationTypeId: typeId });
1375
+ }
1376
+ return { labels };
1377
+ }
1378
+ /**
1379
+ * `LabelsBetweenObjectPair` — the 201 body of a labeled association PUT.
1380
+ *
1381
+ * `fromObjectTypeId` / `toObjectTypeId` carry HubSpot's NUMERIC objectTypeId (`0-1`, `0-2`), not
1382
+ * the plural path segment. `labels` holds the resolved definition labels, which is EMPTY here
1383
+ * for as long as `hubspot.associations.label_definitions` is unmodeled — an empty list is the
1384
+ * honest answer; a list echoing the caller's own input would not be.
1385
+ */
1386
+ function labelsBetweenObjectPair(fromType, fromId, toType, toId, labels) {
1387
+ return {
1388
+ fromObjectTypeId: OBJECT_TYPE_ID[fromType], fromObjectId: fromId,
1389
+ toObjectTypeId: OBJECT_TYPE_ID[toType], toObjectId: toId,
1390
+ // Resolved from the association-type DEFINITIONS, which are unmodeled — so, empty.
1391
+ labels: [],
1392
+ };
1393
+ }
1394
+ async function storeAssociation(fromType, fromId, toType, toId, labels, root, at) {
1395
+ await write('association', associationKey(fromType, fromId, toType, toId), { fromType, fromId, toType, toId, labels }, root, at);
1396
+ // HubSpot associations are bidirectional — the inverse is readable from the other record.
1397
+ const inverseId = DEFAULT_ASSOCIATION_TYPE_IDS[`${toType}>${fromType}`];
1398
+ await write('association', associationKey(toType, toId, fromType, fromId), {
1399
+ fromType: toType, fromId: toId, toType: fromType, toId: fromId,
1400
+ labels: inverseId === undefined ? labels : [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: inverseId }],
1401
+ }, root, at);
1402
+ }
1403
+ async function createDefaultAssociation(ctx, fromType, fromId, rawToType, toId, root, at) {
1404
+ const toType = resolveObjectType(rawToType);
1405
+ if (!toType)
1406
+ return refuseObjectType(ctx, rawToType);
1407
+ if (!readRecord(toType, toId, root))
1408
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${toType} ${toId} not found`);
1409
+ const typeId = DEFAULT_ASSOCIATION_TYPE_IDS[`${fromType}>${toType}`];
1410
+ if (typeId === undefined) {
1411
+ // The twin refuses rather than fabricating an associationTypeId it has no source for
1412
+ // (`hubspot.associations.create_default_unmodeled_pair` files this as a todo).
1413
+ return fail(ctx, 400, 'VALIDATION_ERROR', `This twin models HubSpot-defined default association type ids only for the standard CRM pairs; ${fromType} -> ${toType} is not one of them`);
1414
+ }
1415
+ await storeAssociation(fromType, fromId, toType, toId, [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: typeId }], root, at);
1416
+ return ok({
1417
+ status: 'COMPLETE', startedAt: at, completedAt: at,
1418
+ results: [{ from: { id: fromId }, to: { id: toId }, associationSpec: { associationCategory: 'HUBSPOT_DEFINED', associationTypeId: typeId } }],
1419
+ });
1420
+ }
1421
+ async function createLabeledAssociation(ctx, fromType, fromId, toType, toId, body, root, at) {
1422
+ if (!readRecord(toType, toId, root))
1423
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${toType} ${toId} not found`);
1424
+ const specs = Array.isArray(body) ? body : Array.isArray(body.types) ? body.types : null;
1425
+ if (!specs || specs.length === 0)
1426
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'a non-empty array of association specs is required');
1427
+ const parsed = parseAssociationSpecs(ctx, specs);
1428
+ if ('error' in parsed)
1429
+ return parsed.error;
1430
+ await storeAssociation(fromType, fromId, toType, toId, parsed.labels, root, at);
1431
+ return ok(labelsBetweenObjectPair(fromType, fromId, toType, toId, parsed.labels), 201);
1432
+ }
1433
+ async function associationBatch(ctx, fromType, toType, op, body, root, at) {
1434
+ const inputs = Array.isArray(body.inputs) ? body.inputs : null;
1435
+ if (inputs === null)
1436
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'inputs must be an array');
1437
+ if (op === 'read') {
1438
+ const results = [];
1439
+ for (const input of inputs) {
1440
+ const fromId = String((input.id ?? input.from?.id) ?? '');
1441
+ if (!readRecord(fromType, fromId, root))
1442
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${fromType} ${fromId} not found`);
1443
+ results.push({
1444
+ from: { id: fromId },
1445
+ to: readAssociations(fromType, fromId, toType, root).map((a) => ({
1446
+ toObjectId: a.toId,
1447
+ associationTypes: a.labels.map((l) => ({ category: l.associationCategory, typeId: l.associationTypeId })),
1448
+ })),
1449
+ });
1450
+ }
1451
+ return ok(batchEnvelope(results, at));
1452
+ }
1453
+ if (op === 'create') {
1454
+ const results = [];
1455
+ for (const input of inputs) {
1456
+ const fromId = String(input.from?.id ?? input._from?.id ?? '');
1457
+ const toId = String(input.to?.id ?? '');
1458
+ const types = Array.isArray(input.types) ? input.types : [];
1459
+ if (!fromId || !toId || types.length === 0)
1460
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'each input requires from.id, to.id and a non-empty types array');
1461
+ if (!readRecord(fromType, fromId, root))
1462
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${fromType} ${fromId} not found`);
1463
+ if (!readRecord(toType, toId, root))
1464
+ return fail(ctx, 404, 'OBJECT_NOT_FOUND', `${toType} ${toId} not found`);
1465
+ const parsed = parseAssociationSpecs(ctx, types);
1466
+ if ('error' in parsed)
1467
+ return parsed.error;
1468
+ await storeAssociation(fromType, fromId, toType, toId, parsed.labels, root, at);
1469
+ results.push(labelsBetweenObjectPair(fromType, fromId, toType, toId, parsed.labels));
1470
+ }
1471
+ return ok(batchEnvelope(results, at), 201);
1472
+ }
1473
+ if (op === 'archive') {
1474
+ for (const input of inputs) {
1475
+ const fromId = String(input.from?.id ?? '');
1476
+ const tos = Array.isArray(input.to) ? input.to : [];
1477
+ if (!fromId)
1478
+ return fail(ctx, 400, 'VALIDATION_ERROR', 'each input requires from.id');
1479
+ for (const to of tos) {
1480
+ const toId = String(to.id ?? '');
1481
+ const key = associationKey(fromType, fromId, toType, toId);
1482
+ if (byId('association', key, root))
1483
+ await write('association', key, { deleted: true }, root, at);
1484
+ const mirror = associationKey(toType, toId, fromType, fromId);
1485
+ if (byId('association', mirror, root))
1486
+ await write('association', mirror, { deleted: true }, root, at);
1487
+ }
1488
+ }
1489
+ return noContent();
1490
+ }
1491
+ return notFound(ctx);
1492
+ }
1493
+ // The subject types this twin projects (for the pack descriptor / conformance).
1494
+ export const RESOURCE_TYPES = ['crm_object', 'property', 'property_group', 'pipeline', 'association', 'owner'];
1495
+ /**
1496
+ * THE ENDPOINT CENSUS — what this twin CLAIMS to serve, in canonical `{placeholder}` form.
1497
+ *
1498
+ * Hand-authored from the dispatch above, and held to a two-way bijection with
1499
+ * `hubspot-conformance.ts`'s probe table (every claim needs a live probe; every probe needs a
1500
+ * claim), while that file's independent ROUTER_SURFACE closes the third direction — surface the
1501
+ * router SERVES that this list does not name. It exists here, next to the router, so a branch
1502
+ * added without a claim is visible in the same diff.
1503
+ */
1504
+ export function hubspotTwinSnapshot() {
1505
+ return {
1506
+ implementedEndpoints: [
1507
+ // CRM objects (the generic {objectType} family: contacts, companies, deals, tickets)
1508
+ 'POST /crm/v3/objects/{objectType}',
1509
+ 'GET /crm/v3/objects/{objectType}',
1510
+ 'GET /crm/v3/objects/{objectType}/{objectId}',
1511
+ 'PATCH /crm/v3/objects/{objectType}/{objectId}',
1512
+ 'DELETE /crm/v3/objects/{objectType}/{objectId}',
1513
+ 'POST /crm/v3/objects/{objectType}/search',
1514
+ 'POST /crm/v3/objects/{objectType}/merge',
1515
+ 'POST /crm/v3/objects/{objectType}/batch/create',
1516
+ 'POST /crm/v3/objects/{objectType}/batch/read',
1517
+ 'POST /crm/v3/objects/{objectType}/batch/update',
1518
+ 'POST /crm/v3/objects/{objectType}/batch/upsert',
1519
+ 'POST /crm/v3/objects/{objectType}/batch/archive',
1520
+ // properties + property groups
1521
+ 'GET /crm/v3/properties/{objectType}',
1522
+ 'POST /crm/v3/properties/{objectType}',
1523
+ 'POST /crm/v3/properties/{objectType}/batch/create',
1524
+ 'GET /crm/v3/properties/{objectType}/{propertyName}',
1525
+ 'PATCH /crm/v3/properties/{objectType}/{propertyName}',
1526
+ 'DELETE /crm/v3/properties/{objectType}/{propertyName}',
1527
+ 'GET /crm/v3/properties/{objectType}/groups',
1528
+ 'POST /crm/v3/properties/{objectType}/groups',
1529
+ 'GET /crm/v3/properties/{objectType}/groups/{groupName}',
1530
+ 'PATCH /crm/v3/properties/{objectType}/groups/{groupName}',
1531
+ 'DELETE /crm/v3/properties/{objectType}/groups/{groupName}',
1532
+ // pipelines + stages (deals and tickets)
1533
+ 'GET /crm/v3/pipelines/{objectType}',
1534
+ 'POST /crm/v3/pipelines/{objectType}',
1535
+ 'GET /crm/v3/pipelines/{objectType}/{pipelineId}',
1536
+ 'PATCH /crm/v3/pipelines/{objectType}/{pipelineId}',
1537
+ 'PUT /crm/v3/pipelines/{objectType}/{pipelineId}',
1538
+ 'DELETE /crm/v3/pipelines/{objectType}/{pipelineId}',
1539
+ 'GET /crm/v3/pipelines/{objectType}/{pipelineId}/stages',
1540
+ 'POST /crm/v3/pipelines/{objectType}/{pipelineId}/stages',
1541
+ 'GET /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}',
1542
+ 'PATCH /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}',
1543
+ 'PUT /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}',
1544
+ 'DELETE /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}',
1545
+ // owners
1546
+ 'GET /crm/v3/owners',
1547
+ 'GET /crm/v3/owners/{ownerId}',
1548
+ // associations v4
1549
+ 'GET /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}',
1550
+ 'PUT /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}',
1551
+ 'DELETE /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}',
1552
+ 'PUT /crm/v4/objects/{objectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}',
1553
+ 'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read',
1554
+ 'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create',
1555
+ 'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive',
1556
+ ],
1557
+ };
1558
+ }