@memberjunction/integration-connectors 5.51.0 → 6.1.0-edge.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 (155) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +109 -277
  3. package/dist/index.d.ts +0 -36
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +7 -38
  6. package/dist/index.js.map +1 -1
  7. package/package.json +14 -19
  8. package/dist/AptifyConnector.d.ts +0 -95
  9. package/dist/AptifyConnector.d.ts.map +0 -1
  10. package/dist/AptifyConnector.js +0 -760
  11. package/dist/AptifyConnector.js.map +0 -1
  12. package/dist/BlackbaudConnector.d.ts +0 -64
  13. package/dist/BlackbaudConnector.d.ts.map +0 -1
  14. package/dist/BlackbaudConnector.js +0 -916
  15. package/dist/BlackbaudConnector.js.map +0 -1
  16. package/dist/ConstantContactConnector.d.ts +0 -137
  17. package/dist/ConstantContactConnector.d.ts.map +0 -1
  18. package/dist/ConstantContactConnector.js +0 -663
  19. package/dist/ConstantContactConnector.js.map +0 -1
  20. package/dist/CventConnector.d.ts +0 -146
  21. package/dist/CventConnector.d.ts.map +0 -1
  22. package/dist/CventConnector.js +0 -540
  23. package/dist/CventConnector.js.map +0 -1
  24. package/dist/DynamicsDataverseConnector.d.ts +0 -296
  25. package/dist/DynamicsDataverseConnector.d.ts.map +0 -1
  26. package/dist/DynamicsDataverseConnector.js +0 -790
  27. package/dist/DynamicsDataverseConnector.js.map +0 -1
  28. package/dist/FileFeedConnector.d.ts +0 -64
  29. package/dist/FileFeedConnector.d.ts.map +0 -1
  30. package/dist/FileFeedConnector.js +0 -190
  31. package/dist/FileFeedConnector.js.map +0 -1
  32. package/dist/FontevaConnector.d.ts +0 -153
  33. package/dist/FontevaConnector.d.ts.map +0 -1
  34. package/dist/FontevaConnector.js +0 -421
  35. package/dist/FontevaConnector.js.map +0 -1
  36. package/dist/GrowthZoneConnector.d.ts +0 -183
  37. package/dist/GrowthZoneConnector.d.ts.map +0 -1
  38. package/dist/GrowthZoneConnector.js +0 -702
  39. package/dist/GrowthZoneConnector.js.map +0 -1
  40. package/dist/HivebriteConnector.d.ts +0 -151
  41. package/dist/HivebriteConnector.d.ts.map +0 -1
  42. package/dist/HivebriteConnector.js +0 -533
  43. package/dist/HivebriteConnector.js.map +0 -1
  44. package/dist/HubSpotConnector.d.ts +0 -523
  45. package/dist/HubSpotConnector.d.ts.map +0 -1
  46. package/dist/HubSpotConnector.js +0 -3110
  47. package/dist/HubSpotConnector.js.map +0 -1
  48. package/dist/IMISConnector.d.ts +0 -171
  49. package/dist/IMISConnector.d.ts.map +0 -1
  50. package/dist/IMISConnector.js +0 -695
  51. package/dist/IMISConnector.js.map +0 -1
  52. package/dist/MJToMJConnector.d.ts +0 -279
  53. package/dist/MJToMJConnector.d.ts.map +0 -1
  54. package/dist/MJToMJConnector.js +0 -893
  55. package/dist/MJToMJConnector.js.map +0 -1
  56. package/dist/MagnetMailConnector.d.ts +0 -163
  57. package/dist/MagnetMailConnector.d.ts.map +0 -1
  58. package/dist/MagnetMailConnector.js +0 -823
  59. package/dist/MagnetMailConnector.js.map +0 -1
  60. package/dist/MailchimpConnector.d.ts +0 -197
  61. package/dist/MailchimpConnector.d.ts.map +0 -1
  62. package/dist/MailchimpConnector.js +0 -791
  63. package/dist/MailchimpConnector.js.map +0 -1
  64. package/dist/MemberSuiteConnector.d.ts +0 -185
  65. package/dist/MemberSuiteConnector.d.ts.map +0 -1
  66. package/dist/MemberSuiteConnector.js +0 -762
  67. package/dist/MemberSuiteConnector.js.map +0 -1
  68. package/dist/NeonCRMConnector.d.ts +0 -273
  69. package/dist/NeonCRMConnector.d.ts.map +0 -1
  70. package/dist/NeonCRMConnector.js +0 -1039
  71. package/dist/NeonCRMConnector.js.map +0 -1
  72. package/dist/NetForumConnector.d.ts +0 -170
  73. package/dist/NetForumConnector.d.ts.map +0 -1
  74. package/dist/NetForumConnector.js +0 -778
  75. package/dist/NetForumConnector.js.map +0 -1
  76. package/dist/NetSuiteConnector.d.ts +0 -136
  77. package/dist/NetSuiteConnector.d.ts.map +0 -1
  78. package/dist/NetSuiteConnector.js +0 -703
  79. package/dist/NetSuiteConnector.js.map +0 -1
  80. package/dist/NimbleAMSConnector.d.ts +0 -142
  81. package/dist/NimbleAMSConnector.d.ts.map +0 -1
  82. package/dist/NimbleAMSConnector.js +0 -703
  83. package/dist/NimbleAMSConnector.js.map +0 -1
  84. package/dist/NoviConnector.d.ts +0 -178
  85. package/dist/NoviConnector.d.ts.map +0 -1
  86. package/dist/NoviConnector.js +0 -566
  87. package/dist/NoviConnector.js.map +0 -1
  88. package/dist/ORCIDConnector.d.ts +0 -207
  89. package/dist/ORCIDConnector.d.ts.map +0 -1
  90. package/dist/ORCIDConnector.js +0 -708
  91. package/dist/ORCIDConnector.js.map +0 -1
  92. package/dist/OpenWaterConnector.d.ts +0 -173
  93. package/dist/OpenWaterConnector.d.ts.map +0 -1
  94. package/dist/OpenWaterConnector.js +0 -893
  95. package/dist/OpenWaterConnector.js.map +0 -1
  96. package/dist/PathLMSConnector.d.ts +0 -395
  97. package/dist/PathLMSConnector.d.ts.map +0 -1
  98. package/dist/PathLMSConnector.js +0 -1281
  99. package/dist/PathLMSConnector.js.map +0 -1
  100. package/dist/PheedLoopConnector.d.ts +0 -119
  101. package/dist/PheedLoopConnector.d.ts.map +0 -1
  102. package/dist/PheedLoopConnector.js +0 -429
  103. package/dist/PheedLoopConnector.js.map +0 -1
  104. package/dist/PropFuelConnector.d.ts +0 -153
  105. package/dist/PropFuelConnector.d.ts.map +0 -1
  106. package/dist/PropFuelConnector.js +0 -450
  107. package/dist/PropFuelConnector.js.map +0 -1
  108. package/dist/QuickBooksConnector.d.ts +0 -92
  109. package/dist/QuickBooksConnector.d.ts.map +0 -1
  110. package/dist/QuickBooksConnector.js +0 -713
  111. package/dist/QuickBooksConnector.js.map +0 -1
  112. package/dist/RasaConnector.d.ts +0 -152
  113. package/dist/RasaConnector.d.ts.map +0 -1
  114. package/dist/RasaConnector.js +0 -929
  115. package/dist/RasaConnector.js.map +0 -1
  116. package/dist/Reach360Connector.d.ts +0 -175
  117. package/dist/Reach360Connector.d.ts.map +0 -1
  118. package/dist/Reach360Connector.js +0 -796
  119. package/dist/Reach360Connector.js.map +0 -1
  120. package/dist/RelationalDBConnector.d.ts +0 -108
  121. package/dist/RelationalDBConnector.d.ts.map +0 -1
  122. package/dist/RelationalDBConnector.js +0 -228
  123. package/dist/RelationalDBConnector.js.map +0 -1
  124. package/dist/RhythmConnector.d.ts +0 -188
  125. package/dist/RhythmConnector.d.ts.map +0 -1
  126. package/dist/RhythmConnector.js +0 -784
  127. package/dist/RhythmConnector.js.map +0 -1
  128. package/dist/SageIntacctConnector.d.ts +0 -337
  129. package/dist/SageIntacctConnector.d.ts.map +0 -1
  130. package/dist/SageIntacctConnector.js +0 -2081
  131. package/dist/SageIntacctConnector.js.map +0 -1
  132. package/dist/SalesforceConnector.d.ts +0 -461
  133. package/dist/SalesforceConnector.d.ts.map +0 -1
  134. package/dist/SalesforceConnector.js +0 -1862
  135. package/dist/SalesforceConnector.js.map +0 -1
  136. package/dist/SharePointConnector.d.ts +0 -188
  137. package/dist/SharePointConnector.d.ts.map +0 -1
  138. package/dist/SharePointConnector.js +0 -716
  139. package/dist/SharePointConnector.js.map +0 -1
  140. package/dist/WicketConnector.d.ts +0 -252
  141. package/dist/WicketConnector.d.ts.map +0 -1
  142. package/dist/WicketConnector.js +0 -938
  143. package/dist/WicketConnector.js.map +0 -1
  144. package/dist/WildApricotConnector.d.ts +0 -201
  145. package/dist/WildApricotConnector.d.ts.map +0 -1
  146. package/dist/WildApricotConnector.js +0 -1129
  147. package/dist/WildApricotConnector.js.map +0 -1
  148. package/dist/YourMembershipConnector.d.ts +0 -349
  149. package/dist/YourMembershipConnector.d.ts.map +0 -1
  150. package/dist/YourMembershipConnector.js +0 -4062
  151. package/dist/YourMembershipConnector.js.map +0 -1
  152. package/dist/generate-integration-actions.d.ts +0 -23
  153. package/dist/generate-integration-actions.d.ts.map +0 -1
  154. package/dist/generate-integration-actions.js +0 -201
  155. package/dist/generate-integration-actions.js.map +0 -1
