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