@memberjunction/connector-rasa-io 2.0.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.
@@ -0,0 +1,1029 @@
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
+ import { RegisterClass } from '@memberjunction/global';
8
+ import { Metadata } from '@memberjunction/core';
9
+ import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
10
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
11
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
12
+ import { z } from 'zod';
13
+ // ─── Vendor constants (mechanism, NOT catalog) ───────────────────────
14
+ //
15
+ // These are transport facts about the rasa.io SERVICE, not a schema: the host root, the
16
+ // credential-free schema-of-record URLs, and timing. No object list, no field list, no
17
+ // constraint, and nothing tenant-specific lives in this file — the object/field universe is
18
+ // DISCOVERED at runtime (public OpenAPI walk + persisted Declared metadata + live sampling).
19
+ /**
20
+ * Host ROOT (no version segment). The frozen contract's `apiBaseURLNote` records that each
21
+ * IntegrationObject's `APIPath` already carries its own `/v1` or `/v2` prefix, so a version-scoped
22
+ * base double-prefixes (`…/v1` + `/v2/lists` → `/v1/v2/lists` → HTTP 404). Both generations hang
23
+ * off the host root. Overridable per connection via `BaseURL`.
24
+ */
25
+ const RASA_API_HOST = 'https://api.rasa.io';
26
+ /**
27
+ * Credential-free schema-of-record. rasa.io publishes both API generations as public Swagger 2.0
28
+ * documents (SOURCES.json, Tier 1 / OpenAPISpec, both HTTP 200 unauthenticated). Discovery reads
29
+ * THESE — a live credential is purely ADDITIVE (it only contributes tenant-observed customs), so
30
+ * `DiscoverObjects`/`DiscoverFields` re-yield the full standard universe with no token.
31
+ */
32
+ const RASA_PUBLIC_SPEC_URLS = [
33
+ 'https://api-docs.rasa.io/v1/swagger.json',
34
+ 'https://api-docs.rasa.io/swagger.json',
35
+ ];
36
+ /** Token lifetime guard — refresh well inside the JWT `exp` the vendor issues. */
37
+ const TOKEN_TTL_MS = 45 * 60 * 1000;
38
+ /** Per-request timeout. */
39
+ const REQUEST_TIMEOUT_MS = 60_000;
40
+ /** Timeout for the credential-free public-spec fetch used by discovery. */
41
+ const SPEC_FETCH_TIMEOUT_MS = 20_000;
42
+ /** Transport retry budget (network blips, 401 token refresh, 429 back-off). */
43
+ const MAX_RETRIES = 3;
44
+ /**
45
+ * Page-size fallback when an IntegrationObject declares no `DefaultPageSize`. The v1 spec caps
46
+ * `limit` at 50 (`maximum: 50` on GET /persons); v2 defaults to 50. Never used to OVERRIDE a
47
+ * metadata-declared page size — only as the floor when metadata is silent.
48
+ */
49
+ const RASA_FALLBACK_PAGE_SIZE = 50;
50
+ // ─── Config / envelope typing (Zod — no `any`) ───────────────────────
51
+ const RasaConnectionConfigSchema = z.object({
52
+ /** rasa.io API key presented in the token-exchange body. */
53
+ APIKey: z.string().min(1),
54
+ /** Account username (email) for the token-exchange HTTP Basic header. */
55
+ Username: z.string().min(1),
56
+ /** Account password for the token-exchange HTTP Basic header. */
57
+ Password: z.string().min(1),
58
+ /** Optional host override (self-hosted / proxy / test double). Defaults to the vendor host root. */
59
+ BaseURL: z.string().url().optional(),
60
+ });
61
+ /**
62
+ * Per-object vendor specifics the canonical IntegrationObject columns have no home for. Emitted by
63
+ * the extractor into `IntegrationObject.Configuration`; read here, never invented.
64
+ */
65
+ const RasaObjectConfigSchema = z.object({
66
+ apiVersion: z.string().optional(),
67
+ /** Swagger definition name(s) backing this object — the bridge into the public spec. */
68
+ recordSchemas: z.array(z.string()).optional(),
69
+ /** Query-param name carrying the incremental watermark (`updated_since` / `created_since` / …). */
70
+ watermarkParam: z.string().optional(),
71
+ /**
72
+ * RealityProbe `recordEnvelopeShape` verdict, materialized by the extractor: the per-record path to
73
+ * unwrap BEFORE field mapping (e.g. `data` → `results[].data`). ABSENT means the verdict is
74
+ * `flat`-or-unverified for this object and the connector MUST NOT unwrap (see NormalizeResponse).
75
+ */
76
+ recordUnwrapPath: z.string().optional(),
77
+ /** Nested-graph access path: the door, the descent, and the owning parent object. */
78
+ accessPath: z
79
+ .object({
80
+ door: z.string().optional(),
81
+ nesting: z.array(z.string()).optional(),
82
+ parentObject: z.string().nullable().optional(),
83
+ parentKeyField: z.string().nullable().optional(),
84
+ })
85
+ .optional(),
86
+ }).passthrough();
87
+ // ─── Connector ───────────────────────────────────────────────────────
88
+ /**
89
+ * rasa.io connector (v1 + v2 REST, single host).
90
+ *
91
+ * Everything routine rides `BaseRESTIntegrationConnector`'s metadata-driven machinery — generic
92
+ * per-operation CRUD, pagination loop, template-var/parent iteration, record→ExternalRecord
93
+ * conversion. Four things are genuinely idiosyncratic and are the ONLY behavioural overrides:
94
+ *
95
+ * 1. **Two-step auth** — `POST /v1/tokens` (HTTP Basic + `{key}` body) mints a JWT that every
96
+ * subsequent request presents in a CUSTOM `rasa-token` header, not `Authorization: Bearer`.
97
+ * 2. **`skip`/`limit` + response-metadata paging** — the base emits `offset`/`limit`; rasa.io uses
98
+ * `skip`/`limit` (RealityProbe: "'skip' advanced past page 1 via offset") and drives the loop from
99
+ * `metadata.next_link`, whose own `skip` value is numeric for offset endpoints and an opaque token
100
+ * for others.
101
+ * 3. **`*_since` watermarks** — the incremental filter is a per-object query param
102
+ * (`updated_since` / `created_since` / `archived_since`) read from the frozen contract.
103
+ * 4. **Conditional per-record envelope unwrapping** — rasa.io wraps each record JSON:API-style as
104
+ * `{data, links}` on SOME objects. Driven strictly by the per-object `recordUnwrapPath` verdict;
105
+ * never guessed (see {@link NormalizeResponse}).
106
+ */
107
+ /** CANONICAL registration key — the repo's catalog convention is `ClassName` == the npm package name, so
108
+ * instance discovery matches. `Integration.ClassName` is seeded to this value.
109
+ * The short `RasaConnector` key below stays registered for continuity: `ConnectorFactory.Resolve` looks the
110
+ * Integration row's ClassName up verbatim in the ClassFactory, so any tenant row still carrying the legacy
111
+ * short name resolves rather than failing with "No connector registered". Zero cost to keep; removing it
112
+ * would be a breaking change independent of this release's rename. */
113
+ let RasaConnector = class RasaConnector extends BaseRESTIntegrationConnector {
114
+ constructor() {
115
+ // ── Instance state ───────────────────────────────────────────────
116
+ super(...arguments);
117
+ /** Cached JWT + mint time (idiosyncrasy #1). */
118
+ this.cachedToken = null;
119
+ this.tokenObtainedAt = 0;
120
+ /** Response context for the in-flight fetch (see {@link RasaResponseContext}). */
121
+ this.responseCtx = null;
122
+ /** Watermark value for the in-flight fetch — consumed by {@link AppendDefaultQueryParams}. */
123
+ this.currentWatermark = null;
124
+ /** Non-fatal diagnostics raised during the in-flight fetch, drained into the FetchBatchResult. */
125
+ this.pendingWarnings = [];
126
+ /** Running max of the watermark FIELD across the batches of one object's sync pass. */
127
+ this.watermarkHighWater = new Map();
128
+ /** Integration ID observed on the last operation — lets `StableOrderingKey(name)` reach the cache. */
129
+ this.lastIntegrationID = null;
130
+ /** Merged public-spec cache (credential-free); populated lazily by discovery. */
131
+ this.specCache = null;
132
+ }
133
+ // ── Identity + capability ────────────────────────────────────────
134
+ /** Verbatim from the identity handoff / `MJ: Integrations.Name`. */
135
+ get IntegrationName() {
136
+ return 'rasa';
137
+ }
138
+ /** v1 `POST /persons|/posts|/lead-posts`, v2 `POST /lists|/contacts|/subscriptions` — all metadata-driven. */
139
+ get SupportsCreate() {
140
+ return true;
141
+ }
142
+ /** v1 `PUT /persons/{id}|/posts/{id}|/lead-posts`, v2 `PUT /contacts/{id}|/subscriptions/{id}`. */
143
+ get SupportsUpdate() {
144
+ return true;
145
+ }
146
+ /** v1 `DELETE /persons/{id}` (GDPR hard delete), v2 `DELETE /contacts/{id}` (archive). */
147
+ get SupportsDelete() {
148
+ return true;
149
+ }
150
+ /**
151
+ * `updated_since` / `created_since` are inclusive server-side filters over a monotonically
152
+ * advancing timestamp column, so the highest value seen is a safe resume point.
153
+ */
154
+ get MonotonicWatermark() {
155
+ return true;
156
+ }
157
+ /**
158
+ * Keyset hint the extractor emitted per object (`IntegrationObject.StableOrderingKey`). Returns the
159
+ * declared key, or null when the object has none — never a guess.
160
+ */
161
+ StableOrderingKey(objectName) {
162
+ if (!this.lastIntegrationID)
163
+ return null;
164
+ try {
165
+ const obj = this.GetCachedObject(this.lastIntegrationID, objectName);
166
+ const key = obj.StableOrderingKey;
167
+ return key && key.trim().length > 0 ? key.trim() : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ // ── Idiosyncrasy #1 — two-step token exchange ────────────────────
174
+ /**
175
+ * Step 1 of the vendor's documented flow: `POST {host}/v1/tokens` with an HTTP Basic
176
+ * `Authorization` header AND a `{ key: <apiKey> }` body; the JWT comes back at
177
+ * `results[0]['rasa-token']`. Step 2 (the custom header) is {@link BuildHeaders}.
178
+ */
179
+ async Authenticate(companyIntegration, contextUser) {
180
+ this.lastIntegrationID = companyIntegration.IntegrationID;
181
+ const config = await this.ParseConfig(companyIntegration, contextUser);
182
+ const token = await this.MintToken(config);
183
+ const auth = { Token: token, Config: config };
184
+ return auth;
185
+ }
186
+ /** Mints (or reuses) the session JWT. Never logs any credential-derived value. */
187
+ async MintToken(config) {
188
+ if (this.cachedToken && Date.now() - this.tokenObtainedAt < TOKEN_TTL_MS) {
189
+ return this.cachedToken;
190
+ }
191
+ const basic = Buffer.from(`${config.Username}:${config.Password}`, 'utf8').toString('base64');
192
+ const response = await fetch(`${this.HostOf(config)}/v1/tokens`, {
193
+ method: 'POST',
194
+ headers: {
195
+ Authorization: `Basic ${basic}`,
196
+ 'Content-Type': 'application/json',
197
+ Accept: 'application/json',
198
+ },
199
+ body: JSON.stringify({ key: config.APIKey }),
200
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
201
+ });
202
+ if (!response.ok) {
203
+ throw new Error(`[rasa] token exchange failed: HTTP ${response.status} from POST /v1/tokens`);
204
+ }
205
+ const token = this.ReadTokenFromBody(await response.json());
206
+ if (!token)
207
+ throw new Error('[rasa] token exchange succeeded but no "rasa-token" was present in the response');
208
+ this.cachedToken = token;
209
+ this.tokenObtainedAt = Date.now();
210
+ return token;
211
+ }
212
+ /**
213
+ * Reads the JWT out of the token envelope. Primary shape is the documented
214
+ * `ApiResonseTokenCreated` → `results[0]['rasa-token']` (`definitions.RasaToken`); the `data`-wrapped
215
+ * and v2 `access_token` shapes are accepted as transport tolerance, not as a schema claim.
216
+ */
217
+ ReadTokenFromBody(body) {
218
+ const root = this.AsRecord(body);
219
+ if (!root)
220
+ return null;
221
+ const candidates = [root];
222
+ const results = root.results;
223
+ if (Array.isArray(results) && results.length > 0) {
224
+ const first = this.AsRecord(results[0]);
225
+ candidates.unshift(first, this.AsRecord(first?.data));
226
+ }
227
+ for (const candidate of candidates) {
228
+ if (!candidate)
229
+ continue;
230
+ for (const key of ['rasa-token', 'access_token', 'token']) {
231
+ const value = candidate[key];
232
+ if (typeof value === 'string' && value.length > 0)
233
+ return value;
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+ /** Step 2: the JWT rides a CUSTOM header (`securityDefinitions.authorizer`), not `Authorization`. */
239
+ BuildHeaders(auth) {
240
+ return {
241
+ 'rasa-token': auth.Token ?? '',
242
+ Accept: 'application/json',
243
+ 'Content-Type': 'application/json',
244
+ };
245
+ }
246
+ /** Host root for every request; APIPath supplies its own `/v1` or `/v2` segment. */
247
+ GetBaseURL(_companyIntegration, auth) {
248
+ return this.HostOf(auth.Config);
249
+ }
250
+ HostOf(config) {
251
+ return (config?.BaseURL ?? RASA_API_HOST).replace(/\/+$/, '');
252
+ }
253
+ // ── Transport ────────────────────────────────────────────────────
254
+ /**
255
+ * HTTP transport with bounded retry: network blips, a 401 (mint a fresh JWT and replay once the
256
+ * cached one has aged out), and 429 honouring `Retry-After` when the vendor sends one.
257
+ */
258
+ async MakeHTTPRequest(auth, url, method, headers, body) {
259
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
260
+ let response;
261
+ try {
262
+ response = await fetch(url, this.BuildRequestInit(method, headers, body));
263
+ }
264
+ catch (err) {
265
+ if (attempt < MAX_RETRIES && this.IsRetriableNetworkError(err)) {
266
+ await this.Sleep(500 * (attempt + 1));
267
+ continue;
268
+ }
269
+ throw err;
270
+ }
271
+ if (response.status === 401 && attempt < MAX_RETRIES) {
272
+ this.cachedToken = null;
273
+ const fresh = await this.MintToken(auth.Config);
274
+ auth.Token = fresh;
275
+ headers['rasa-token'] = fresh;
276
+ continue;
277
+ }
278
+ if (response.status === 429 && attempt < MAX_RETRIES) {
279
+ await this.Sleep(this.RetryAfterMs(response.headers) ?? 2000 * 2 ** attempt);
280
+ continue;
281
+ }
282
+ return this.ToRESTResponse(response);
283
+ }
284
+ throw new Error(`[rasa] request failed after ${MAX_RETRIES} retries: ${method} ${url}`);
285
+ }
286
+ BuildRequestInit(method, headers, body) {
287
+ const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) };
288
+ if (body !== undefined && method.toUpperCase() !== 'GET')
289
+ init.body = JSON.stringify(body);
290
+ return init;
291
+ }
292
+ /** Normalizes a fetch Response, tolerating empty/non-JSON bodies (e.g. a 204 from DELETE). */
293
+ async ToRESTResponse(response) {
294
+ const outHeaders = {};
295
+ response.headers.forEach((value, key) => {
296
+ outHeaders[key.toLowerCase()] = value;
297
+ });
298
+ const text = await response.text();
299
+ let parsed = text;
300
+ if (text.length === 0)
301
+ parsed = null;
302
+ else {
303
+ try {
304
+ parsed = JSON.parse(text);
305
+ }
306
+ catch {
307
+ /* leave as text — ExtractErrorMessage/NormalizeResponse both tolerate it */
308
+ }
309
+ }
310
+ return { Status: response.status, Body: parsed, Headers: outHeaders };
311
+ }
312
+ /** `Retry-After` in seconds or as an HTTP-date; null when the vendor sent neither. */
313
+ RetryAfterMs(headers) {
314
+ const raw = headers.get('retry-after');
315
+ if (!raw)
316
+ return null;
317
+ const seconds = Number(raw);
318
+ if (Number.isFinite(seconds))
319
+ return Math.max(0, seconds * 1000);
320
+ const when = Date.parse(raw);
321
+ return Number.isFinite(when) ? Math.max(0, when - Date.now()) : null;
322
+ }
323
+ IsRetriableNetworkError(err) {
324
+ if (!(err instanceof Error))
325
+ return false;
326
+ const msg = err.message.toLowerCase();
327
+ return ['timeout', 'abort', 'econnreset', 'econnrefused', 'enotfound', 'fetch failed'].some(t => msg.includes(t));
328
+ }
329
+ Sleep(ms) {
330
+ return new Promise(resolve => setTimeout(resolve, ms));
331
+ }
332
+ // ── Idiosyncrasy #4 — conditional per-record envelope unwrapping ──
333
+ /**
334
+ * Strips the rasa.io response envelope. TWO layers, both metadata-driven:
335
+ *
336
+ * 1. the LIST envelope — `{ code, event, metadata, results, status_code }` — via the object's
337
+ * `ResponseDataKey` (`results`, `results[].data`, `results[].lists`, …);
338
+ * 2. the PER-RECORD envelope — rasa.io wraps some records JSON:API-style as `{ data, links }` —
339
+ * via the object's `Configuration.recordUnwrapPath`, which materializes the RealityProbe's
340
+ * `recordEnvelopeShape` verdict. Present ⇒ verdict was `nested` ⇒ unwrap. **Absent ⇒ we do NOT
341
+ * unwrap** — a missing verdict is DEFERRED, never resolved as "flat" by assumption. When an
342
+ * un-verdicted object nonetheless arrives looking exactly like the vendor's envelope
343
+ * (`{data, links}` and nothing else), we emit a `RECORD_ENVELOPE_UNVERIFIED` warning naming the
344
+ * object so the gap is loud in the run artifact rather than silently mis-mapped.
345
+ */
346
+ NormalizeResponse(rawBody, responseDataKey) {
347
+ const ctx = this.responseCtx;
348
+ const path = ctx ? ctx.DataPath : responseDataKey;
349
+ const records = this.ExtractByPath(rawBody, path);
350
+ if (ctx && !ctx.UnwrapDeclared)
351
+ this.FlagUndeclaredEnvelope(ctx.ObjectName, records);
352
+ return records;
353
+ }
354
+ /**
355
+ * Evaluates a dotted extraction path against a response body. A segment may be a plain key or a
356
+ * `key[]` list segment; arrays are flattened either way, so `results`, `results[].data` and
357
+ * `results[].data.topics` all resolve uniformly. A null path returns the body itself (array or object).
358
+ */
359
+ ExtractByPath(body, path) {
360
+ if (body == null)
361
+ return [];
362
+ let current = Array.isArray(body) ? [...body] : [body];
363
+ if (path) {
364
+ for (const segment of path.split('.')) {
365
+ const key = segment.endsWith('[]') ? segment.slice(0, -2) : segment;
366
+ const next = [];
367
+ for (const node of current) {
368
+ const record = this.AsRecord(node);
369
+ const value = record ? record[key] : undefined;
370
+ if (value == null)
371
+ continue;
372
+ if (Array.isArray(value))
373
+ next.push(...value);
374
+ else
375
+ next.push(value);
376
+ }
377
+ current = next;
378
+ }
379
+ }
380
+ return current.filter((n) => this.AsRecord(n) !== null);
381
+ }
382
+ /** Raises a one-per-fetch diagnostic when an un-verdicted object arrives inside the vendor envelope. */
383
+ FlagUndeclaredEnvelope(objectName, records) {
384
+ if (records.length === 0)
385
+ return;
386
+ const looksWrapped = records.every(r => {
387
+ const keys = Object.keys(r);
388
+ return keys.includes('data') && keys.every(k => k === 'data' || k === 'links');
389
+ });
390
+ if (!looksWrapped)
391
+ return;
392
+ if (this.pendingWarnings.some(w => w.Code === 'RECORD_ENVELOPE_UNVERIFIED' && w.Data?.object === objectName)) {
393
+ return;
394
+ }
395
+ this.pendingWarnings.push({
396
+ Code: 'RECORD_ENVELOPE_UNVERIFIED',
397
+ Message: `"${objectName}": records arrived in the vendor's per-record envelope ({data,links}) but no ` +
398
+ `recordEnvelopeShape verdict is declared for this object (Configuration.recordUnwrapPath is ` +
399
+ `absent). The connector DEFERS rather than assuming a shape — re-run the reality probe for ` +
400
+ `this object and amend the contract; field mapping is unreliable until then.`,
401
+ Data: { object: objectName, observedTopLevelKeys: Object.keys(records[0]) },
402
+ });
403
+ }
404
+ // ── Idiosyncrasy #2 — skip/limit + next_link paging ──────────────
405
+ /**
406
+ * rasa.io pages with `skip` + `limit` (NOT the base's `offset`/`limit`). The `skip` value is a
407
+ * numeric offset on offset endpoints and an opaque token on the endpoints whose `next_link` carries
408
+ * a non-numeric `skip` — {@link ExtractPaginationInfo} classifies it, we replay whichever it gave us.
409
+ * `limit` is capped by the object's declared `DefaultPageSize` (the v1 spec's hard `maximum: 50`).
410
+ */
411
+ BuildPaginatedURL(basePath, obj, _page, offset, cursor, effectivePageSize) {
412
+ const cap = obj.DefaultPageSize ?? RASA_FALLBACK_PAGE_SIZE;
413
+ const limit = Math.max(1, Math.min(effectivePageSize ?? cap, cap));
414
+ const params = new URLSearchParams();
415
+ if (cursor)
416
+ params.set('skip', cursor);
417
+ else if (offset > 0)
418
+ params.set('skip', String(offset));
419
+ params.set('limit', String(limit));
420
+ return `${basePath}${basePath.includes('?') ? '&' : '?'}${params.toString()}`;
421
+ }
422
+ /**
423
+ * Drives the loop from the response-metadata envelope
424
+ * (`PersonsApiResponseMetadata` / `InsightApiResponseMetadata` / v2 `ResponseMetadata`):
425
+ * `next_link` is the vendor's own next-page URL. A short page (fewer records than requested) always
426
+ * terminates — the vendor emits `next_link` past the end of the dataset, so it is not a sufficient
427
+ * stop signal on its own.
428
+ */
429
+ ExtractPaginationInfo(rawBody, _paginationType, _currentPage, currentOffset, pageSize) {
430
+ const root = this.AsRecord(rawBody);
431
+ const metadata = this.AsRecord(root?.metadata);
432
+ const totalRecords = typeof metadata?.record_count === 'number' ? metadata.record_count : undefined;
433
+ const returned = this.ExtractByPath(rawBody, this.responseCtx?.DataPath ?? 'results').length;
434
+ if (returned === 0 || (pageSize > 0 && returned < pageSize)) {
435
+ return { HasMore: false, TotalRecords: totalRecords };
436
+ }
437
+ const nextLink = typeof metadata?.next_link === 'string' ? metadata.next_link : '';
438
+ if (nextLink.length === 0)
439
+ return { HasMore: false, TotalRecords: totalRecords };
440
+ const skip = this.ReadSkipParam(nextLink);
441
+ if (skip !== null && !/^\d+$/.test(skip)) {
442
+ return { HasMore: true, NextCursor: skip, TotalRecords: totalRecords };
443
+ }
444
+ const nextOffset = skip !== null ? Number(skip) : currentOffset + returned;
445
+ return { HasMore: true, NextOffset: nextOffset, TotalRecords: totalRecords };
446
+ }
447
+ /** Reads the `skip` query param out of a vendor `next_link`, tolerating a relative URL. */
448
+ ReadSkipParam(nextLink) {
449
+ try {
450
+ return new URL(nextLink, RASA_API_HOST).searchParams.get('skip');
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }
456
+ // ── Idiosyncrasy #3 — *_since watermarks ─────────────────────────
457
+ /**
458
+ * Appends the object's declared incremental filter — `Configuration.watermarkParam`
459
+ * (`updated_since` / `created_since` / `archived_since`) — to every request of a watermarked
460
+ * object. Applied HERE rather than in {@link BuildPaginatedURL} so it also reaches non-paginated
461
+ * single-page fetches. Never invents a param name: silent metadata ⇒ full pull.
462
+ */
463
+ AppendDefaultQueryParams(url, obj) {
464
+ const withDefaults = super.AppendDefaultQueryParams(url, obj);
465
+ const watermark = this.currentWatermark;
466
+ if (!watermark || !obj.SupportsIncrementalSync)
467
+ return withDefaults;
468
+ const param = this.ObjectConfig(obj).watermarkParam;
469
+ if (!param)
470
+ return withDefaults;
471
+ if (new RegExp(`[?&]${param}=`, 'i').test(withDefaults))
472
+ return withDefaults;
473
+ const separator = withDefaults.includes('?') ? '&' : '?';
474
+ return `${withDefaults}${separator}${encodeURIComponent(param)}=${encodeURIComponent(watermark)}`;
475
+ }
476
+ // ── Fetch orchestration ──────────────────────────────────────────
477
+ /**
478
+ * Thin wrapper around the base's metadata-driven fetch. It (a) publishes the per-object response
479
+ * context the base's object-less `NormalizeResponse` signature can't carry, (b) bridges the frozen
480
+ * contract's `accessPath.parentObject` onto the key the base's parent resolver reads, (c) tracks the
481
+ * watermark high-water mark across the object's batches and emits it ONLY on the terminal batch, so
482
+ * a mid-pass failure (which throws before we return) leaves the stored watermark untouched.
483
+ */
484
+ async FetchChanges(ctx) {
485
+ this.lastIntegrationID = ctx.CompanyIntegration.IntegrationID;
486
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
487
+ this.AssertReadable(obj, ctx.ObjectName);
488
+ this.PublishResponseContext(obj, ctx.ObjectName);
489
+ this.BridgeAccessPathToParentConfig(obj);
490
+ this.currentWatermark = ctx.WatermarkValue;
491
+ this.pendingWarnings = [];
492
+ if (this.IsFirstBatch(ctx))
493
+ this.watermarkHighWater.delete(ctx.ObjectName);
494
+ const result = await super.FetchChanges(ctx);
495
+ this.AdvanceWatermarkHighWater(ctx.ObjectName, obj, result.Records);
496
+ const warnings = [...(result.Warnings ?? []), ...this.pendingWarnings];
497
+ this.pendingWarnings = [];
498
+ return {
499
+ ...result,
500
+ Warnings: warnings.length > 0 ? warnings : undefined,
501
+ NewWatermarkValue: result.HasMore ? undefined : this.FinalWatermark(ctx),
502
+ };
503
+ }
504
+ /**
505
+ * Refuses a read against an object that declares NO read door, instead of letting the base compose a
506
+ * request against the API ROOT. rasa.io has one such object — `Lead Post` is write-only (`APIPath`
507
+ * empty, `CreateAPIPath: /v1/lead-posts`, Create+Update only) — and a caller that syncs "all objects"
508
+ * will ask it to read. Without this guard the empty path resolves to `GET {baseURL}/`, whose failure
509
+ * mode is worse than an error: if the vendor root ever answers 200 (a status/banner document), the
510
+ * envelope reader would treat that payload as this object's record set. Fail precisely and name the
511
+ * cause instead.
512
+ */
513
+ AssertReadable(obj, objectName) {
514
+ if ((obj.APIPath ?? '').trim().length > 0)
515
+ return;
516
+ throw new Error(`Object "${objectName}" declares no read path (APIPath is empty) and cannot be read. ` +
517
+ `It is write-only in this connector's contract; use its per-operation write path instead.`);
518
+ }
519
+ /** GetRecord also runs NormalizeResponse — publish the same response context first. */
520
+ async GetRecord(ctx) {
521
+ const ci = ctx.CompanyIntegration;
522
+ this.lastIntegrationID = ci.IntegrationID;
523
+ this.PublishResponseContext(this.GetCachedObject(ci.IntegrationID, ctx.ObjectName), ctx.ObjectName);
524
+ this.currentWatermark = null;
525
+ return super.GetRecord(ctx);
526
+ }
527
+ IsFirstBatch(ctx) {
528
+ return !ctx.CurrentOffset && !ctx.CurrentPage && !ctx.CurrentCursor && !ctx.AfterKeyValue;
529
+ }
530
+ /** Resolves and stores the extraction path + unwrap-verdict state for the object being fetched. */
531
+ PublishResponseContext(obj, objectName) {
532
+ const config = this.ObjectConfig(obj);
533
+ this.responseCtx = {
534
+ ObjectName: objectName,
535
+ DataPath: this.EffectiveDataPath(obj, config),
536
+ UnwrapDeclared: !!config.recordUnwrapPath,
537
+ };
538
+ }
539
+ /**
540
+ * Composes the LIST path (`ResponseDataKey`) with the verdicted PER-RECORD unwrap path. The unwrap
541
+ * verdict is anchored at the list root, so `results` + `data` → `results[].data`, and
542
+ * `results[].topics` + `data.topics` → `results[].data.topics` (the probe's re-point: the declared
543
+ * fields live under `data`, not at the record root). No verdict ⇒ the declared key verbatim.
544
+ */
545
+ EffectiveDataPath(obj, config) {
546
+ const declaredKey = obj.ResponseDataKey && obj.ResponseDataKey.length > 0 ? obj.ResponseDataKey : null;
547
+ const unwrap = config.recordUnwrapPath;
548
+ if (!unwrap)
549
+ return declaredKey;
550
+ const listRoot = (declaredKey ?? 'results').split('[]')[0].split('.')[0];
551
+ return `${listRoot}[].${unwrap}`;
552
+ }
553
+ /**
554
+ * The frozen contract expresses an object's owner as `Configuration.accessPath.parentObject`; the
555
+ * base's template-var resolver reads `Configuration.parentObjectName`. Same declared fact, two
556
+ * spellings — bridged here (in memory only, never persisted) so a `{var}` path resolves its parent
557
+ * instead of returning PARENT_UNRESOLVED. Additive: an existing `parentObjectName` always wins.
558
+ */
559
+ BridgeAccessPathToParentConfig(obj) {
560
+ if (!/\{\w+\}/.test(obj.APIPath ?? ''))
561
+ return;
562
+ const parent = this.ObjectConfig(obj).accessPath?.parentObject;
563
+ if (!parent)
564
+ return;
565
+ try {
566
+ const raw = obj.Configuration;
567
+ const parsed = raw ? JSON.parse(raw) : {};
568
+ if (typeof parsed.parentObjectName === 'string' && parsed.parentObjectName.length > 0)
569
+ return;
570
+ obj.Configuration = JSON.stringify({ ...parsed, parentObjectName: parent });
571
+ }
572
+ catch {
573
+ /* unparseable configuration — the base already warns and skips rather than guessing */
574
+ }
575
+ }
576
+ /** Folds this batch's watermark-field values into the object's running high-water mark. */
577
+ AdvanceWatermarkHighWater(objectName, obj, records) {
578
+ const field = obj.IncrementalWatermarkField;
579
+ if (!field || records.length === 0)
580
+ return;
581
+ let best = this.watermarkHighWater.get(objectName) ?? null;
582
+ for (const record of records) {
583
+ const raw = record.Fields[field];
584
+ if (typeof raw !== 'string' && typeof raw !== 'number')
585
+ continue;
586
+ const candidate = String(raw);
587
+ const parsed = Date.parse(candidate);
588
+ if (!Number.isFinite(parsed))
589
+ continue;
590
+ if (best === null || parsed > Date.parse(best))
591
+ best = candidate;
592
+ }
593
+ if (best !== null)
594
+ this.watermarkHighWater.set(objectName, best);
595
+ }
596
+ /** Terminal-batch watermark: the run's high-water mark, else the caller's value unchanged. */
597
+ FinalWatermark(ctx) {
598
+ const best = this.watermarkHighWater.get(ctx.ObjectName);
599
+ this.watermarkHighWater.delete(ctx.ObjectName);
600
+ return best ?? ctx.WatermarkValue ?? undefined;
601
+ }
602
+ // ── Write-path envelope helpers (generic CRUD stays generic) ─────
603
+ /**
604
+ * The generic `CreateRecord` is used as-is; only ID extraction is vendor-shaped. rasa.io returns the
605
+ * new record inside the standard envelope — `PersonApiPostResponse.example` is
606
+ * `{ code, metadata, results: [ { id } ] }` — so the base's root-level `body.id` probe finds nothing.
607
+ * We look at `results[0]` and, when the per-record envelope is in play, `results[0].data`.
608
+ */
609
+ ExtractIDFromResponse(response, idLocation) {
610
+ if (idLocation && idLocation !== 'body')
611
+ return super.ExtractIDFromResponse(response, idLocation);
612
+ const root = this.AsRecord(response.Body);
613
+ const results = root?.results;
614
+ const first = Array.isArray(results) && results.length > 0 ? this.AsRecord(results[0]) : null;
615
+ for (const candidate of [first, this.AsRecord(first?.data), root]) {
616
+ if (!candidate)
617
+ continue;
618
+ const value = candidate.id;
619
+ if (typeof value === 'string' || typeof value === 'number')
620
+ return String(value);
621
+ }
622
+ return super.ExtractIDFromResponse(response, idLocation);
623
+ }
624
+ /** Vendor error envelope: `metadata.errors` alongside the standard `code` / `status_code`. */
625
+ ExtractErrorMessage(response) {
626
+ const root = this.AsRecord(response.Body);
627
+ const errors = this.AsRecord(root?.metadata)?.errors;
628
+ if (typeof errors === 'string' && errors.length > 0)
629
+ return errors;
630
+ if (Array.isArray(errors) && errors.length > 0)
631
+ return JSON.stringify(errors);
632
+ return super.ExtractErrorMessage(response);
633
+ }
634
+ // ── Discovery — credential-free public schema of record ──────────
635
+ /**
636
+ * Enumerates the object universe from rasa.io's PUBLICLY-PUBLISHED OpenAPI documents (both API
637
+ * generations), unioned with the persisted Declared metadata. Deliberately credential-free: the
638
+ * runtime structure self-check runs without a token, so standard objects MUST re-yield without one.
639
+ * A live credential is additive only (see {@link IntrospectSchema}). If the specs are unreachable the
640
+ * method degrades to the persisted set rather than failing the sync.
641
+ */
642
+ async DiscoverObjects(companyIntegration, contextUser) {
643
+ this.lastIntegrationID = companyIntegration.IntegrationID;
644
+ const persisted = await super.DiscoverObjects(companyIntegration, contextUser);
645
+ const spec = await this.LoadPublicSpecs();
646
+ if (!spec)
647
+ return persisted;
648
+ const byName = new Map();
649
+ for (const obj of persisted)
650
+ byName.set(obj.Name.toLowerCase(), obj);
651
+ const declaredBySchema = this.IndexDeclaredObjectsByRecordSchema(companyIntegration.IntegrationID);
652
+ const declaredDoors = this.IndexDeclaredDoors(companyIntegration.IntegrationID);
653
+ for (const recordType of spec.RecordTypes) {
654
+ const mapped = declaredBySchema.get(recordType.DefinitionName);
655
+ const name = mapped ?? this.DeriveObjectName(recordType.DefinitionName);
656
+ // A spec record type whose DOOR is already served by a Declared object is that same
657
+ // resource under its schema-derived alias, NOT a second object. Name dedup alone cannot
658
+ // catch this: `DeriveObjectName('CommunitiesApiGetResponseItem')` yields the PLURAL
659
+ // `Communities`, which never collides with the Declared singular `Community` — so the
660
+ // pre-fix code emitted BOTH, i.e. two objects (two target tables) for one endpoint.
661
+ // The door is the structural identity of a resource; compare on that.
662
+ if (!mapped && declaredDoors.has(this.NormalizeDoor(recordType.Door)))
663
+ continue;
664
+ if (byName.has(name.toLowerCase()))
665
+ continue;
666
+ byName.set(name.toLowerCase(), {
667
+ Name: name,
668
+ Label: name,
669
+ Description: recordType.Summary ??
670
+ `Record type "${recordType.DefinitionName}" exposed at ${recordType.Door} (rasa.io public OpenAPI).`,
671
+ SupportsIncrementalSync: false,
672
+ SupportsWrite: false,
673
+ });
674
+ }
675
+ return [...byName.values()];
676
+ }
677
+ /**
678
+ * Fields for one object: the persisted Declared set, unioned with every property the PUBLIC spec
679
+ * declares on the object's backing definitions (`Configuration.recordSchemas`). Credential-free —
680
+ * a token contributes nothing here. Declared entries win on collision; spec-only properties are
681
+ * appended so a vendor schema addition surfaces without a metadata re-extract.
682
+ */
683
+ async DiscoverFields(companyIntegration, objectName, contextUser) {
684
+ this.lastIntegrationID = companyIntegration.IntegrationID;
685
+ const declared = await this.SafeDeclaredFields(companyIntegration, objectName, contextUser);
686
+ const schemaNames = this.RecordSchemaNamesFor(companyIntegration.IntegrationID, objectName);
687
+ const spec = schemaNames.length > 0 ? await this.LoadPublicSpecs() : null;
688
+ if (!spec)
689
+ return declared;
690
+ const byName = new Map();
691
+ for (const field of declared)
692
+ byName.set(field.Name.toLowerCase(), field);
693
+ for (const schemaName of schemaNames) {
694
+ for (const field of this.FieldsFromDefinition(spec.Definitions, schemaName)) {
695
+ if (!byName.has(field.Name.toLowerCase()))
696
+ byName.set(field.Name.toLowerCase(), field);
697
+ }
698
+ }
699
+ return [...byName.values()];
700
+ }
701
+ /**
702
+ * Sample-union enrichment (MJ connector standard): after the cache-driven introspection, sample each
703
+ * object's LIVE read shape and union it into the declared field set — this is where a tenant's own
704
+ * custom person attributes reach the schema. Best-effort; a sample failure leaves the declared set
705
+ * untouched. Overrides `IntrospectSchema`, NOT `DiscoverFields` (which would recurse into
706
+ * `DiscoverFieldsViaFetch`'s own fallback).
707
+ *
708
+ * SEQUENTIAL, and that is load-bearing. {@link FetchChanges} publishes its per-object response
709
+ * context onto INSTANCE state (`responseCtx`/`currentWatermark`/`pendingWarnings`) which is read
710
+ * back AFTER the HTTP round-trip. Sampling the catalog with `Promise.all` therefore raced: every
711
+ * concurrent call overwrote `responseCtx`, so a response was normalized against some OTHER object's
712
+ * `DataPath`, matched nothing, and yielded ZERO records — cleanly, with no throw and no warning.
713
+ * Observed live: 17 of 18 objects that reached the sampler logged `rows=0 | cols: []`, the sole
714
+ * survivor being the one that happened to win the race, and the resulting all-null field widths
715
+ * were what made the framework's unknown-width defect drop 8,841 records. The sync engine iterates
716
+ * objects sequentially, which is why the identical read path works there. Do not re-parallelize
717
+ * this without first threading the response context through the call instead of the instance.
718
+ */
719
+ async IntrospectSchema(companyIntegration, contextUser) {
720
+ const info = await super.IntrospectSchema(companyIntegration, contextUser);
721
+ for (const obj of info.Objects) {
722
+ try {
723
+ const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
724
+ obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
725
+ }
726
+ catch (err) {
727
+ // Best-effort — the declared set stands. But NEVER swallow silently: a bare `catch {}`
728
+ // here is what hid 16 objects failing before they ever reached the sampler.
729
+ console.warn(`[RasaConnector.IntrospectSchema] sample-union skipped for "${obj.ExternalName}": ` +
730
+ `${err instanceof Error ? err.message : String(err)}`);
731
+ }
732
+ }
733
+ return info;
734
+ }
735
+ /** Declared fields, tolerating an object the cache doesn't carry (a spec-only discovery). */
736
+ async SafeDeclaredFields(companyIntegration, objectName, contextUser) {
737
+ try {
738
+ return await super.DiscoverFields(companyIntegration, objectName, contextUser);
739
+ }
740
+ catch {
741
+ return [];
742
+ }
743
+ }
744
+ /** The Swagger definition name(s) backing an object, per its declared `Configuration.recordSchemas`. */
745
+ RecordSchemaNamesFor(integrationID, objectName) {
746
+ try {
747
+ return this.ObjectConfig(this.GetCachedObject(integrationID, objectName)).recordSchemas ?? [];
748
+ }
749
+ catch {
750
+ return [];
751
+ }
752
+ }
753
+ /** Reverse index: Swagger definition name → the Declared object that claims it. */
754
+ IndexDeclaredObjectsByRecordSchema(integrationID) {
755
+ const index = new Map();
756
+ for (const obj of this.ActiveObjects(integrationID)) {
757
+ for (const schemaName of this.ObjectConfig(obj).recordSchemas ?? []) {
758
+ if (!index.has(schemaName))
759
+ index.set(schemaName, obj.Name);
760
+ }
761
+ }
762
+ return index;
763
+ }
764
+ /**
765
+ * The set of doors (normalized endpoint paths) already served by a Declared object. Used to suppress
766
+ * spec-only "objects" that are really an alias of a Declared resource — see the door test in
767
+ * {@link DiscoverObjects}. Both the frozen contract's `accessPath.door` and the row's own `APIPath`
768
+ * are indexed, since either may carry the endpoint for a given object.
769
+ */
770
+ IndexDeclaredDoors(integrationID) {
771
+ const doors = new Set();
772
+ for (const obj of this.ActiveObjects(integrationID)) {
773
+ for (const raw of [this.ObjectConfig(obj).accessPath?.door, obj.APIPath]) {
774
+ const normalized = this.NormalizeDoor(raw);
775
+ if (normalized.length > 0)
776
+ doors.add(normalized);
777
+ }
778
+ }
779
+ return doors;
780
+ }
781
+ /**
782
+ * Canonical form of an endpoint path for identity comparison: version segment dropped (a Declared
783
+ * `APIPath` carries `/v1`|`/v2`; a Swagger 2.0 path key does not — the prefix lives in `basePath`),
784
+ * path parameters collapsed to `{}` (`/persons/{id}/topics` ≡ `/persons/{person_id}/topics`), and
785
+ * casing/trailing slashes normalized.
786
+ */
787
+ NormalizeDoor(path) {
788
+ if (!path)
789
+ return '';
790
+ return path
791
+ .replace(/^\/?(v\d+)\//i, '/')
792
+ .replace(/\{[^}]*\}/g, '{}')
793
+ .replace(/\/+$/, '')
794
+ .toLowerCase();
795
+ }
796
+ /**
797
+ * Every ACTIVE IntegrationObject for this integration, read from the same engine cache the base
798
+ * class reads (`IntegrationEngineBase.GetActiveIntegrationObjects`). Empty when the engine has not
799
+ * been configured yet — discovery then degrades to the spec-only naming, never throws.
800
+ */
801
+ ActiveObjects(integrationID) {
802
+ try {
803
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integrationID);
804
+ }
805
+ catch {
806
+ return [];
807
+ }
808
+ }
809
+ // ── Public-spec reading ──────────────────────────────────────────
810
+ /**
811
+ * Fetches + merges rasa.io's public Swagger documents (v1 and v2) with NO credential, and walks
812
+ * every readable operation to enumerate RECORD TYPES (not entry points): each GET's success schema
813
+ * is resolved through the `{code, metadata, results[]}` envelope down to the item definition. Only
814
+ * the item type itself is yielded — nested `$ref`'d sub-objects are NOT promoted to record types
815
+ * (see {@link EnumerateRecordTypes}). Cached for the process lifetime.
816
+ */
817
+ async LoadPublicSpecs() {
818
+ if (this.specCache)
819
+ return this.specCache;
820
+ const docs = [];
821
+ for (const url of RASA_PUBLIC_SPEC_URLS) {
822
+ const doc = await this.FetchSpec(url);
823
+ if (doc)
824
+ docs.push(doc);
825
+ }
826
+ if (docs.length === 0)
827
+ return null;
828
+ const definitions = {};
829
+ for (const doc of docs)
830
+ Object.assign(definitions, doc.definitions ?? {});
831
+ const recordTypes = [];
832
+ const seen = new Set();
833
+ for (const doc of docs) {
834
+ for (const found of this.EnumerateRecordTypes(doc, definitions)) {
835
+ if (seen.has(found.DefinitionName))
836
+ continue;
837
+ seen.add(found.DefinitionName);
838
+ recordTypes.push(found);
839
+ }
840
+ }
841
+ this.specCache = { Definitions: definitions, RecordTypes: recordTypes };
842
+ return this.specCache;
843
+ }
844
+ async FetchSpec(url) {
845
+ try {
846
+ const response = await fetch(url, {
847
+ method: 'GET',
848
+ headers: { Accept: 'application/json' },
849
+ signal: AbortSignal.timeout(SPEC_FETCH_TIMEOUT_MS),
850
+ });
851
+ if (!response.ok)
852
+ return null;
853
+ return (await response.json());
854
+ }
855
+ catch {
856
+ return null; // offline / blocked — discovery degrades to the persisted Declared set
857
+ }
858
+ }
859
+ /** Walks every readable operation in one document, yielding the record types it exposes. */
860
+ EnumerateRecordTypes(doc, definitions) {
861
+ const out = [];
862
+ for (const [path, operations] of Object.entries(doc.paths ?? {})) {
863
+ for (const [method, operation] of Object.entries(operations)) {
864
+ if (method.toLowerCase() !== 'get')
865
+ continue;
866
+ const schema = operation.responses?.['200']?.schema ?? operation.responses?.['201']?.schema;
867
+ const itemName = this.ResolveRecordDefinitionName(schema, definitions);
868
+ if (!itemName)
869
+ continue;
870
+ out.push({ DefinitionName: itemName, Door: path, Summary: operation.summary });
871
+ // Deliberately NOT promoting `$ref`'d child properties to objects. A child definition
872
+ // (`AttributesItem`, `UserAction`, `AnalyticsActivityData`, `ExternalIdentifier`, …) is a
873
+ // nested structure of its parent record, reachable only THROUGH the parent's door. Emitted
874
+ // as a top-level object it carries no door of its own, so it can never be fetched — a
875
+ // permanently-empty target table. When a child collection IS independently syncable, the
876
+ // Declared metadata says so with its own `accessPath` (door + nesting) and its
877
+ // `recordSchemas` claims the definition, so `declaredBySchema` already maps it and it is
878
+ // already present in `persisted`. Promoting the UNdeclared remainder can therefore only
879
+ // ever manufacture junk. Measured on rasa.io: this loop plus plural-alias duplication
880
+ // inflated discovery to 52 objects against 34 Declared.
881
+ }
882
+ }
883
+ return out;
884
+ }
885
+ /** Descends the `{code, metadata, results[]}` envelope to the item definition name. */
886
+ ResolveRecordDefinitionName(schema, definitions) {
887
+ const rootName = this.RefName(schema);
888
+ const root = rootName ? definitions[rootName] : schema;
889
+ if (!root)
890
+ return null;
891
+ const results = root.properties?.results;
892
+ const itemName = this.RefName(results?.items) ?? this.RefName(results);
893
+ if (itemName) {
894
+ const item = definitions[itemName];
895
+ // One more hop when the item is itself the {data, links} per-record envelope.
896
+ const inner = this.RefName(item?.properties?.data);
897
+ return inner ?? itemName;
898
+ }
899
+ return rootName;
900
+ }
901
+ RefName(schema) {
902
+ const ref = schema?.$ref;
903
+ if (typeof ref !== 'string')
904
+ return null;
905
+ const parts = ref.split('/');
906
+ return parts.length > 0 ? parts[parts.length - 1] : null;
907
+ }
908
+ /** Maps a Swagger definition's properties onto ExternalFieldSchema. Constraints come from the spec only. */
909
+ FieldsFromDefinition(definitions, definitionName) {
910
+ const definition = definitions[definitionName];
911
+ if (!definition?.properties)
912
+ return [];
913
+ const required = new Set(definition.required ?? []);
914
+ return Object.entries(definition.properties).map(([name, property]) => ({
915
+ Name: name,
916
+ Label: name,
917
+ Description: property.description,
918
+ DataType: property.format ?? property.type ?? 'string',
919
+ IsRequired: required.has(name),
920
+ IsUniqueKey: false,
921
+ IsReadOnly: false,
922
+ MaxLength: typeof property.maxLength === 'number' ? property.maxLength : null,
923
+ }));
924
+ }
925
+ /** Human-readable object name for a spec-only record type (`PersonsApiGetResponseItem` → `Persons`). */
926
+ DeriveObjectName(definitionName) {
927
+ const stripped = definitionName
928
+ .replace(/(Api)?(Get|Post|Put|Patch|Delete)?(Response|Request|Body)?(Item)?$/i, '')
929
+ .replace(/(Api)$/i, '');
930
+ const base = stripped.length > 0 ? stripped : definitionName;
931
+ return base.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
932
+ }
933
+ // ── Connection test ──────────────────────────────────────────────
934
+ /**
935
+ * Proves the whole auth chain end-to-end: mint the JWT (step 1) and spend it on a read (step 2).
936
+ * Uses `/v1/communities` — the smallest documented readable surface every v1 credential can reach.
937
+ */
938
+ async TestConnection(companyIntegration, contextUser) {
939
+ try {
940
+ const config = await this.ParseConfig(companyIntegration, contextUser);
941
+ const token = await this.MintToken(config);
942
+ const response = await fetch(`${this.HostOf(config)}/v1/communities`, {
943
+ method: 'GET',
944
+ headers: { 'rasa-token': token, Accept: 'application/json' },
945
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
946
+ });
947
+ if (!response.ok) {
948
+ return { Success: false, Message: `Connection failed: HTTP ${response.status} from GET /v1/communities` };
949
+ }
950
+ const communities = this.ExtractByPath(await response.json(), 'results');
951
+ return {
952
+ Success: true,
953
+ Message: `Connected to rasa.io — ${communities.length} community(ies) reachable with this credential`,
954
+ ServerVersion: 'rasa.io API v1 + v2',
955
+ };
956
+ }
957
+ catch (err) {
958
+ return { Success: false, Message: `Connection failed: ${err instanceof Error ? err.message : String(err)}` };
959
+ }
960
+ }
961
+ // ── Configuration parsing ────────────────────────────────────────
962
+ /** Credential record first (the supported path), CompanyIntegration.Configuration as the fallback. */
963
+ async ParseConfig(companyIntegration, contextUser, provider) {
964
+ if (companyIntegration.CredentialID) {
965
+ return this.ParseConfigFromCredential(companyIntegration.CredentialID, contextUser, provider);
966
+ }
967
+ if (companyIntegration.Configuration) {
968
+ return this.NormalizeConfigValues(JSON.parse(companyIntegration.Configuration));
969
+ }
970
+ throw new Error('[rasa] connector requires either a CredentialID or a CompanyIntegration.Configuration JSON');
971
+ }
972
+ async ParseConfigFromCredential(credentialID, contextUser, provider) {
973
+ const md = provider ?? new Metadata();
974
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
975
+ await credential.Load(credentialID);
976
+ if (!credential.Values)
977
+ throw new Error('[rasa] credential record has no Values JSON');
978
+ return this.NormalizeConfigValues(JSON.parse(credential.Values));
979
+ }
980
+ /** Case-insensitive key matching over the credential payload, then strict Zod validation. */
981
+ NormalizeConfigValues(values) {
982
+ const read = (...aliases) => {
983
+ for (const [key, value] of Object.entries(values)) {
984
+ if (typeof value !== 'string' || value.length === 0)
985
+ continue;
986
+ if (aliases.includes(key.toLowerCase()))
987
+ return value;
988
+ }
989
+ return undefined;
990
+ };
991
+ const parsed = RasaConnectionConfigSchema.safeParse({
992
+ APIKey: read('apikey', 'api_key', 'key'),
993
+ Username: read('username', 'user', 'email'),
994
+ Password: read('password', 'pass'),
995
+ BaseURL: read('baseurl', 'base_url', 'host'),
996
+ });
997
+ if (!parsed.success) {
998
+ const missing = parsed.error.issues.map(i => i.path.join('.')).join(', ');
999
+ throw new Error(`[rasa] configuration is missing or invalid for: ${missing} (need APIKey, Username, Password)`);
1000
+ }
1001
+ return parsed.data;
1002
+ }
1003
+ // ── Small shared helpers ─────────────────────────────────────────
1004
+ /** Parses an IntegrationObject's Configuration JSON into the typed per-object shape. */
1005
+ ObjectConfig(obj) {
1006
+ try {
1007
+ const raw = obj.Configuration;
1008
+ if (!raw)
1009
+ return {};
1010
+ const parsed = RasaObjectConfigSchema.safeParse(JSON.parse(raw));
1011
+ return parsed.success ? parsed.data : {};
1012
+ }
1013
+ catch {
1014
+ return {};
1015
+ }
1016
+ }
1017
+ /** Narrows an unknown to a plain object record, or null. */
1018
+ AsRecord(value) {
1019
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1020
+ ? value
1021
+ : null;
1022
+ }
1023
+ };
1024
+ RasaConnector = __decorate([
1025
+ RegisterClass(BaseIntegrationConnector, '@memberjunction/connector-rasa-io'),
1026
+ RegisterClass(BaseIntegrationConnector, 'RasaConnector')
1027
+ ], RasaConnector);
1028
+ export { RasaConnector };
1029
+ //# sourceMappingURL=RasaConnector.js.map