@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,537 @@
1
+ // HubSpot twin CONFORMANCE (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime).
2
+ //
3
+ // ── THIS CHECK DRIVES THE ROUTER AND ASSERTS WHAT CAME BACK ─────────────────────────────────
4
+ // Held to docs/contributing/adding-a-twin.md §6's bar and to the two escalating false-greens two packs' §9
5
+ // reviews found there:
6
+ // • two constants asserting about each other is not a check — so every expectation below is a
7
+ // LITERAL written here, never a value read back out of the handler's own module. The status
8
+ // codes are the ones @hubspot/api-client 14.0.1's GENERATED response processors accept
9
+ // (201 create, 200 read/update, 204 archive, 207 partial batch), transcribed from the
10
+ // published tarball, not from this twin;
11
+ // • "not the router's own miss" has teeth at the dispatch and nowhere deeper — so each probe
12
+ // declares the STATUS SET and a PREDICATE over the body a LIVE handler produces, and the
13
+ // hand-written ROUTER_SURFACE closes the third direction in BOTH directions: a served route
14
+ // the census does not claim is a violation, AND a census route the router no longer reaches
15
+ // is a violation. That second half is what makes deleting a handler branch redden the entry
16
+ // that NAMES it, rather than only the probe that happens to drive it.
17
+ //
18
+ // ROUTER_SURFACE is deliberately NOT a copy of the probe table's keys (the tautology the groq
19
+ // build wrote and §9 rejected). It drives object types and route families the probes never
20
+ // touch — companies, deals, tickets, the numeric `0-1` objectTypeId alias, the ticket pipeline —
21
+ // and each is mapped back to its canonical template by `canonicalPath`, a hand-written second
22
+ // encoding of the route shapes that the handler itself does not export.
23
+ //
24
+ // The field-name renames are checked as a round trip, not as a status: HubSpot's record carries
25
+ // `id`, `createdAt` and `updatedAt`, all three of which the kernel's META set silently DROPS from
26
+ // a stored resource, so every record probe asserts those three came BACK — a status-only check
27
+ // sails straight past that class (mistral's dropped `type` field is the precedent).
28
+ import { mkdtempSync, rmSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { handleHubspotTwinRequest, hubspotTwinSnapshot, type HubspotResponse } from './hubspot-twin.ts';
32
+
33
+ export type HubspotConformanceReport = {
34
+ ok: boolean;
35
+ endpointsClaimed: number;
36
+ endpointsProbed: number;
37
+ routerRoutesChecked: number;
38
+ violations: string[];
39
+ };
40
+
41
+ type Probe = {
42
+ method: string;
43
+ /** A CONCRETE path (sentinels substituted at run time) whose canonical template must equal the
44
+ * claimed endpoint's path — checked, not assumed. */
45
+ path: string;
46
+ body?: unknown;
47
+ /** The status(es) a WORKING handler answers with. */
48
+ status: number[];
49
+ /** What a working handler's body must look like. */
50
+ expect?: (body: unknown) => boolean;
51
+ };
52
+
53
+ const isObject = (b: unknown): b is Record<string, unknown> => !!b && typeof b === 'object' && !Array.isArray(b);
54
+ const AT = '2026-02-01T00:00:00.000Z';
55
+
56
+ // Sentinels substituted with the ids the seeded fixtures actually minted. Shaped so that no real
57
+ // HubSpot id could collide, and so a missed substitution fails loudly rather than silently.
58
+ const CONTACT = 'SENTINEL_CONTACT_ID';
59
+ const CONTACT2 = 'SENTINEL_CONTACT2_ID';
60
+ const COMPANY = 'SENTINEL_COMPANY_ID';
61
+ const DEAL = 'SENTINEL_DEAL_ID';
62
+ const TICKET = 'SENTINEL_TICKET_ID';
63
+ const PIPELINE = 'SENTINEL_PIPELINE_ID';
64
+ const STAGE_PIPELINE = 'SENTINEL_STAGE_PIPELINE';
65
+ const STAGE = 'SENTINEL_STAGE_ID';
66
+
67
+ /**
68
+ * Map a CONCRETE request path back to the canonical `{placeholder}` template the endpoint census
69
+ * names. Hand-written from the route shapes — a SECOND encoding, so a probe (or a router-census
70
+ * entry) that drifts onto a different route is caught rather than silently accepted.
71
+ */
72
+ export function canonicalPath(path: string): string {
73
+ const p = path.split('?')[0]!;
74
+ const s = p.replace(/^\/+|\/+$/g, '').split('/');
75
+ if (s[0] !== 'crm') return p;
76
+ if (s[1] === 'v3' && s[2] === 'objects') {
77
+ if (s.length === 4) return '/crm/v3/objects/{objectType}';
78
+ if (s.length === 5 && (s[4] === 'search' || s[4] === 'merge')) return `/crm/v3/objects/{objectType}/${s[4]}`;
79
+ if (s.length === 6 && s[4] === 'batch') return `/crm/v3/objects/{objectType}/batch/${s[5]}`;
80
+ if (s.length === 5) return '/crm/v3/objects/{objectType}/{objectId}';
81
+ }
82
+ if (s[1] === 'v3' && s[2] === 'properties') {
83
+ if (s.length === 4) return '/crm/v3/properties/{objectType}';
84
+ if (s.length === 5 && s[4] === 'groups') return '/crm/v3/properties/{objectType}/groups';
85
+ if (s.length === 6 && s[4] === 'groups') return '/crm/v3/properties/{objectType}/groups/{groupName}';
86
+ if (s.length === 6 && s[4] === 'batch' && s[5] === 'create') return '/crm/v3/properties/{objectType}/batch/create';
87
+ if (s.length === 5) return '/crm/v3/properties/{objectType}/{propertyName}';
88
+ }
89
+ if (s[1] === 'v3' && s[2] === 'pipelines') {
90
+ if (s.length === 4) return '/crm/v3/pipelines/{objectType}';
91
+ if (s.length === 5) return '/crm/v3/pipelines/{objectType}/{pipelineId}';
92
+ if (s.length === 6 && s[5] === 'stages') return '/crm/v3/pipelines/{objectType}/{pipelineId}/stages';
93
+ if (s.length === 7 && s[5] === 'stages') return '/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}';
94
+ }
95
+ if (s[1] === 'v3' && s[2] === 'owners') {
96
+ if (s.length === 3) return '/crm/v3/owners';
97
+ if (s.length === 4) return '/crm/v3/owners/{ownerId}';
98
+ }
99
+ if (s[1] === 'v4' && s[2] === 'objects' && s[4] !== undefined && s[5] === 'associations') {
100
+ if (s.length === 7) return '/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}';
101
+ if (s.length === 9 && s[6] === 'default') return '/crm/v4/objects/{objectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}';
102
+ if (s.length === 8) return '/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}';
103
+ }
104
+ if (s[1] === 'v4' && s[2] === 'associations' && s[5] === 'batch' && s.length === 7) {
105
+ return `/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/${s[6]}`;
106
+ }
107
+ return p;
108
+ }
109
+
110
+ /** A record body must carry the three field names the kernel projection reserves. */
111
+ function isRecord(b: unknown): boolean {
112
+ return isObject(b)
113
+ && typeof b.id === 'string' && b.id !== ''
114
+ && isObject(b.properties)
115
+ && typeof b.createdAt === 'string' && b.createdAt !== ''
116
+ && typeof b.updatedAt === 'string' && b.updatedAt !== '';
117
+ }
118
+ function isCollection(b: unknown, minResults = 1): boolean {
119
+ return isObject(b) && Array.isArray(b.results) && b.results.length >= minResults;
120
+ }
121
+ function isBatch(b: unknown, minResults = 1): boolean {
122
+ return isObject(b) && b.status === 'COMPLETE' && Array.isArray(b.results) && b.results.length >= minResults
123
+ && typeof b.startedAt === 'string' && typeof b.completedAt === 'string';
124
+ }
125
+ const any_ = (b: unknown) => b as Record<string, any>;
126
+
127
+ /** A representative request per claimed endpoint, with the outcome a LIVE handler produces. */
128
+ const PROBES: Record<string, Probe> = {
129
+ // ── CRM objects ──
130
+ 'POST /crm/v3/objects/{objectType}': {
131
+ method: 'POST', path: '/crm/v3/objects/contacts', body: { properties: { email: 'probe@twin.test', firstname: 'Probe' } },
132
+ status: [201],
133
+ expect: (b) => isRecord(b) && any_(b).properties.email === 'probe@twin.test' && any_(b).archived === false,
134
+ },
135
+ 'GET /crm/v3/objects/{objectType}': {
136
+ method: 'GET', path: '/crm/v3/objects/contacts?limit=1', status: [200],
137
+ expect: (b) => isCollection(b) && isRecord(any_(b).results[0]),
138
+ },
139
+ 'GET /crm/v3/objects/{objectType}/{objectId}': {
140
+ method: 'GET', path: `/crm/v3/objects/contacts/${CONTACT}`, status: [200],
141
+ expect: (b) => isRecord(b) && any_(b).properties.hs_object_id === any_(b).id,
142
+ },
143
+ 'PATCH /crm/v3/objects/{objectType}/{objectId}': {
144
+ method: 'PATCH', path: `/crm/v3/objects/contacts/${CONTACT}`, body: { properties: { lastname: 'Patched' } },
145
+ status: [200], expect: (b) => isRecord(b) && any_(b).properties.lastname === 'Patched',
146
+ },
147
+ 'DELETE /crm/v3/objects/{objectType}/{objectId}': {
148
+ method: 'DELETE', path: `/crm/v3/objects/contacts/${CONTACT2}`, status: [204], expect: (b) => b === null,
149
+ },
150
+ 'POST /crm/v3/objects/{objectType}/search': {
151
+ method: 'POST', path: '/crm/v3/objects/contacts/search',
152
+ body: { filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: 'probe@twin.test' }] }] },
153
+ status: [200],
154
+ expect: (b) => isObject(b) && typeof b.total === 'number' && b.total >= 1 && Array.isArray(b.results) && isRecord(b.results[0]),
155
+ },
156
+ 'POST /crm/v3/objects/{objectType}/merge': {
157
+ method: 'POST', path: '/crm/v3/objects/contacts/merge', body: { primaryObjectId: CONTACT, objectIdToMerge: 'SENTINEL_MERGE_ID' },
158
+ status: [200],
159
+ // HubSpot keeps the PRIMARY's values and fills only its blanks from the secondary: the
160
+ // primary's e-mail survives while the secondary's phone (which the primary lacks) arrives.
161
+ expect: (b) => isRecord(b) && any_(b).properties.email === 'probe@twin.test' && any_(b).properties.phone === '+15550199',
162
+ },
163
+ 'POST /crm/v3/objects/{objectType}/batch/create': {
164
+ method: 'POST', path: '/crm/v3/objects/contacts/batch/create', body: { inputs: [{ properties: { email: 'batch1@twin.test' } }] },
165
+ status: [201], expect: (b) => isBatch(b) && isRecord(any_(b).results[0]),
166
+ },
167
+ 'POST /crm/v3/objects/{objectType}/batch/read': {
168
+ method: 'POST', path: '/crm/v3/objects/contacts/batch/read', body: { properties: ['email'], inputs: [{ id: CONTACT }] },
169
+ status: [200, 207], expect: (b) => isBatch(b) && any_(b).results[0].properties.email === 'probe@twin.test',
170
+ },
171
+ 'POST /crm/v3/objects/{objectType}/batch/update': {
172
+ method: 'POST', path: '/crm/v3/objects/contacts/batch/update', body: { inputs: [{ id: CONTACT, properties: { phone: '+15550100' } }] },
173
+ status: [200, 207], expect: (b) => isBatch(b) && any_(b).results[0].properties.phone === '+15550100',
174
+ },
175
+ 'POST /crm/v3/objects/{objectType}/batch/upsert': {
176
+ method: 'POST', path: '/crm/v3/objects/contacts/batch/upsert', body: { inputs: [{ idProperty: 'email', id: 'upsert@twin.test', properties: { firstname: 'Ups' } }] },
177
+ status: [200, 207], expect: (b) => isBatch(b) && any_(b).results[0].new === true,
178
+ },
179
+ 'POST /crm/v3/objects/{objectType}/batch/archive': {
180
+ method: 'POST', path: '/crm/v3/objects/contacts/batch/archive', body: { inputs: [{ id: 'SENTINEL_BATCH_ARCHIVE_ID' }] },
181
+ status: [204], expect: (b) => b === null,
182
+ },
183
+ // ── properties ──
184
+ 'GET /crm/v3/properties/{objectType}': {
185
+ method: 'GET', path: '/crm/v3/properties/contacts', status: [200],
186
+ // HubSpot-defined contact property names — literals, so the assertion cannot drift with the
187
+ // handler's own seed table.
188
+ expect: (b) => isCollection(b, 5) && ['email', 'firstname', 'lastname', 'hs_object_id'].every((n) => any_(b).results.some((p: any) => p.name === n && p.hubspotDefined === true)),
189
+ },
190
+ 'POST /crm/v3/properties/{objectType}': {
191
+ method: 'POST', path: '/crm/v3/properties/contacts',
192
+ body: { name: 'probe_custom', label: 'Probe Custom', type: 'string', fieldType: 'text', groupName: 'contactinformation' },
193
+ status: [201], expect: (b) => isObject(b) && b.name === 'probe_custom' && b.fieldType === 'text' && b.hubspotDefined === false,
194
+ },
195
+ 'POST /crm/v3/properties/{objectType}/batch/create': {
196
+ method: 'POST', path: '/crm/v3/properties/contacts/batch/create',
197
+ body: { inputs: [{ name: 'probe_batch_a', label: 'Probe Batch A', type: 'string', fieldType: 'text', groupName: 'contactinformation', formField: true }] },
198
+ status: [201], expect: (b) => isBatch(b) && any_(b).results[0].name === 'probe_batch_a' && any_(b).results[0].formField === true,
199
+ },
200
+ 'GET /crm/v3/properties/{objectType}/{propertyName}': {
201
+ method: 'GET', path: '/crm/v3/properties/contacts/email', status: [200],
202
+ expect: (b) => isObject(b) && b.name === 'email' && b.hubspotDefined === true && b.groupName === 'contactinformation',
203
+ },
204
+ 'PATCH /crm/v3/properties/{objectType}/{propertyName}': {
205
+ method: 'PATCH', path: '/crm/v3/properties/contacts/email', body: { label: 'Probe Relabelled' },
206
+ status: [200], expect: (b) => isObject(b) && b.label === 'Probe Relabelled' && b.name === 'email',
207
+ },
208
+ 'DELETE /crm/v3/properties/{objectType}/{propertyName}': {
209
+ method: 'DELETE', path: '/crm/v3/properties/contacts/probe_deletable', status: [204], expect: (b) => b === null,
210
+ },
211
+ 'GET /crm/v3/properties/{objectType}/groups': {
212
+ method: 'GET', path: '/crm/v3/properties/contacts/groups', status: [200],
213
+ expect: (b) => isCollection(b) && any_(b).results.some((g: any) => g.name === 'contactinformation'),
214
+ },
215
+ 'POST /crm/v3/properties/{objectType}/groups': {
216
+ method: 'POST', path: '/crm/v3/properties/contacts/groups', body: { name: 'probe_group', label: 'Probe Group' },
217
+ status: [201], expect: (b) => isObject(b) && b.name === 'probe_group' && b.label === 'Probe Group',
218
+ },
219
+ 'GET /crm/v3/properties/{objectType}/groups/{groupName}': {
220
+ method: 'GET', path: '/crm/v3/properties/contacts/groups/contactinformation', status: [200],
221
+ expect: (b) => isObject(b) && b.name === 'contactinformation' && typeof b.label === 'string',
222
+ },
223
+ 'PATCH /crm/v3/properties/{objectType}/groups/{groupName}': {
224
+ method: 'PATCH', path: '/crm/v3/properties/contacts/groups/contactinformation', body: { label: 'Probe Group Relabelled' },
225
+ status: [200], expect: (b) => isObject(b) && b.label === 'Probe Group Relabelled',
226
+ },
227
+ 'DELETE /crm/v3/properties/{objectType}/groups/{groupName}': {
228
+ method: 'DELETE', path: '/crm/v3/properties/contacts/groups/probe_deletable_group', status: [204], expect: (b) => b === null,
229
+ },
230
+ // ── pipelines ──
231
+ 'GET /crm/v3/pipelines/{objectType}': {
232
+ method: 'GET', path: '/crm/v3/pipelines/deals', status: [200],
233
+ // HubSpot's out-of-the-box deal pipeline id and two of its stage ids — literals.
234
+ expect: (b) => isCollection(b) && any_(b).results.some((p: any) => p.id === 'default' && Array.isArray(p.stages)
235
+ && p.stages.some((s: any) => s.id === 'closedwon') && p.stages.some((s: any) => s.id === 'appointmentscheduled')),
236
+ },
237
+ 'POST /crm/v3/pipelines/{objectType}': {
238
+ method: 'POST', path: '/crm/v3/pipelines/deals', body: { label: 'Probe Pipeline', displayOrder: 3, stages: [{ label: 'Probe Stage', displayOrder: 0 }] },
239
+ status: [201], expect: (b) => isObject(b) && b.label === 'Probe Pipeline' && Array.isArray(b.stages) && (b.stages as any[]).length === 1,
240
+ },
241
+ 'GET /crm/v3/pipelines/{objectType}/{pipelineId}': {
242
+ method: 'GET', path: `/crm/v3/pipelines/deals/${PIPELINE}`, status: [200],
243
+ expect: (b) => isObject(b) && b.label === 'Probe Fixture Pipeline' && Array.isArray(b.stages),
244
+ },
245
+ 'PATCH /crm/v3/pipelines/{objectType}/{pipelineId}': {
246
+ method: 'PATCH', path: `/crm/v3/pipelines/deals/${PIPELINE}`, body: { label: 'Probe Pipeline Renamed' },
247
+ status: [200], expect: (b) => isObject(b) && b.label === 'Probe Pipeline Renamed',
248
+ },
249
+ 'PUT /crm/v3/pipelines/{objectType}/{pipelineId}': {
250
+ method: 'PUT', path: `/crm/v3/pipelines/deals/${PIPELINE}`, body: { label: 'Probe Pipeline Replaced', displayOrder: 4, stages: [{ label: 'Only Stage', displayOrder: 0 }] },
251
+ status: [200], expect: (b) => isObject(b) && b.label === 'Probe Pipeline Replaced' && (b.stages as any[]).length === 1,
252
+ },
253
+ 'DELETE /crm/v3/pipelines/{objectType}/{pipelineId}': {
254
+ method: 'DELETE', path: '/crm/v3/pipelines/deals/SENTINEL_DELETABLE_PIPELINE', status: [204], expect: (b) => b === null,
255
+ },
256
+ // The stage family drives its OWN pipeline: the `PUT /pipelines/{id}` probe above REPLACES
257
+ // that pipeline's stages, which would invalidate a stage id captured before it ran.
258
+ 'GET /crm/v3/pipelines/{objectType}/{pipelineId}/stages': {
259
+ method: 'GET', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages`, status: [200],
260
+ expect: (b) => isCollection(b, 2) && any_(b).results.every((s: any) => typeof s.id === 'string' && typeof s.label === 'string'),
261
+ },
262
+ 'POST /crm/v3/pipelines/{objectType}/{pipelineId}/stages': {
263
+ method: 'POST', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages`, body: { label: 'Added Stage', displayOrder: 9 },
264
+ status: [201], expect: (b) => isObject(b) && b.label === 'Added Stage' && b.displayOrder === 9,
265
+ },
266
+ 'GET /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
267
+ method: 'GET', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/${STAGE}`, status: [200],
268
+ expect: (b) => isObject(b) && b.label === 'S1' && typeof b.displayOrder === 'number',
269
+ },
270
+ 'PATCH /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
271
+ method: 'PATCH', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/${STAGE}`, body: { label: 'Stage Renamed' },
272
+ status: [200], expect: (b) => isObject(b) && b.label === 'Stage Renamed',
273
+ },
274
+ 'PUT /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
275
+ method: 'PUT', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/${STAGE}`, body: { label: 'Stage Replaced', displayOrder: 1 },
276
+ status: [200], expect: (b) => isObject(b) && b.label === 'Stage Replaced' && b.displayOrder === 1,
277
+ },
278
+ 'DELETE /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
279
+ method: 'DELETE', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/SENTINEL_DELETABLE_STAGE`, status: [204], expect: (b) => b === null,
280
+ },
281
+ // ── owners ──
282
+ 'GET /crm/v3/owners': {
283
+ method: 'GET', path: '/crm/v3/owners', status: [200],
284
+ expect: (b) => isCollection(b) && any_(b).results.every((o: any) => typeof o.id === 'string' && ['PERSON', 'QUEUE'].includes(o.type)),
285
+ },
286
+ 'GET /crm/v3/owners/{ownerId}': {
287
+ method: 'GET', path: '/crm/v3/owners/1', status: [200],
288
+ expect: (b) => isObject(b) && b.id === '1' && b.type === 'PERSON' && typeof b.email === 'string',
289
+ },
290
+ // ── associations v4 ──
291
+ 'GET /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}': {
292
+ // Driven against the fixture contact that ALREADY HAS an association. §9 round one caught the
293
+ // first version pointing at CONTACT, whose associations are only created by probes that run
294
+ // LATER in the census order — so the body was `{results: []}` and `length >= 0` plus
295
+ // `[].every(...)` were both vacuously true. A handler returning a constant empty list passed.
296
+ method: 'GET', path: '/crm/v4/objects/contacts/SENTINEL_ASSOC_CONTACT/associations/companies', status: [200],
297
+ expect: (b) => isCollection(b, 1)
298
+ && any_(b).results.some((r: any) => typeof r.toObjectId === 'string' && r.toObjectId !== ''
299
+ && Array.isArray(r.associationTypes) && r.associationTypes.some((t: any) => t.typeId === 279 && t.category === 'HUBSPOT_DEFINED')),
300
+ },
301
+ 'PUT /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}': {
302
+ method: 'PUT', path: `/crm/v4/objects/contacts/${CONTACT}/associations/companies/${COMPANY}`,
303
+ // `AssociationSpec` is a CLOSED two-key shape — there is no `label` on the request; HubSpot
304
+ // resolves labels from the association-type DEFINITION, which this twin does not model, so
305
+ // `labels` is empty and `fromObjectTypeId`/`toObjectTypeId` carry the NUMERIC objectTypeIds.
306
+ body: [{ associationCategory: 'USER_DEFINED', associationTypeId: 42 }],
307
+ status: [201],
308
+ expect: (b) => isObject(b) && Array.isArray(b.labels) && (b.labels as string[]).length === 0
309
+ && b.fromObjectTypeId === '0-1' && b.toObjectTypeId === '0-2' && typeof b.toObjectId === 'string',
310
+ },
311
+ 'DELETE /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}': {
312
+ method: 'DELETE', path: '/crm/v4/objects/contacts/SENTINEL_ASSOC_CONTACT/associations/companies/SENTINEL_ASSOC_COMPANY',
313
+ status: [204], expect: (b) => b === null,
314
+ },
315
+ 'PUT /crm/v4/objects/{objectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}': {
316
+ method: 'PUT', path: `/crm/v4/objects/contacts/${CONTACT}/associations/default/companies/${COMPANY}`, status: [200],
317
+ // 279 is HubSpot's documented contact→company default association type id — a literal.
318
+ expect: (b) => isObject(b) && Array.isArray(b.results) && (b.results as any[])[0]?.associationSpec?.associationTypeId === 279,
319
+ },
320
+ 'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read': {
321
+ method: 'POST', path: '/crm/v4/associations/contacts/companies/batch/read', body: { inputs: [{ id: CONTACT }] },
322
+ status: [200], expect: (b) => isBatch(b) && Array.isArray(any_(b).results[0].to),
323
+ },
324
+ 'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create': {
325
+ method: 'POST', path: '/crm/v4/associations/contacts/companies/batch/create',
326
+ body: { inputs: [{ from: { id: CONTACT }, to: { id: COMPANY }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 279 }] }] },
327
+ status: [201], expect: (b) => isBatch(b) && any_(b).results[0].fromObjectTypeId === '0-1' && any_(b).results[0].toObjectTypeId === '0-2',
328
+ },
329
+ 'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive': {
330
+ method: 'POST', path: '/crm/v4/associations/contacts/companies/batch/archive',
331
+ body: { inputs: [{ from: { id: 'SENTINEL_ASSOC2_CONTACT' }, to: [{ id: 'SENTINEL_ASSOC2_COMPANY' }] }] },
332
+ status: [204], expect: (b) => b === null,
333
+ },
334
+ };
335
+
336
+ /**
337
+ * THE ROUTER CENSUS — method/path pairs `handleHubspotTwinRequest` branches on, enumerated BY HAND
338
+ * from the dispatch. Deliberately NOT the probe table's keys: these drive object types and route
339
+ * families the probes never touch, so the generic `{objectType}` branches are proven reachable for
340
+ * more than the one type the probes use.
341
+ */
342
+ type RouterRoute = [method: string, path: string, status: number[], body?: unknown];
343
+ const ROUTER_SURFACE: RouterRoute[] = [
344
+ // Each entry declares the STATUS a LIVE branch answers with. §9 round one caught the first
345
+ // version grading only "is this the generic 404 miss?", which meant a branch that degraded to
346
+ // a 400/405/500 — or one whose objectType alias table was deleted, so `GET /crm/v3/objects/0-1`
347
+ // started answering "Unable to infer object type from: 0-1" — stayed GREEN in the census that
348
+ // names it. That is the tinybird "teeth at the dispatch and nowhere deeper" trap, surviving in
349
+ // the census half. The status set closes it.
350
+ ['POST', '/crm/v3/objects/companies', [400]], // no properties in the body
351
+ ['GET', '/crm/v3/objects/companies', [200]],
352
+ ['GET', `/crm/v3/objects/companies/${COMPANY}`, [200]],
353
+ ['PATCH', `/crm/v3/objects/companies/${COMPANY}`, [400]], // no properties in the body
354
+ ['GET', '/crm/v3/objects/deals', [200]],
355
+ ['GET', `/crm/v3/objects/deals/${DEAL}`, [200]],
356
+ ['GET', '/crm/v3/objects/tickets', [200]],
357
+ ['GET', `/crm/v3/objects/tickets/${TICKET}`, [200]],
358
+ ['POST', '/crm/v3/objects/deals/search', [200]],
359
+ // A REAL inputs array, so the request reaches the `op === 'read'` branch rather than
360
+ // stopping at the shared `inputs must be an array` guard above it. §9 round two: the earlier
361
+ // `[400]` entries were satisfied by that guard, so the branch they NAME stayed deletable.
362
+ ['POST', '/crm/v3/objects/tickets/batch/read', [200, 207], { properties: [], inputs: [{ id: '424242' }] }],
363
+ // the numeric objectTypeId alias HubSpot also accepts for contacts — a 200 here, so deleting
364
+ // OBJECT_TYPE_IDS reddens THIS entry rather than only a capability elsewhere
365
+ ['GET', '/crm/v3/objects/0-1', [200]],
366
+ // properties + groups on a second object type
367
+ ['GET', '/crm/v3/properties/deals', [200]],
368
+ ['GET', '/crm/v3/properties/deals/groups', [200]],
369
+ ['GET', '/crm/v3/properties/deals/dealstage', [200]],
370
+ ['POST', '/crm/v3/properties/deals/batch/create', [400]], // no inputs array
371
+ // the pipelines family on tickets — HubSpot's other pipelined object
372
+ ['GET', '/crm/v3/pipelines/tickets', [200]],
373
+ ['GET', '/crm/v3/pipelines/tickets/0', [200]],
374
+ ['GET', '/crm/v3/pipelines/tickets/0/stages', [200]],
375
+ ['GET', '/crm/v3/pipelines/tickets/0/stages/1', [200]],
376
+ ['GET', '/crm/v3/owners', [200]],
377
+ ['GET', '/crm/v3/owners/1', [200]],
378
+ // associations in the other direction
379
+ // The company that HAS an association — a 200-on-empty would be the same vacuity round one
380
+ // removed from the probe half.
381
+ ['GET', '/crm/v4/objects/companies/SENTINEL_ASSOC_COMPANY/associations/contacts', [200]],
382
+ ['POST', '/crm/v4/associations/deals/contacts/batch/read', [404], { inputs: [{ id: '424242' }] }], // reaches the branch; the record miss is its own 404
383
+ ];
384
+
385
+ /** The generic router miss — distinct in MESSAGE from a real record-not-found 404. */
386
+ function isRouterMiss(res: HubspotResponse): boolean {
387
+ return res.status === 404
388
+ && isObject(res.body)
389
+ && typeof res.body.message === 'string'
390
+ && res.body.message.startsWith('resource not found: ');
391
+ }
392
+
393
+ export async function checkHubspotConformance(opts: { root?: string } = {}): Promise<HubspotConformanceReport> {
394
+ void opts; // always a THROWAWAY root: the probes create, archive and merge real records.
395
+ const snapshot = hubspotTwinSnapshot();
396
+ const claimed = new Set(snapshot.implementedEndpoints);
397
+ const violations: string[] = [];
398
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-conformance-'));
399
+ const call = (method: string, path: string, body?: unknown): Promise<HubspotResponse> =>
400
+ handleHubspotTwinRequest({ method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }), root, occurredAt: AT });
401
+
402
+ let probed = 0;
403
+ try {
404
+ // ── fixtures the probes address ───────────────────────────────────────────────────────
405
+ const mk = async (type: string, props: Record<string, string>): Promise<string> => {
406
+ const res = await call('POST', `/crm/v3/objects/${type}`, { properties: props });
407
+ const id = isObject(res.body) ? String(res.body.id ?? '') : '';
408
+ if (res.status !== 201 || !id) violations.push(`fixture: POST /crm/v3/objects/${type} answered ${res.status}, so every probe addressing it is unverified`);
409
+ return id;
410
+ };
411
+ const contactId = await mk('contacts', { email: 'probe@twin.test', firstname: 'Probe' });
412
+ const contact2Id = await mk('contacts', { email: 'archivable@twin.test' });
413
+ const mergeId = await mk('contacts', { email: 'mergeable@twin.test', phone: '+15550199' });
414
+ const batchArchiveId = await mk('contacts', { email: 'batch-archivable@twin.test' });
415
+ const assocContactId = await mk('contacts', { email: 'assoc@twin.test' });
416
+ const assoc2ContactId = await mk('contacts', { email: 'assoc2@twin.test' });
417
+ const companyId = await mk('companies', { name: 'Probe Co', domain: 'probe.test' });
418
+ const assocCompanyId = await mk('companies', { name: 'Assoc Co' });
419
+ const assoc2CompanyId = await mk('companies', { name: 'Assoc2 Co' });
420
+ const dealId = await mk('deals', { dealname: 'Probe Deal', amount: '1200', dealstage: 'closedwon' });
421
+ const ticketId = await mk('tickets', { subject: 'Probe Ticket', hs_pipeline_stage: '1' });
422
+ // Each destructive probe gets its OWN fixture, so probe ORDER cannot make this check lie.
423
+ await call('PUT', `/crm/v4/objects/contacts/${assocContactId}/associations/default/companies/${assocCompanyId}`);
424
+ await call('PUT', `/crm/v4/objects/contacts/${assoc2ContactId}/associations/default/companies/${assoc2CompanyId}`);
425
+ await call('POST', '/crm/v3/properties/contacts', { name: 'probe_deletable', label: 'Deletable', type: 'string', fieldType: 'text', groupName: 'contactinformation' });
426
+ await call('POST', '/crm/v3/properties/contacts/groups', { name: 'probe_deletable_group', label: 'Deletable Group' });
427
+ const mkPipeline = async (label: string, stages: Record<string, unknown>[]): Promise<string> => {
428
+ const res = await call('POST', '/crm/v3/pipelines/deals', { label, displayOrder: 5, stages });
429
+ return isObject(res.body) ? String(res.body.id ?? '') : '';
430
+ };
431
+ const pipelineId = await mkPipeline('Probe Fixture Pipeline', [{ label: 'S1', displayOrder: 0 }]);
432
+ const deletablePipelineId = await mkPipeline('Deletable Pipeline', [{ label: 'S1', displayOrder: 0 }]);
433
+ const stagePipelineId = await mkPipeline('Stage Fixture Pipeline', [{ label: 'S1', displayOrder: 0 }, { label: 'S2', displayOrder: 1 }]);
434
+ const stagesOf = async (pid: string): Promise<string[]> => {
435
+ const res = await call('GET', `/crm/v3/pipelines/deals/${pid}`);
436
+ return isObject(res.body) && Array.isArray(res.body.stages) ? (res.body.stages as any[]).map((s) => String(s.id)) : [];
437
+ };
438
+ const fixtureStages = await stagesOf(stagePipelineId);
439
+ const stageId = fixtureStages[0] ?? '';
440
+ const deletableStageId = fixtureStages[1] ?? '';
441
+
442
+ // Longest sentinel first: CONTACT is a prefix of nothing, but CONTACT2 contains CONTACT's
443
+ // stem, so order matters (the §0.5 longest-match-first rule, applied to substitution).
444
+ const substitute = (s: string): string => s
445
+ .replaceAll(CONTACT2, contact2Id)
446
+ .replaceAll(CONTACT, contactId)
447
+ .replaceAll(COMPANY, companyId)
448
+ .replaceAll(DEAL, dealId)
449
+ .replaceAll(TICKET, ticketId)
450
+ .replaceAll('SENTINEL_MERGE_ID', mergeId)
451
+ .replaceAll('SENTINEL_BATCH_ARCHIVE_ID', batchArchiveId)
452
+ .replaceAll('SENTINEL_ASSOC2_CONTACT', assoc2ContactId)
453
+ .replaceAll('SENTINEL_ASSOC2_COMPANY', assoc2CompanyId)
454
+ .replaceAll('SENTINEL_ASSOC_CONTACT', assocContactId)
455
+ .replaceAll('SENTINEL_ASSOC_COMPANY', assocCompanyId)
456
+ .replaceAll('SENTINEL_DELETABLE_PIPELINE', deletablePipelineId)
457
+ .replaceAll(STAGE_PIPELINE, stagePipelineId)
458
+ .replaceAll('SENTINEL_DELETABLE_STAGE', deletableStageId)
459
+ .replaceAll(PIPELINE, pipelineId)
460
+ .replaceAll(STAGE, stageId);
461
+ const substituteBody = (b: unknown): unknown => (b === undefined ? undefined : JSON.parse(substitute(JSON.stringify(b))) as unknown);
462
+
463
+ // ── the ROUTER CENSUS, both directions ────────────────────────────────────────────────
464
+ for (const [method, rawPath, expected, routeBody] of ROUTER_SURFACE) {
465
+ const res = await call(method, substitute(rawPath), substituteBody(routeBody));
466
+ const key = `${method} ${canonicalPath(rawPath)}`;
467
+ if (isRouterMiss(res)) {
468
+ violations.push(`the router census names '${method} ${rawPath}' (canonically '${key}') but the router no longer REACHES it — it fell through to the generic miss, so the handler branch that served it is gone`);
469
+ continue;
470
+ }
471
+ if (!expected.includes(res.status)) {
472
+ violations.push(`the router census names '${method} ${rawPath}' and a live branch answers [${expected.join(', ')}] there, but it answered ${res.status} — ${JSON.stringify(res.body).slice(0, 200)}`);
473
+ continue;
474
+ }
475
+ if (!claimed.has(key)) {
476
+ violations.push(`the router answers '${method} ${rawPath}' (${res.status}) whose canonical form '${key}' the endpoint census does not claim — served surface outside the census is deletable without this check noticing`);
477
+ }
478
+ }
479
+
480
+ // ── probe⇄snapshot bijection, then one LIVE request per claimed endpoint ──────────────
481
+ for (const endpoint of snapshot.implementedEndpoints) {
482
+ const probe = PROBES[endpoint];
483
+ if (!probe) { violations.push(`endpoint '${endpoint}' is claimed but has no conformance probe — the claim is unverified`); continue; }
484
+ const [claimedMethod, claimedPath] = endpoint.split(' ');
485
+ if (probe.method !== claimedMethod) { violations.push(`probe '${endpoint}' drives ${probe.method}, not ${claimedMethod}`); continue; }
486
+ if (canonicalPath(probe.path) !== claimedPath) {
487
+ violations.push(`probe '${endpoint}' drives ${probe.path}, whose canonical form is ${canonicalPath(probe.path)} — not the claimed path ${claimedPath}`);
488
+ continue;
489
+ }
490
+ const res = await call(probe.method, substitute(probe.path), substituteBody(probe.body));
491
+ probed += 1;
492
+ if (!probe.status.includes(res.status)) {
493
+ violations.push(`${endpoint}: answered ${res.status}, expected one of [${probe.status.join(', ')}] — body ${JSON.stringify(res.body).slice(0, 200)}`);
494
+ continue;
495
+ }
496
+ if (probe.expect && !probe.expect(res.body)) {
497
+ violations.push(`${endpoint}: status ${res.status} was right but the body is not what a live handler produces — ${JSON.stringify(res.body).slice(0, 240)}`);
498
+ }
499
+ }
500
+ for (const key of Object.keys(PROBES)) {
501
+ if (!claimed.has(key)) violations.push(`probe '${key}' exercises an endpoint the census does not claim — the two must biject`);
502
+ }
503
+
504
+ // ── the error envelope + the determinism invariant, driven for real ───────────────────
505
+ const miss = await call('GET', '/crm/v3/objects/contacts/99999999');
506
+ const missBody = miss.body as Record<string, unknown> | null;
507
+ if (miss.status !== 404 || !isObject(missBody) || missBody.status !== 'error' || missBody.category !== 'OBJECT_NOT_FOUND'
508
+ || typeof missBody.message !== 'string'
509
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(missBody.correlationId))) {
510
+ violations.push(`an unknown record id must answer HubSpot's documented error envelope { status:'error', message, correlationId (uuid-shaped), category:'OBJECT_NOT_FOUND' } — got ${miss.status} ${JSON.stringify(missBody)}`);
511
+ }
512
+ const badType = await call('GET', '/crm/v3/objects/wombats');
513
+ if (badType.status !== 400 || !isObject(badType.body) || badType.body.category !== 'VALIDATION_ERROR'
514
+ || !String(badType.body.message).startsWith('Unable to infer object type from:')) {
515
+ violations.push(`an unknown objectType must answer 400 VALIDATION_ERROR "Unable to infer object type from: …" — got ${badType.status} ${JSON.stringify(badType.body)}`);
516
+ }
517
+ const headed = await call('GET', '/crm/v3/objects/contacts');
518
+ if (headed.headers?.['x-hubspot-ratelimit-max'] !== '190' || headed.headers?.['x-hubspot-ratelimit-interval-milliseconds'] !== '10000' || headed.headers?.['x-hubspot-ratelimit-daily'] !== '625000') {
519
+ violations.push(`every response must carry HubSpot's documented rate-limit policy headers (Max 190 / Interval 10000 / Daily 625000) — got ${JSON.stringify(headed.headers)}`);
520
+ }
521
+ const a = await call('GET', '/crm/v3/objects/contacts/99999999');
522
+ const b = await call('GET', '/crm/v3/objects/contacts/99999999');
523
+ if (JSON.stringify(a.body) !== JSON.stringify(b.body)) {
524
+ violations.push('two identical requests produced different bodies — the correlationId (or something else) is not derived, and the serve path is not replayable');
525
+ }
526
+ } finally {
527
+ rmSync(root, { recursive: true, force: true });
528
+ }
529
+
530
+ return {
531
+ ok: violations.length === 0,
532
+ endpointsClaimed: snapshot.implementedEndpoints.length,
533
+ endpointsProbed: probed,
534
+ routerRoutesChecked: ROUTER_SURFACE.length,
535
+ violations,
536
+ };
537
+ }