@@ -1,762 +0,0 @@
1
- var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
- else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
- return c > 3 && r && Object.defineProperty(target, key, r), r;
6
- };
7
- /**
8
- * MemberSuiteConnector — Integration connector for MemberSuite (Association Management System).
9
- *
10
- * MemberSuite is a multi-service AMS exposed over a REST API v2 (JSON). Member data is reached
11
- * across six in-scope services — CRM, Membership, Events, Orders, Certifications, Fundraising —
12
- * each versioned independently (`/{service}/v1/{resource}`; the Platform auth service is v2). The
13
- * shared host is `https://rest.membersuite.com`; tenancy is resolved by a `{tenantId}` PATH segment
14
- * on every list endpoint plus the auth-token context — there is NO per-account hostname variation.
15
- *
16
- * READS — list/get via `GET /{service}/v1/{resource}/{tenantId}?msql=&page=&pageSize=`.
17
- * PageNumber pagination (1-indexed). Filtering + incremental sync use MSQL
18
- * (MemberSuite Query Language) on the uniform `lastModifiedDate` watermark field:
19
- * `msql=select * from {object} where lastModifiedDate > {watermark}`.
20
- * WRITES — SCOPED to Activity + Certification writebacks ONLY (the operator-approved write-back
21
- * use cases per the MemberSuiteContext). The API documents full CRUD on most objects, but
22
- * writing arbitrary member/membership/order records against a live AMS is high-risk, so
23
- * the connector REFUSES writes outside the writeback allowlist even though the per-IO
24
- * write columns exist in metadata. Allowed writes route through the generic per-operation
25
- * BaseRESTIntegrationConnector CRUD path (create POST flat / id-in-body → BuildCreatedResult;
26
- * update PUT path-keyed; delete uses the non-standard infix `/delete/{id}`).
27
- *
28
- * AUTH (two-step signed request):
29
- * 1. POST the 6-part signed credential set (accessKeyId + associationId + associationKey +
30
- * secretAccessKey + signingCertificate + signingCertificateId) to
31
- * `/platform/v2/msc_authdata/{tenantId}` → receive AuthenticationData {accessToken, idToken,
32
- * refreshToken}.
33
- * 2. Pass the raw `accessToken` as the `Authorization` header value (NO "Bearer " prefix — the
34
- * swagger documents the Authorization parameter as the bare access-token string) on all
35
- * subsequent data requests. The `/platform/v2/refreshtoken` endpoint refreshes an expired token.
36
- *
37
- * The 6-part credential set + tenantId are ALL per-connection Configuration/credential values —
38
- * never baked constants (tenant-agnostic code). The "signing certificate" and its id are POSTed as
39
- * credential VALUES in the auth body per the OpenAPI spec; the public swagger documents NO
40
- * client-side HMAC/signature computation, so this connector performs NO inline crypto. See the
41
- * RemainingGaps note in CODE_REPORT.md if a tenant's deployment requires request-signing beyond
42
- * posting the credential set — that is a live-verification gap, not a fabricated routine here.
43
- *
44
- * DISCOVERY: DiscoverObjects/DiscoverFields return the Declared baseline (the credential-free swagger
45
- * universe, seeded into the engine cache) AND encode the runtime MECHANISM for tenant-specific
46
- * custom-field (`/platform/v2/customfields/{tenantId}`) + saved-search (`/platform/v2/savedsearches/
47
- * {tenantId}`) discovery — never a baked catalog of a customer's customs/saved searches.
48
- * DiscoveryIsAuthoritative stays FALSE: the baseline is multi-swagger (not a single enumerate-all
49
- * endpoint) and the custom/saved-search surface is per-tenant + partial, so absence in any one
50
- * tenant's refresh must NEVER deactivate the Declared metadata.
51
- *
52
- * API Documentation (swagger, credential-free):
53
- * - Platform: https://rest.membersuite.com/platform/swagger/docs/v2
54
- * - CRM: https://rest.membersuite.com/crm/swagger/docs/v1
55
- * - (Membership / Events / Orders / Certifications / Fundraising mirror the CRM shape)
56
- *
57
- * NO BAKED CATALOG. The object/field universe is Declared (swagger → metadata file → engine cache) +
58
- * Discovered (runtime custom-field + saved-search MECHANISM). This file is pure mechanism: auth, HTTP,
59
- * discover, fetch, normalize, transform, write.
60
- */
61
- import { RegisterClass } from '@memberjunction/global';
62
- import { Metadata } from '@memberjunction/core';
63
- import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
64
- // ─── Constants ──────────────────────────────────────────────────────────────
65
- const DEFAULT_BASE_URL = 'https://rest.membersuite.com';
66
- const AUTH_PATH_TEMPLATE = '/platform/v2/msc_authdata/{tenantId}';
67
- const REFRESH_PATH = '/platform/v2/refreshtoken';
68
- /** Runtime-discovery MECHANISM endpoints (per-tenant, auth-gated, partial — never the baseline). */
69
- const CUSTOM_FIELDS_PATH_TEMPLATE = '/platform/v2/customfields/{tenantId}';
70
- const SAVED_SEARCHES_PATH_TEMPLATE = '/platform/v2/savedsearches/{tenantId}';
71
- /** Uniform vendor watermark field across all six in-scope services. */
72
- const DEFAULT_WATERMARK_FIELD = 'lastModifiedDate';
73
- /** Vendor-wide PK convention (id on every resource definition). */
74
- const PK_FIELD = 'id';
75
- /** PageNumber pagination is 1-indexed (first page = 1). */
76
- const FIRST_PAGE = 1;
77
- const DEFAULT_PAGE_SIZE = 50;
78
- /**
79
- * The ONLY objects this connector will write to — the operator-approved writeback allowlist
80
- * (Activity + Certification). Lower-cased IO names. Per-connection, not a vendor constant of the API
81
- * surface: it encodes the SCOPE decision, not a catalog. Writes to any other object are refused even
82
- * though the API + metadata declare CRUD columns for them.
83
- */
84
- const WRITEBACK_ALLOWLIST = new Set(['activities', 'certifications']);
85
- const TOKEN_REFRESH_BUFFER_MS = 60_000;
86
- const TOKEN_LIFETIME_MS = 3_600_000;
87
- const MAX_RETRIES = 3;
88
- const REQUEST_TIMEOUT_MS = 30_000;
89
- const MIN_REQUEST_INTERVAL_MS = 50;
90
- // ─── Connector ──────────────────────────────────────────────────────────────
91
- let MemberSuiteConnector = class MemberSuiteConnector extends BaseRESTIntegrationConnector {
92
- constructor() {
93
- super(...arguments);
94
- this.tokenCache = null;
95
- this.lastRequestTime = 0;
96
- }
97
- get IntegrationName() { return 'MemberSuite'; }
98
- // Capability getters: the connector CAN write (the writeback path exists), but the per-object
99
- // allowlist guard in CreateRecord/UpdateRecord/DeleteRecord enforces that only Activity +
100
- // Certification writebacks actually fire. Declaring the capability here lets the engine route
101
- // writes to the connector; the guard decides per object.
102
- get SupportsCreate() { return true; }
103
- get SupportsUpdate() { return true; }
104
- get SupportsDelete() { return true; }
105
- /**
106
- * NOT authoritative for deactivation (matches the contract). The Declared baseline is seeded from
107
- * six independent swagger specs (no single enumerate-all endpoint), and the runtime custom-field /
108
- * saved-search surface is per-tenant + partial — absence in any one refresh proves nothing about
109
- * the canonical object set, so it must NEVER trigger deactivation of Declared metadata.
110
- */
111
- get DiscoveryIsAuthoritative() { return false; }
112
- // ── §7 sync-efficiency hooks ─────────────────────────────────────────────
113
- /**
114
- * MSQL incremental orders by lastModifiedDate and re-fetches `> watermark`, so the watermark is
115
- * monotonic per object — the engine may narrow the next incremental window.
116
- */
117
- get MonotonicWatermark() { return true; }
118
- /** Every resource exposes `id`; for no-watermark resume the engine keyset-orders by it. */
119
- StableOrderingKey(_objectName) { return PK_FIELD; }
120
- /**
121
- * MemberSuite publishes no documented tokens/sec rate limit in the swagger specs. A conservative
122
- * sustained rate keeps the connector polite against a shared host; the engine's AIMD bucket adapts
123
- * downward on any throttle signal. (Provable-only: this is a safe default, not a documented limit.)
124
- */
125
- get RateLimitPolicy() {
126
- return { TokensPerSec: 10, Burst: 15, ThrottleBackoffFactor: 0.5 };
127
- }
128
- /** Honor a standard `Retry-After` header (seconds) if the vendor sends one on a 429. */
129
- ExtractRetryAfterMs(error) {
130
- if (!error || typeof error !== 'object')
131
- return undefined;
132
- const headers = error.Headers;
133
- const ra = headers?.['retry-after'];
134
- if (ra) {
135
- const secs = Number(ra);
136
- if (!isNaN(secs))
137
- return secs * 1000;
138
- }
139
- return undefined;
140
- }
141
- // ── Discovery (Declared baseline + runtime custom-field / saved-search MECHANISM) ─
142
- /**
143
- * Returns the Declared baseline (the swagger universe seeded into the engine cache by the base
144
- * implementation) PLUS — additively, when a credential is present — tenant-specific custom objects
145
- * surfaced by the runtime saved-search MECHANISM. The baseline ALWAYS comes back credential-free
146
- * (so a credential-free DocStructureSelfCheck re-yields the standard universe); a live credential is
147
- * additive only. No catalog of customs is baked here.
148
- */
149
- async DiscoverObjects(companyIntegration, contextUser) {
150
- // 1) Declared baseline — credential-free, from the engine cache. ALWAYS the standard universe.
151
- const baseline = await super.DiscoverObjects(companyIntegration, contextUser);
152
- // 2) ADDITIVE runtime discovery (mechanism, never a baked answer): a tenant's saved searches are
153
- // a per-tenant custom query surface. Surface them as extra discoverable objects ONLY when a
154
- // credential is configured — absence never removes baseline objects (DiscoveryIsAuthoritative=false).
155
- if (!this.HasCredential(companyIntegration))
156
- return baseline;
157
- try {
158
- const extras = await this.DiscoverSavedSearchObjects(companyIntegration, contextUser);
159
- const known = new Set(baseline.map(o => o.Name.toLowerCase()));
160
- for (const e of extras)
161
- if (!known.has(e.Name.toLowerCase()))
162
- baseline.push(e);
163
- }
164
- catch (err) {
165
- // Runtime discovery is best-effort + additive — a failure must not break the standard universe.
166
- console.warn(`[${this.IntegrationName}] saved-search discovery skipped: ${err instanceof Error ? err.message : String(err)}`);
167
- }
168
- return baseline;
169
- }
170
- /**
171
- * Returns the Declared fields (from the engine cache) PLUS — additively, with a credential — the
172
- * tenant's custom fields for the object via the runtime custom-field MECHANISM. The Declared set is
173
- * always present; customs are an extension, never a replacement, and are never baked.
174
- */
175
- async DiscoverFields(companyIntegration, objectName, contextUser) {
176
- const declared = await super.DiscoverFields(companyIntegration, objectName, contextUser);
177
- if (!this.HasCredential(companyIntegration))
178
- return declared;
179
- try {
180
- const customs = await this.DiscoverCustomFields(companyIntegration, objectName, contextUser);
181
- const known = new Set(declared.map(f => f.Name.toLowerCase()));
182
- for (const c of customs)
183
- if (!known.has(c.Name.toLowerCase()))
184
- declared.push(c);
185
- }
186
- catch (err) {
187
- console.warn(`[${this.IntegrationName}] custom-field discovery skipped for "${objectName}": ${err instanceof Error ? err.message : String(err)}`);
188
- }
189
- return declared;
190
- }
191
- /**
192
- * Runtime MECHANISM: fetch a tenant's custom-field definitions for an object and map them to
193
- * ExternalFieldSchema. Custom fields are tenant-defined extensions — never declared, never NOT NULL
194
- * (provable-only: a custom field's required-ness is not asserted by this endpoint's shape unless
195
- * the vendor flags it). This is the discovery mechanism; the actual fields come from the live call.
196
- */
197
- async DiscoverCustomFields(companyIntegration, objectName, contextUser) {
198
- const auth = await this.Authenticate(companyIntegration, contextUser);
199
- const path = this.ResolveTenantPath(CUSTOM_FIELDS_PATH_TEMPLATE, auth.Config.TenantID);
200
- const url = `${this.GetBaseURL(companyIntegration, auth)}${path}?objectType=${encodeURIComponent(objectName)}`;
201
- const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
202
- if (response.Status < 200 || response.Status >= 300)
203
- return [];
204
- const raw = this.NormalizeResponse(response.Body, null);
205
- return raw.map(cf => this.CustomFieldToSchema(cf));
206
- }
207
- /** Maps a raw custom-field definition to a field schema. Provable-only on required/type. */
208
- CustomFieldToSchema(cf) {
209
- const name = String(cf['name'] ?? cf['fieldName'] ?? cf['id'] ?? '');
210
- const dataType = typeof cf['dataType'] === 'string' ? this.MapCustomFieldType(cf['dataType']) : 'string';
211
- const schema = {
212
- Name: name,
213
- Label: typeof cf['label'] === 'string' ? cf['label'] : name,
214
- Description: typeof cf['description'] === 'string' ? cf['description'] : undefined,
215
- DataType: dataType,
216
- // Provable-only: only mark required when the vendor explicitly flags it.
217
- IsRequired: cf['isRequired'] === true,
218
- IsUniqueKey: false,
219
- IsReadOnly: cf['isReadOnly'] === true,
220
- };
221
- return schema;
222
- }
223
- MapCustomFieldType(t) {
224
- const map = {
225
- string: 'string', text: 'string', textarea: 'string', email: 'string', url: 'string', phone: 'string',
226
- boolean: 'boolean', bool: 'boolean', integer: 'number', int: 'number', number: 'number',
227
- decimal: 'decimal', currency: 'decimal', money: 'decimal', date: 'datetime', datetime: 'datetime',
228
- picklist: 'string', lookup: 'string',
229
- };
230
- return map[t.toLowerCase()] ?? 'string';
231
- }
232
- /**
233
- * Runtime MECHANISM: surface a tenant's saved searches as discoverable objects. Saved searches are a
234
- * per-tenant query surface (each tenant defines its own) — never a standard object family, so they
235
- * are additive-only and the connector stays non-authoritative.
236
- */
237
- async DiscoverSavedSearchObjects(companyIntegration, contextUser) {
238
- const auth = await this.Authenticate(companyIntegration, contextUser);
239
- const path = this.ResolveTenantPath(SAVED_SEARCHES_PATH_TEMPLATE, auth.Config.TenantID);
240
- const url = `${this.GetBaseURL(companyIntegration, auth)}${path}`;
241
- const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
242
- if (response.Status < 200 || response.Status >= 300)
243
- return [];
244
- const raw = this.NormalizeResponse(response.Body, null);
245
- return raw.map(ss => ({
246
- Name: `savedsearch:${String(ss['name'] ?? ss['id'] ?? '')}`,
247
- Label: typeof ss['name'] === 'string' ? ss['name'] : 'Saved Search',
248
- Description: 'Tenant-defined saved search (runtime-discovered query surface).',
249
- SupportsIncrementalSync: false,
250
- SupportsWrite: false,
251
- }));
252
- }
253
- // ── Auth (two-step signed request → accessToken header) ───────────────────
254
- async Authenticate(companyIntegration, contextUser) {
255
- const config = await this.ParseConfig(companyIntegration, contextUser);
256
- if (this.tokenCache && this.tokenCache.ExpiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS) {
257
- return this.BuildAuthContext(config, this.tokenCache.AccessToken);
258
- }
259
- const token = await this.ObtainToken(config);
260
- this.tokenCache = token;
261
- return this.BuildAuthContext(config, token.AccessToken);
262
- }
263
- BuildAuthContext(config, accessToken) {
264
- return {
265
- Token: accessToken,
266
- AccessToken: accessToken,
267
- Config: config,
268
- BaseURL: config.BaseURL ?? DEFAULT_BASE_URL,
269
- };
270
- }
271
- /**
272
- * Step 1 of the two-step flow: POST the 6-part signed credential set to msc_authdata → accessToken.
273
- *
274
- * NO inline crypto. The swagger documents the signing certificate + its id as VALUES posted in the
275
- * auth body (the server verifies them); it documents NO client-side HMAC/signature computation. If a
276
- * future tenant deployment requires request-signing beyond posting the credential set, that is a
277
- * NOTED auth-helpers extension request (RemainingGaps), not a fabricated routine here.
278
- */
279
- async ObtainToken(config) {
280
- // Pre-provisioned token (test/broker injection) — use as-is, no exchange.
281
- if (config.AccessToken) {
282
- return { AccessToken: config.AccessToken, RefreshToken: config.RefreshToken, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
283
- }
284
- const baseURL = config.BaseURL ?? DEFAULT_BASE_URL;
285
- // Refresh path: a cached refresh token can re-mint an access token without re-signing.
286
- if (this.tokenCache?.RefreshToken) {
287
- const refreshed = await this.RefreshToken(baseURL, this.tokenCache.RefreshToken, config);
288
- if (refreshed)
289
- return refreshed;
290
- }
291
- const authPath = this.ResolveTenantPath(AUTH_PATH_TEMPLATE, config.TenantID);
292
- const body = {
293
- accessKeyId: config.AccessKeyID,
294
- associationId: config.AssociationID,
295
- associationKey: config.AssociationKey,
296
- secretAccessKey: config.SecretAccessKey,
297
- signingCertificate: config.SigningCertificate,
298
- signingCertificateId: config.SigningCertificateID,
299
- };
300
- const response = await this.RawPost(`${baseURL}${authPath}`, body);
301
- if (response.Status < 200 || response.Status >= 300) {
302
- throw new Error(`MemberSuite auth failed: HTTP ${response.Status} at ${authPath} — ${this.SafeBody(response)}`);
303
- }
304
- const data = response.Body;
305
- if (!data || typeof data.accessToken !== 'string' || data.accessToken.length === 0) {
306
- throw new Error('MemberSuite auth response carried no accessToken.');
307
- }
308
- return { AccessToken: data.accessToken, RefreshToken: data.refreshToken, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
309
- }
310
- /** Step 2 refresh: POST RefreshTokenRequest {idToken, refreshToken} → new accessToken. Best-effort. */
311
- async RefreshToken(baseURL, refreshToken, _config) {
312
- try {
313
- const response = await this.RawPost(`${baseURL}${REFRESH_PATH}`, { refreshToken });
314
- if (response.Status < 200 || response.Status >= 300)
315
- return null;
316
- const data = response.Body;
317
- if (typeof data?.accessToken !== 'string' || data.accessToken.length === 0)
318
- return null;
319
- return { AccessToken: data.accessToken, RefreshToken: data.refreshToken ?? refreshToken, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
320
- }
321
- catch {
322
- return null; // fall back to a full re-auth
323
- }
324
- }
325
- async ParseConfig(companyIntegration, contextUser, provider) {
326
- const fromConfigJSON = this.ParseConfigJSON(companyIntegration.Configuration);
327
- const credentialID = companyIntegration.CredentialID;
328
- if (credentialID) {
329
- const md = provider ?? new Metadata();
330
- const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
331
- const loaded = await credential.Load(credentialID);
332
- if (loaded && credential.Values) {
333
- const parsed = JSON.parse(credential.Values);
334
- return this.BuildConfig(parsed, fromConfigJSON);
335
- }
336
- }
337
- if (fromConfigJSON)
338
- return this.BuildConfig(fromConfigJSON, null);
339
- throw new Error('No MemberSuite credentials found — set the Credential entity or Configuration JSON.');
340
- }
341
- ParseConfigJSON(json) {
342
- if (!json)
343
- return null;
344
- try {
345
- return JSON.parse(json);
346
- }
347
- catch {
348
- return null;
349
- }
350
- }
351
- BuildConfig(creds, configJSON) {
352
- const pick = (...keys) => {
353
- for (const k of keys) {
354
- if (creds[k])
355
- return creds[k];
356
- if (configJSON && configJSON[k])
357
- return configJSON[k];
358
- }
359
- return undefined;
360
- };
361
- return {
362
- TenantID: pick('TenantID', 'tenantId', 'tenant_id', 'AssociationID', 'associationId') ?? '',
363
- AccessKeyID: pick('AccessKeyID', 'accessKeyId', 'access_key_id') ?? '',
364
- AssociationID: pick('AssociationID', 'associationId', 'association_id') ?? '',
365
- AssociationKey: pick('AssociationKey', 'associationKey', 'association_key') ?? '',
366
- SecretAccessKey: pick('SecretAccessKey', 'secretAccessKey', 'secret_access_key') ?? '',
367
- SigningCertificate: pick('SigningCertificate', 'signingCertificate', 'signing_certificate') ?? '',
368
- SigningCertificateID: pick('SigningCertificateID', 'signingCertificateId', 'signing_certificate_id') ?? '',
369
- AccessToken: pick('AccessToken', 'accessToken', 'access_token'),
370
- RefreshToken: pick('RefreshToken', 'refreshToken', 'refresh_token'),
371
- BaseURL: pick('BaseURL', 'baseUrl', 'base_url'),
372
- };
373
- }
374
- /** True when a credential or Configuration-carried credential set is present (gates live discovery). */
375
- HasCredential(companyIntegration) {
376
- if (companyIntegration.CredentialID)
377
- return true;
378
- const cfg = this.ParseConfigJSON(companyIntegration.Configuration);
379
- return !!cfg && (!!cfg['AccessToken'] || !!cfg['accessToken'] || !!cfg['SecretAccessKey'] || !!cfg['secretAccessKey']);
380
- }
381
- // ── TestConnection ───────────────────────────────────────────────────────
382
- async TestConnection(companyIntegration, contextUser) {
383
- try {
384
- const auth = await this.Authenticate(companyIntegration, contextUser);
385
- if (!auth.AccessToken)
386
- return { Success: false, Message: 'MemberSuite authentication returned no access token.' };
387
- // A successful token exchange is the connection proof; confirm the host is reachable for a read.
388
- const probePath = this.ResolveTenantPath(CUSTOM_FIELDS_PATH_TEMPLATE, auth.Config.TenantID);
389
- const response = await this.MakeHTTPRequest(auth, `${this.GetBaseURL(companyIntegration, auth)}${probePath}`, 'GET', this.BuildHeaders(auth));
390
- if (response.Status >= 200 && response.Status < 300)
391
- return { Success: true, Message: 'Connected to MemberSuite REST API v2.' };
392
- if (response.Status === 401 || response.Status === 403)
393
- return { Success: false, Message: `MemberSuite authorization denied: HTTP ${response.Status}.` };
394
- // The token exchange already succeeded; a non-2xx on the probe (e.g. 404 for a tenant with no
395
- // custom fields) still means we authenticated successfully.
396
- return { Success: true, Message: `Authenticated to MemberSuite (probe returned HTTP ${response.Status}).` };
397
- }
398
- catch (err) {
399
- return { Success: false, Message: err instanceof Error ? err.message : String(err) };
400
- }
401
- }
402
- // ── URL / Response / Pagination ──────────────────────────────────────────
403
- GetBaseURL(_ci, auth) {
404
- return auth.BaseURL;
405
- }
406
- /** Substitute the `{tenantId}` segment into a path template (tenant-scoped, never a constant). */
407
- ResolveTenantPath(template, tenantID) {
408
- return template.replace(/\{tenantId\}/g, encodeURIComponent(tenantID));
409
- }
410
- /**
411
- * Strip the response envelope to expose individual records. MemberSuite list responses return either
412
- * a bare array, or an envelope with the records under a `results`/`items`/`data` key (the swagger
413
- * uses `results` for the paged list shape); a get-one returns a single object.
414
- */
415
- NormalizeResponse(rawBody, responseDataKey) {
416
- if (Array.isArray(rawBody))
417
- return rawBody;
418
- if (!rawBody || typeof rawBody !== 'object')
419
- return [];
420
- const body = rawBody;
421
- if (responseDataKey && Array.isArray(body[responseDataKey]))
422
- return body[responseDataKey];
423
- for (const k of ['results', 'items', 'data', 'records', 'Results', 'Items']) {
424
- if (Array.isArray(body[k]))
425
- return body[k];
426
- }
427
- // A single non-enveloped record (e.g. get-one) — pass through as one record.
428
- return [body];
429
- }
430
- /**
431
- * PageNumber pagination (1-indexed). A page that returns a full page-size implies more may remain;
432
- * a short/empty page is the last. The total may be reported under `totalCount`/`count` — when present
433
- * we use it for an exact HasMore, otherwise fall back to the page-fill heuristic.
434
- */
435
- ExtractPaginationInfo(rawBody, _pt, currentPage, _co, pageSize) {
436
- const records = this.NormalizeResponse(rawBody, null);
437
- let total;
438
- if (rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)) {
439
- const body = rawBody;
440
- for (const k of ['totalCount', 'count', 'total', 'TotalCount', 'totalRecords']) {
441
- if (typeof body[k] === 'number') {
442
- total = body[k];
443
- break;
444
- }
445
- }
446
- }
447
- if (typeof total === 'number') {
448
- const seen = currentPage * pageSize;
449
- return { HasMore: seen < total, NextPage: currentPage + 1, TotalRecords: total };
450
- }
451
- const hasMore = records.length >= pageSize && records.length > 0;
452
- return { HasMore: hasMore, NextPage: currentPage + 1 };
453
- }
454
- // ── FetchChanges (MSQL list with tenantId path + watermark filter + page paging) ─
455
- /**
456
- * Reads a MemberSuite object via the list door:
457
- * GET /{service}/v1/{resource}/{tenantId}?msql=<select … where lastModifiedDate > wm>&page=&pageSize=
458
- *
459
- * The IO's `APIPath` (`/{service}/v1/{resource}`) is the base; we append the `{tenantId}` path segment
460
- * (the access path's `entryQuery` carries `/{service}/v1/{resource}/{tenantId}`), the MSQL filter
461
- * (watermark-driven incremental), and the 1-indexed page params. This is genuinely idiosyncratic
462
- * (tenantId path segment + MSQL query language), so we override the base flat-fetch rather than the
463
- * base appending only `page`/`pageSize` to a tenantId-less path.
464
- */
465
- async FetchChanges(ctx) {
466
- const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
467
- // Saved-search runtime objects are a query surface, not a standard list door — nothing to pull here.
468
- if (ctx.ObjectName.startsWith('savedsearch:')) {
469
- return { Records: [], HasMore: false, Warnings: [{ Code: 'NON_STANDARD_OBJECT', Message: `"${ctx.ObjectName}" is a tenant saved search (runtime query surface); it is not synced via the standard list door.` }] };
470
- }
471
- const fields = this.GetCachedFields(obj.ID);
472
- const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
473
- const headers = this.BuildHeaders(auth);
474
- const watermarkField = obj.IncrementalWatermarkField ?? DEFAULT_WATERMARK_FIELD;
475
- const cfg = this.ReadAccessConfig(obj);
476
- const resourceName = this.ResourceNameForMSQL(obj, cfg);
477
- const pageSize = obj.DefaultPageSize ?? DEFAULT_PAGE_SIZE;
478
- const listBase = `${this.GetBaseURL(ctx.CompanyIntegration, auth)}${this.BuildListPath(obj, cfg, auth.Config.TenantID)}`;
479
- const msql = this.BuildMSQL(resourceName, watermarkField, ctx.WatermarkValue ?? null);
480
- const allRaw = [];
481
- let page = ctx.CurrentPage ?? FIRST_PAGE;
482
- const batchLimit = ctx.BatchSize ?? Number.MAX_SAFE_INTEGER;
483
- let hasMore = true;
484
- let lastPageReached = false;
485
- while (hasMore && allRaw.length < batchLimit) {
486
- const url = this.BuildMSQLPageURL(listBase, msql, page, pageSize);
487
- const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
488
- if (response.Status === 403) {
489
- return { Records: [], HasMore: false, Warnings: [{ Code: 'FORBIDDEN', Message: `"${ctx.ObjectName}" requires additional API permissions (HTTP 403); skipping.` }] };
490
- }
491
- if (response.Status < 200 || response.Status >= 300) {
492
- throw new Error(`MemberSuite list error for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}`);
493
- }
494
- const pageRecords = this.NormalizeResponse(response.Body, obj.ResponseDataKey);
495
- if (pageRecords.length === 0) {
496
- hasMore = false;
497
- lastPageReached = true;
498
- break;
499
- }
500
- allRaw.push(...pageRecords);
501
- const pg = this.ExtractPaginationInfo(response.Body, obj.PaginationType, page, 0, pageSize);
502
- hasMore = pg.HasMore;
503
- page = pg.NextPage ?? page + 1;
504
- if (!hasMore)
505
- lastPageReached = true;
506
- }
507
- const pkFieldNames = this.FindPKFieldNames(fields);
508
- const records = allRaw.map(r => this.RawToExternalRecord(r, obj, ctx.ObjectName, watermarkField, pkFieldNames));
509
- // Watermark advances ONLY when the full batch drained (last page reached, full-batch success),
510
- // and only to the max lastModifiedDate seen — partial-failure / mid-batch leaves it unchanged.
511
- let newWatermark;
512
- if (lastPageReached) {
513
- for (const r of allRaw) {
514
- const mod = r[watermarkField];
515
- if (typeof mod === 'string' && (!newWatermark || mod > newWatermark))
516
- newWatermark = mod;
517
- }
518
- }
519
- return {
520
- Records: records,
521
- HasMore: !lastPageReached && hasMore,
522
- NextPage: !lastPageReached ? page : undefined,
523
- NewWatermarkValue: lastPageReached ? newWatermark : undefined,
524
- };
525
- }
526
- /** The list path: `/{service}/v1/{resource}/{tenantId}` (from the access path's entryQuery / APIPath). */
527
- BuildListPath(obj, cfg, tenantID) {
528
- const entry = cfg.accessPath?.entryQuery ?? cfg.listPath ?? `${obj.APIPath}/{tenantId}`;
529
- return entry.replace(/\{tenantId\}/g, encodeURIComponent(tenantID));
530
- }
531
- /** The MSQL `from` target — the vendor object/definition name (resource segment of the API path). */
532
- ResourceNameForMSQL(obj, _cfg) {
533
- // The MSQL `from` uses the resource name; the API path's last segment is the resource collection.
534
- const segments = obj.APIPath.split('/').filter(Boolean);
535
- return segments[segments.length - 1] ?? obj.Name;
536
- }
537
- /**
538
- * Build the MSQL query. Incremental uses `> watermark` on lastModifiedDate; a first sync selects all.
539
- * `select *` returns the full record so full-record pass-through is preserved.
540
- */
541
- BuildMSQL(resourceName, watermarkField, watermarkValue) {
542
- let q = `select * from ${resourceName}`;
543
- if (watermarkValue)
544
- q += ` where ${watermarkField} > ${this.FormatMSQLDateTime(watermarkValue)}`;
545
- return q;
546
- }
547
- /** MSQL datetime literals are single-quoted ISO-8601 strings. */
548
- FormatMSQLDateTime(value) {
549
- if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
550
- const norm = value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value) ? value : `${value}Z`;
551
- return `'${norm}'`;
552
- }
553
- const d = new Date(value);
554
- return `'${isNaN(d.getTime()) ? value : d.toISOString()}'`;
555
- }
556
- /** Append `msql`, `page` (1-indexed), and `pageSize` to the tenant-scoped list URL. */
557
- BuildMSQLPageURL(listBase, msql, page, pageSize) {
558
- const sep = listBase.includes('?') ? '&' : '?';
559
- return `${listBase}${sep}msql=${encodeURIComponent(msql)}&page=${page}&pageSize=${pageSize}`;
560
- }
561
- /** Parse the per-IO access Configuration (service / version / listPath / accessPath). */
562
- ReadAccessConfig(obj) {
563
- const raw = obj.Configuration;
564
- if (!raw)
565
- return {};
566
- try {
567
- return JSON.parse(raw);
568
- }
569
- catch {
570
- return {};
571
- }
572
- }
573
- /** PK field names from the IO's fields (id by convention), sorted by sequence; falls back to `id`. */
574
- FindPKFieldNames(fields) {
575
- const pks = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
576
- return pks.length > 0 ? pks : [PK_FIELD];
577
- }
578
- /**
579
- * Build an ExternalRecord from a raw vendor record. The FULL source record flows into `Fields`
580
- * (custom-column pass-through contract M1–M4) — the only transformation is the transform-preserving
581
- * hook (default identity), so no source key is silently dropped. The PK field(s) supply the
582
- * ExternalID (joined with '|' for composite keys); lastModifiedDate supplies ModifiedAt.
583
- */
584
- RawToExternalRecord(raw, obj, objectType, watermarkField, pkFieldNames) {
585
- const fields = this.applyTransformPreservingKeys(raw, obj, this.GetCachedFields(obj.ID));
586
- const allPkPresent = pkFieldNames.length > 0 && pkFieldNames.every(n => fields[n] != null && String(fields[n]).length > 0);
587
- const externalID = allPkPresent ? pkFieldNames.map(n => String(fields[n])).join('|') : '';
588
- const modRaw = raw[watermarkField];
589
- return {
590
- ExternalID: externalID,
591
- ObjectType: objectType,
592
- Fields: fields,
593
- ModifiedAt: typeof modRaw === 'string' ? new Date(modRaw) : undefined,
594
- IsDeleted: raw['isDeleted'] === true,
595
- };
596
- }
597
- // ── CRUD (writeback-scoped: Activity + Certification only) ────────────────
598
- //
599
- // The generic BaseRESTIntegrationConnector CRUD handles the wire shape (POST flat / id-in-body →
600
- // BuildCreatedResult; PUT path-keyed update; DELETE via the non-standard `/delete/{id}` infix). We
601
- // override only to ENFORCE the operator-approved writeback allowlist: a write to any object outside
602
- // {activities, certifications} is REFUSED, even though metadata declares CRUD columns for it.
603
- async CreateRecord(ctx) {
604
- const guard = this.GuardWriteback(ctx.ObjectName, 'create');
605
- if (guard)
606
- return guard;
607
- return super.CreateRecord(ctx);
608
- }
609
- async UpdateRecord(ctx) {
610
- const guard = this.GuardWriteback(ctx.ObjectName, 'update');
611
- if (guard)
612
- return guard;
613
- return super.UpdateRecord(ctx);
614
- }
615
- async DeleteRecord(ctx) {
616
- const guard = this.GuardWriteback(ctx.ObjectName, 'delete');
617
- if (guard)
618
- return guard;
619
- return super.DeleteRecord(ctx);
620
- }
621
- /**
622
- * Returns a refusal CRUDResult when the object is OUTSIDE the writeback allowlist; null when the
623
- * write is allowed (Activity / Certification). This enforces the operator-scoped "only Activity +
624
- * Certification writebacks" decision in code — not a capability fabrication.
625
- */
626
- GuardWriteback(objectName, verb) {
627
- if (WRITEBACK_ALLOWLIST.has(objectName.toLowerCase()))
628
- return null;
629
- return {
630
- Success: false,
631
- StatusCode: 0,
632
- ErrorMessage: `MemberSuite ${verb} refused for "${objectName}": writebacks are scoped to Activity + Certification only (operator-approved write-back use cases). Writing other objects against a live AMS is out of scope.`,
633
- };
634
- }
635
- // ── Idempotency on create (honor the configured idempotency key) ──────────
636
- /**
637
- * Extract the created-record id from a create response. MemberSuite returns the new record (id in
638
- * body) per the contract's CreateIDLocation='body'. We also honor a configured idempotency reference
639
- * (e.g. external_activity_id) when the vendor echoes it — so a duplicate writeback re-resolves to the
640
- * same record id rather than minting a duplicate.
641
- */
642
- ExtractIDFromResponse(response, idLocation) {
643
- const base = super.ExtractIDFromResponse(response, idLocation);
644
- if (base)
645
- return base;
646
- if (response.Body && typeof response.Body === 'object') {
647
- const b = response.Body;
648
- for (const k of [PK_FIELD, 'ID', 'Id', 'recordId', 'RecordID']) {
649
- if (typeof b[k] === 'string' || typeof b[k] === 'number')
650
- return String(b[k]);
651
- }
652
- }
653
- return undefined;
654
- }
655
- // ── Headers + HTTP transport ─────────────────────────────────────────────
656
- /**
657
- * Authorization header carries the RAW accessToken value (NO "Bearer " prefix — the swagger
658
- * documents the Authorization parameter as the bare access-token string).
659
- */
660
- BuildHeaders(auth) {
661
- const token = auth.AccessToken ?? auth.Token ?? '';
662
- return {
663
- Authorization: token,
664
- Accept: 'application/json',
665
- 'User-Agent': 'MemberJunction-Integration/1.0',
666
- };
667
- }
668
- /** Unauthenticated POST used by the token-exchange + refresh steps (no Authorization header). */
669
- async RawPost(url, body) {
670
- const controller = new AbortController();
671
- const tid = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
672
- try {
673
- const response = await fetch(url, {
674
- method: 'POST',
675
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
676
- body: JSON.stringify(body),
677
- signal: controller.signal,
678
- });
679
- const parsed = await this.ParseBody(response);
680
- return this.ToRESTResponse(response, parsed);
681
- }
682
- catch (err) {
683
- if (err instanceof Error && err.name === 'AbortError')
684
- throw new Error(`MemberSuite auth request timed out: ${url}`);
685
- throw err;
686
- }
687
- finally {
688
- clearTimeout(tid);
689
- }
690
- }
691
- async MakeHTTPRequest(_auth, url, method, headers, body) {
692
- await this.Throttle();
693
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
694
- const response = await this.FetchWithTimeout(url, method, headers, body);
695
- this.lastRequestTime = Date.now();
696
- if (response.status === 401 && attempt === 0) {
697
- this.tokenCache = null;
698
- continue;
699
- }
700
- if (response.status === 429 && attempt < MAX_RETRIES) {
701
- const ra = Number(response.headers.get('retry-after'));
702
- const wait = !isNaN(ra) && ra > 0 ? ra * 1000 : Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
703
- await this.Sleep(wait);
704
- continue;
705
- }
706
- if (response.status >= 500 && attempt < MAX_RETRIES) {
707
- await this.Sleep(Math.min(1000 * Math.pow(2, attempt), 30_000));
708
- continue;
709
- }
710
- const parsed = await this.ParseBody(response);
711
- return this.ToRESTResponse(response, parsed);
712
- }
713
- throw new Error(`MemberSuite request failed after ${MAX_RETRIES} retries: ${url}`);
714
- }
715
- async FetchWithTimeout(url, method, headers, body) {
716
- const controller = new AbortController();
717
- const tid = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
718
- try {
719
- const opts = { method, headers };
720
- opts.signal = controller.signal;
721
- if (body !== undefined && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
722
- opts.body = JSON.stringify(body);
723
- opts.headers = { ...headers, 'Content-Type': 'application/json' };
724
- }
725
- return await fetch(url, opts);
726
- }
727
- catch (err) {
728
- if (err instanceof Error && err.name === 'AbortError')
729
- throw new Error(`Request timed out: ${url}`);
730
- throw err;
731
- }
732
- finally {
733
- clearTimeout(tid);
734
- }
735
- }
736
- async ParseBody(r) {
737
- const ct = r.headers.get('content-type') ?? '';
738
- return ct.includes('json') ? r.json().catch(() => null) : r.text();
739
- }
740
- ToRESTResponse(r, body) {
741
- const h = {};
742
- r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; });
743
- return { Status: r.status, Body: body, Headers: h };
744
- }
745
- SafeBody(r) {
746
- const s = typeof r.Body === 'string' ? r.Body : JSON.stringify(r.Body);
747
- return (s ?? '').substring(0, 300);
748
- }
749
- async Throttle() {
750
- const elapsed = Date.now() - this.lastRequestTime;
751
- if (elapsed < MIN_REQUEST_INTERVAL_MS)
752
- await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
753
- }
754
- Sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
755
- };
756
- MemberSuiteConnector = __decorate([
757
- RegisterClass(BaseIntegrationConnector, 'MemberSuiteConnector')
758
- ], MemberSuiteConnector);
759
- export { MemberSuiteConnector };
760
- // Tree-shaking prevention — REQUIRED so @RegisterClass survives bundling.
761
- export function LoadMemberSuiteConnector() { }
762
- //# sourceMappingURL=MemberSuiteConnector.js.map