@memberjunction/connector-wordpress 1.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,1782 @@
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 { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
10
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, buildBasicAuthHeaderValue, ClassifyError, } from '@memberjunction/integration-engine';
11
+ import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
12
+ // ─── Design note ──────────────────────────────────────────────────────────────
13
+ //
14
+ // The connector is PURE MECHANISM. There is NO baked object list, NO field catalog and NO
15
+ // PK/FK/required/readonly constants in this file. The stock-install FLOOR (WP 7.1 + WooCommerce
16
+ // 11.0.1, 78 record types / 1,052 fields / 222 paths) lives in the Declared metadata
17
+ // (metadata/integrations/wordpress/.wordpress.integration.json) and reaches the connector through the
18
+ // IntegrationEngineBase cache. On top of that floor the connector UNIONS what the CONNECTION's own
19
+ // site actually exposes, because WordPress's object universe is PER-SITE, never per-vendor:
20
+ //
21
+ // * `DiscoverObjects` GETs the site's own ROUTE INDEX (`GET <apiRoot>`) and derives listable
22
+ // collections from the registered routes — so a site's custom post types, custom taxonomies and
23
+ // third-party plugin namespaces become visible objects that no pinned source could have known.
24
+ // * `DiscoverFields` issues `OPTIONS <route>` and reads the endpoint's real JSON Schema.
25
+ // * `IntrospectSchema` unions declared ∪ route-index-discovered ∪ OPTIONS-described ∪ live-sampled.
26
+ //
27
+ // Nothing is ever DEACTIVATED from discovery (`DiscoveryIsAuthoritative` stays false): a namespace can
28
+ // vanish from one site's index behind a feature flag or a lazy-load filter, and field visibility is
29
+ // capability-gated (`context=edit`), so absence proves nothing.
30
+ //
31
+ // What this class supplies (the WordPress protocol shape over REST/JSON):
32
+ // - Auth: HTTP Basic (RFC 7617) with a WordPress APPLICATION PASSWORD, encoded via the shared
33
+ // auth-helper (buildBasicAuthHeaderValue) — NO inline base64/crypto. An optional WooCommerce
34
+ // consumer key/secret pair is supported for `wc/v3`, including the documented query-param fallback
35
+ // for hosts that strip `Authorization`. A Woo-ONLY credential cannot read `wp/v2` at all, so those
36
+ // objects fail with an explicit capability warning instead of an empty-but-green sync.
37
+ // - Base URL: PER-CONNECTION and DERIVED, never string-concatenated. WordPress is self-hosted and the
38
+ // REST prefix is filterable (`rest_get_url_prefix()`), so the API root comes from the site's own
39
+ // advertised REST URL (the `Link: <…>; rel="https://api.w.org/"` header) with `{site}/wp-json/` and
40
+ // the permalink-less `{site}/?rest_route=/` forms as fallbacks.
41
+ // - Pagination: `page` + `per_page` clamped to the documented cap, terminated on `X-WP-TotalPages`
42
+ // (or the `Link rel="next"` header, or a short page), with `X-WP-Total` surfaced as the expected
43
+ // count and a STABLE SORT (`orderby=id&order=asc`) to minimise offset drift.
44
+ // - `context=edit` with a GRACEFUL, LOUD degrade to `context=view` on 401/403.
45
+ // - Incremental: per-object, strictly FROM METADATA. Objects whose metadata declares no watermark
46
+ // (wp/v2/users, wc/v3/customers, and the six objects that register `modified_after` but expose no
47
+ // modified column) get NO delta path — the connector never synthesises one.
48
+ // - FULL-RECORD pass-through: `_fields` is deliberately never sent, so per-site `meta` and
49
+ // plugin-added properties reach the framework's custom-column capture.
50
+ // ─── Constants (protocol facts, not a catalog) ────────────────────────────────
51
+ /** Documented `per_page` ceiling for a WP REST collection; a request above it is REJECTED, not clamped. */
52
+ const WP_DEFAULT_MAX_PER_PAGE = 100;
53
+ /** The rel value WordPress uses to advertise its REST API root from the site's homepage. */
54
+ const WP_API_LINK_REL = 'https://api.w.org/';
55
+ /** Sentinel path segment marking "this site answers at `?rest_route=`, not at a REST prefix path". */
56
+ const REST_ROUTE_SENTINEL = '/__mj_rest_route__';
57
+ // ─── WordPressConnector ───────────────────────────────────────────────────────
58
+ /**
59
+ * WordPress connector — extends BaseRESTIntegrationConnector (REST/JSON over HTTP).
60
+ *
61
+ * Serves BOTH in-scope namespaces (`wp/v2` core and `wc/v3` WooCommerce) off ONE self-hosted site root.
62
+ * Pagination, template-var (per-parent) read traversal and the generic metadata-driven CRUD are
63
+ * inherited; this class supplies the WordPress-specific protocol surface plus the §7/§10 sync-efficiency
64
+ * hooks the frozen contract actually evidences.
65
+ */
66
+ let WordPressConnector = class WordPressConnector extends BaseRESTIntegrationConnector {
67
+ constructor() {
68
+ super(...arguments);
69
+ /** Resolved auth per CompanyIntegration.ID — Application Passwords and Woo keys never expire. */
70
+ this.authCache = new Map();
71
+ /** Route index per API root, fetched once per connector lifetime (656 routes on a stock install). */
72
+ this.routeIndexCache = new Map();
73
+ /** Response headers keyed by the PARSED BODY OBJECT — the race-free way to get `X-WP-TotalPages`
74
+ * into `ExtractPaginationInfo`, whose signature only receives the body. Weak so nothing is retained. */
75
+ this.headersByBody = new WeakMap();
76
+ /** Route paths that answered 401/403 to `context=edit` and have been degraded to `context=view`. */
77
+ this.contextDegraded = new Set();
78
+ /** Per-object query params for the ACTIVE fetch, consumed by AppendDefaultQueryParams. Keyed by object name. */
79
+ this.activeFetchParams = new Map();
80
+ /** Objects whose capability warnings have already been logged (keep the log honest, not noisy). */
81
+ this.warnedOnce = new Set();
82
+ /** Last non-2xx READ outcome per route path. The base's paginated loop SWALLOWS a 403 into an empty
83
+ * result; without this the connector could not tell "forbidden" from "genuinely no records". */
84
+ this.lastReadFailureByPath = new Map();
85
+ }
86
+ // ── Identity (T1 three-way invariant) ─────────────────────────────────────
87
+ /** Verbatim `MJ: Integrations.Name`. Load-bearing: T1 compares this === the metadata Name. */
88
+ get IntegrationName() {
89
+ return 'WordPress';
90
+ }
91
+ // ── Capability getters (kept in lockstep with the per-operation IO columns) ──
92
+ get SupportsCreate() { return true; }
93
+ get SupportsUpdate() { return true; }
94
+ get SupportsDelete() { return true; }
95
+ /**
96
+ * FALSE, and deliberately so — the two levels of authority differ and one boolean cannot express both.
97
+ * OBJECT level: the route index enumerates every route the site REGISTERED for that request, but a
98
+ * namespace can be absent because of a feature flag (`wc/v4` needs `rest-api-v4`) or WooCommerce's
99
+ * per-request lazy-load filter, so absence does not mean the vendor dropped it. FIELD level: schema
100
+ * visibility is capability-gated (`context=edit` + the matching capability), so an under-privileged
101
+ * credential legitimately receives a THINNER schema. Deactivating on either would wipe real metadata.
102
+ */
103
+ get DiscoveryIsAuthoritative() {
104
+ return false;
105
+ }
106
+ // ── Sync-efficiency hooks (§7/§10) ────────────────────────────────────────
107
+ /**
108
+ * NULL on purpose. Neither `wp/v2` nor `wc/v3` documents ANY rate limit — WordPress core imposes none
109
+ * and the WooCommerce v3 docs have no rate-limit section (`Configuration.RateLimitPolicy.vendorDocumented
110
+ * = false`, and the metadata records that no number may be emitted). Real limits are HOST/CDN-imposed and
111
+ * per-tenant. Publishing a `TokensPerSec` here would fabricate a vendor commitment that does not exist;
112
+ * the engine derives a conservative rate instead and `ExtractRetryAfterMs` + `MaxConcurrencyHint` below
113
+ * carry the obligations that ARE real.
114
+ */
115
+ get RateLimitPolicy() {
116
+ return null;
117
+ }
118
+ /**
119
+ * Deliberately LOW. The thing being loaded is the TENANT'S OWN WEBSITE — the same PHP workers that serve
120
+ * their visitors — and deep offset paging is O(offset) in their database. Two in flight is the
121
+ * conservative default the metadata's `connectorObligation` calls for.
122
+ */
123
+ get MaxConcurrencyHint() { return 2; }
124
+ /**
125
+ * Honours `429` + `Retry-After` (and `503`) ADAPTIVELY: parses both the delta-seconds and the HTTP-date
126
+ * forms of the header off the error the transport threw, so the engine's AIMD bucket backs off by the
127
+ * host's actual instruction rather than a guess.
128
+ */
129
+ ExtractRetryAfterMs(error) {
130
+ const headers = error instanceof WordPressHTTPError
131
+ ? error.Headers
132
+ : this.headersFromUnknownError(error);
133
+ if (!headers)
134
+ return undefined;
135
+ const raw = headers['retry-after'] ?? headers['Retry-After'];
136
+ if (raw == null)
137
+ return undefined;
138
+ const seconds = Number(raw);
139
+ if (Number.isFinite(seconds) && seconds >= 0)
140
+ return Math.ceil(seconds * 1000);
141
+ const when = Date.parse(String(raw));
142
+ if (Number.isFinite(when))
143
+ return Math.max(0, when - Date.now());
144
+ return undefined;
145
+ }
146
+ /**
147
+ * Keyset resume for the objects with NO usable server-side watermark — read from the IO metadata's
148
+ * `StableOrderingKey` (`id` for most collections, `slug`/`code`/`name`/`instance_id` where that is the
149
+ * declared key), never guessed. Null when the object declares no stable key.
150
+ *
151
+ * DELIBERATELY NULL for every object that DOES declare a usable server-side date filter. That is the
152
+ * ENGINE'S OWN CONTRACT, not a preference: `IntegrationEngine`'s §8a keyset block treats "has a
153
+ * StableOrderingKey" and "uses a timestamp watermark" as MUTUALLY EXCLUSIVE — `isKeysetConnector`
154
+ * forces `initialWatermark = null` on every run, and a clean scan then CLEARS the keyset marker instead
155
+ * of saving a timestamp (its own comment: a connector whose object has a usable server-side date
156
+ * incremental MUST NOT declare a StableOrderingKey for that object). Declaring a key for a
157
+ * watermark-capable object therefore (a) never hands `FetchContext.WatermarkValue` back to the
158
+ * connector, so the `modified_after` filter is built but never issued, and (b) never persists a
159
+ * watermark at all — a DEAD incremental that silently full-re-lists forever. Which of the two applies
160
+ * is a PER-OBJECT metadata fact, so it is READ from metadata here rather than declaring both and
161
+ * getting neither.
162
+ *
163
+ * The metadata `StableOrderingKey` COLUMN is untouched and still drives the `orderby=id&order=asc`
164
+ * stable sort in {@link buildObjectQueryParams}; only the engine-facing keyset signal is withheld.
165
+ */
166
+ StableOrderingKey(objectName) {
167
+ const obj = this.tryGetCachedObjectByName(objectName);
168
+ if (!obj)
169
+ return null;
170
+ if (this.hasLiveIncrementalWatermark(obj))
171
+ return null;
172
+ const declared = obj.StableOrderingKey;
173
+ if (declared && declared.trim().length > 0)
174
+ return declared.trim();
175
+ const pk = this.GetCachedFields(obj.ID).find(f => f.IsPrimaryKey);
176
+ return pk?.Name ?? null;
177
+ }
178
+ /**
179
+ * Whether this object has a LIVE server-side incremental filter — all three metadata facts present:
180
+ * the `SupportsIncrementalSync` flag, the `IncrementalWatermarkField` the max-seen is read from, and a
181
+ * `Configuration.incrementalWatermark.filterParam` to actually put on the wire. Anything less is a
182
+ * DECORATIVE watermark and the object is treated as full-scan-only.
183
+ *
184
+ * FALSE, correctly, for `wp/v2/users` and `wc/v3/customers` (their controllers inherit no date params
185
+ * at all) and for the six objects that register `modified_after` but expose no modified column
186
+ * (MenuItem, GlobalStyle, FontFamily, FontFamilyFontFace, OrderRefund, Refund) — every one of which
187
+ * carries a null `incrementalWatermark` plus an `incrementalNote` in the metadata.
188
+ */
189
+ hasLiveIncrementalWatermark(obj, cfg) {
190
+ if (!obj.SupportsIncrementalSync || !obj.IncrementalWatermarkField)
191
+ return false;
192
+ const resolved = cfg === undefined ? this.objectConfig(obj) : cfg;
193
+ const filterParam = resolved?.incrementalWatermark?.filterParam;
194
+ return typeof filterParam === 'string' && filterParam.length > 0;
195
+ }
196
+ // ── Discovery ─────────────────────────────────────────────────────────────
197
+ /**
198
+ * DYNAMIC discovery. The Declared metadata is the stock-install FLOOR — never the ceiling — so this
199
+ * reads the CONNECTION's OWN route index (`GET <apiRoot>`) and UNIONS the per-site remainder on top:
200
+ * custom post types, custom taxonomies and third-party plugin namespaces, all of which flow through the
201
+ * same core controllers with a different `rest_base` and are therefore invisible to any pinned source.
202
+ *
203
+ * A route becomes a candidate object when it is a GET collection route (no path capture in its own last
204
+ * segment) that registers `per_page` — i.e. a LISTABLE collection, the discriminator that separates a
205
+ * record set from the RPC routes (`/wp/v2/block-renderer/…`, `/oembed/1.0/proxy`).
206
+ *
207
+ * Namespaces the OPERATOR scoped out are skipped by READING the metadata's own structured
208
+ * `Configuration.OutOfScopeObjectFamilies[].kind` — first-party RPC/admin/legacy/transport surfaces are
209
+ * not record collections. Third-party plugin namespaces are NOT skipped: the metadata's own reason text
210
+ * says they are "reachable at runtime via route-index discovery", which is exactly this path.
211
+ *
212
+ * A discovery failure NEVER removes the declared floor — the union degrades to the floor with a warning.
213
+ */
214
+ async DiscoverObjects(companyIntegration, contextUser) {
215
+ const declaredObjects = this.getCachedObjects(companyIntegration.IntegrationID);
216
+ const out = declaredObjects.map(obj => ({
217
+ ID: obj.ID,
218
+ Name: obj.Name,
219
+ Label: obj.DisplayName ?? obj.Name,
220
+ Description: obj.Description ?? undefined,
221
+ SupportsIncrementalSync: obj.SupportsIncrementalSync,
222
+ SupportsWrite: obj.SupportsWrite,
223
+ }));
224
+ let index = null;
225
+ try {
226
+ const auth = await this.Authenticate(companyIntegration, contextUser);
227
+ index = await this.loadRouteIndex(auth);
228
+ }
229
+ catch (err) {
230
+ this.warnOnce('route-index', `[WordPress] Route-index discovery unavailable (${this.errText(err)}). Falling back to the DECLARED ` +
231
+ `stock-install floor only — this site's custom post types, custom taxonomies and plugin namespaces ` +
232
+ `will NOT be surfaced until discovery succeeds. Nothing was deactivated.`);
233
+ return out;
234
+ }
235
+ const excludedNamespaces = this.readScopedOutNamespaces(companyIntegration);
236
+ const declaredPaths = new Set(declaredObjects.map(o => this.canonicalRoutePath(this.declaredListPath(o))));
237
+ const takenNames = new Set(out.map(o => o.Name.toLowerCase()));
238
+ for (const candidate of this.deriveCollectionRoutes(index)) {
239
+ if (excludedNamespaces.has(candidate.Namespace))
240
+ continue;
241
+ if (declaredPaths.has(this.canonicalRoutePath(candidate.Path)))
242
+ continue;
243
+ const name = candidate.Path;
244
+ if (takenNames.has(name.toLowerCase()))
245
+ continue;
246
+ takenNames.add(name.toLowerCase());
247
+ out.push({
248
+ // The NAME is the route path on purpose: a runtime-discovered IntegrationObject has its
249
+ // APIPath defaulted from the ExternalName by the persist layer, so naming it by its path is
250
+ // what makes a newly-found custom post type / plugin collection immediately FETCHABLE
251
+ // instead of a visible-but-dead object.
252
+ Name: name,
253
+ Label: this.humanLabelForRoute(candidate.Path, candidate.Namespace),
254
+ Description: `Discovered from this site's route index in namespace "${candidate.Namespace}" — not part of the ` +
255
+ `declared stock-install floor (per-site custom post type, custom taxonomy or plugin collection).`,
256
+ SupportsIncrementalSync: false, // provable-only: no watermark evidence for an unknown route
257
+ SupportsWrite: candidate.SupportsWrite,
258
+ });
259
+ }
260
+ return out;
261
+ }
262
+ /**
263
+ * Field discovery via `OPTIONS <route>` — WordPress's self-describing endpoint schema. The result is
264
+ * UNIONED over the Declared field set and NEVER shrinks it: field visibility is capability-gated, so an
265
+ * under-privileged credential legitimately sees a thinner schema and its ABSENCES prove nothing.
266
+ *
267
+ * A templated collection path (`/wc/v3/orders/{order_id}/notes`) has no literal URL to OPTIONS without a
268
+ * real parent id, so those objects return their declared fields unchanged rather than a fabricated set.
269
+ */
270
+ async DiscoverFields(companyIntegration, objectName, contextUser) {
271
+ let declared = [];
272
+ try {
273
+ declared = await super.DiscoverFields(companyIntegration, objectName, contextUser);
274
+ }
275
+ catch {
276
+ declared = []; // a route-index-discovered object has no cached IOFs yet — OPTIONS is all we have
277
+ }
278
+ const routePath = this.optionsRoutePathFor(companyIntegration.IntegrationID, objectName);
279
+ if (!routePath)
280
+ return declared;
281
+ try {
282
+ const auth = await this.Authenticate(companyIntegration, contextUser);
283
+ const described = await this.describeRoute(auth, routePath);
284
+ return this.unionFieldSchemas(declared, described);
285
+ }
286
+ catch (err) {
287
+ this.warnOnce(`options:${objectName}`, `[WordPress] OPTIONS ${routePath} failed for "${objectName}" (${this.errText(err)}) — keeping the ` +
288
+ `declared field set unchanged. NO field was deactivated (field absence is capability-gated and never authoritative).`);
289
+ return declared;
290
+ }
291
+ }
292
+ /**
293
+ * Union introspection — the layer where the per-site truth actually lands in the schema:
294
+ * 1. `super.IntrospectSchema` returns the persisted DECLARED objects (the stock-install floor).
295
+ * 2. Objects the ROUTE INDEX exposes but the floor never declared are APPENDED (per-site customs).
296
+ * 3. Every object's field set is UNIONED with the live `OPTIONS` schema and with a live record SAMPLE
297
+ * (`DiscoverFieldsViaFetch` → `mergeDeclaredWithSampledFields`, never-shrink / declared-wins /
298
+ * capacities widened), so a tenant's registered `meta` keys and plugin-added properties reach the
299
+ * schema instead of being silently dropped at field-mapping time.
300
+ * Every step is best-effort and additive — a failure leaves the declared set exactly as it was.
301
+ */
302
+ async IntrospectSchema(companyIntegration, contextUser) {
303
+ const info = await super.IntrospectSchema(companyIntegration, contextUser);
304
+ const known = new Set(info.Objects.map(o => o.ExternalName.toLowerCase()));
305
+ // (2) per-site remainder from the route index
306
+ try {
307
+ const discovered = await this.DiscoverObjects(companyIntegration, contextUser);
308
+ for (const obj of discovered) {
309
+ if (known.has(obj.Name.toLowerCase()))
310
+ continue;
311
+ known.add(obj.Name.toLowerCase());
312
+ const fields = await this.DiscoverFields(companyIntegration, obj.Name, contextUser);
313
+ info.Objects.push(this.toSourceObjectInfo(obj, fields));
314
+ }
315
+ }
316
+ catch (err) {
317
+ this.warnOnce('introspect-union', `[WordPress] Per-site object union skipped: ${this.errText(err)}`);
318
+ }
319
+ // (3) OPTIONS + sample-union field enrichment
320
+ await Promise.all(info.Objects.map(async (obj) => {
321
+ try {
322
+ const described = await this.DiscoverFields(companyIntegration, obj.ExternalName, contextUser);
323
+ obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, described);
324
+ }
325
+ catch { /* best-effort — a describe failure leaves the declared fields as-is */ }
326
+ try {
327
+ const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
328
+ obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
329
+ }
330
+ catch { /* best-effort — a sample failure leaves the declared fields as-is */ }
331
+ }));
332
+ return info;
333
+ }
334
+ // ── Fetch ─────────────────────────────────────────────────────────────────
335
+ /**
336
+ * OVERRIDDEN for four things the generic path cannot express, then delegated:
337
+ * 1. DUAL-NAMESPACE CREDENTIAL GUARD — a Woo-only consumer key/secret cannot read `wp/v2` at ALL
338
+ * (`WC_REST_Authentication::is_request_to_rest_api()` matches only `wc/`/`wc-` URIs), so those
339
+ * objects surface an explicit capability warning instead of an empty-but-green sync.
340
+ * 2. PER-OBJECT QUERY PARAMS — the stable sort and the metadata-declared incremental filter, staged
341
+ * for `AppendDefaultQueryParams` (which the base calls for every page of every request).
342
+ * 3. WATERMARK — max-seen over the object's declared `IncrementalWatermarkField`, persisted ONLY on a
343
+ * fully-drained pass so a partial batch never advances it.
344
+ * 4. GRACEFUL DEGRADES — an unregistered route (a gated Woo feature such as order fulfillments) and a
345
+ * capability-forbidden collection become a WARNED zero-record result, not a failed sync.
346
+ *
347
+ * `ctx.RequestedSourceFields` is deliberately IGNORED: WordPress's `_fields` param would truncate the
348
+ * record to whatever the connector thought to ask for, which is exactly what breaks the framework's
349
+ * custom/overflow capture of per-site `meta` and plugin-added properties.
350
+ */
351
+ async FetchChanges(ctx) {
352
+ const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
353
+ const cfg = this.objectConfig(obj);
354
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
355
+ const guard = this.namespaceCredentialGuard(obj, cfg, auth);
356
+ if (guard)
357
+ return { Records: [], HasMore: false, Warnings: [guard] };
358
+ if (this.isGlobalStylesObject(obj, cfg))
359
+ return this.fetchGlobalStyles(obj, cfg, ctx, auth);
360
+ const warnings = [];
361
+ this.clearReadFailures(obj, cfg);
362
+ this.activeFetchParams.set(obj.Name, await this.buildObjectQueryParams(obj, cfg, ctx, auth));
363
+ let result;
364
+ try {
365
+ result = await super.FetchChanges(ctx);
366
+ }
367
+ catch (err) {
368
+ const graceful = this.gracefulFetchFailure(obj, this.statusFromError(err), this.bodyFromError(err))
369
+ ?? this.parentResolutionUnavailable(obj, err);
370
+ if (!graceful)
371
+ throw err;
372
+ return graceful;
373
+ }
374
+ finally {
375
+ this.activeFetchParams.delete(obj.Name);
376
+ }
377
+ // The base swallows a 403 into an empty batch — surface the recorded refusal instead of a
378
+ // legitimately-empty object, so a capability gap can never read as an empty-but-green sync.
379
+ const swallowed = result.Records.length === 0 ? this.recordedReadFailure(obj, cfg) : null;
380
+ if (swallowed) {
381
+ const graceful = this.gracefulFetchFailure(obj, swallowed.Status, swallowed.Body);
382
+ if (graceful)
383
+ return graceful;
384
+ }
385
+ if (result.Warnings)
386
+ warnings.push(...result.Warnings);
387
+ // Soft-delete capture: WordPress post types DELETE to `status=trash` and trashed rows stay listable.
388
+ // Only an authenticated caller can ever see them, so the sweep is gated on a real credential.
389
+ const trash = await this.fetchTrashedRecords(obj, cfg, ctx, auth, result);
390
+ if (trash.Records.length > 0)
391
+ result.Records = [...result.Records, ...trash.Records];
392
+ if (trash.Warning)
393
+ warnings.push(trash.Warning);
394
+ this.markTrashedAsDeleted(result.Records);
395
+ const watermark = this.maxWatermark(obj, result.Records);
396
+ return {
397
+ ...result,
398
+ Warnings: warnings.length > 0 ? warnings : undefined,
399
+ // Advance ONLY on a fully-drained pass — a partial batch (or a mid-iteration failure, which
400
+ // never reaches here) leaves the stored watermark untouched so the next run resumes cleanly.
401
+ NewWatermarkValue: !result.HasMore && watermark ? watermark : undefined,
402
+ };
403
+ }
404
+ // ── Abstract REST hooks ───────────────────────────────────────────────────
405
+ /**
406
+ * Resolves the per-connection credential AND the DERIVED REST API root. Cached per CompanyIntegration —
407
+ * Application Passwords and Woo consumer keys are long-lived with no refresh endpoint, so there is
408
+ * nothing to renew. A credential-free connection is LEGAL and does not throw: WordPress's route index
409
+ * and `OPTIONS` are public, which is what lets discovery work without a secret.
410
+ */
411
+ async Authenticate(companyIntegration, contextUser) {
412
+ const cached = this.authCache.get(companyIntegration.ID);
413
+ if (cached)
414
+ return cached;
415
+ const creds = await this.loadCredentials(companyIntegration, contextUser);
416
+ const siteUrl = (creds.SiteUrl ?? '').trim().replace(/\/+$/, '');
417
+ if (!siteUrl && !creds.ApiRoot) {
418
+ throw new Error('No WordPress site URL configured. WordPress is SELF-HOSTED — there is no vendor host — so the ' +
419
+ 'connection must supply "siteUrl" (the tenant\'s own site root) on its credential or Configuration JSON.');
420
+ }
421
+ const root = await this.deriveApiRoot(siteUrl, creds.ApiRoot ?? null);
422
+ const hasAppPassword = !!(creds.Username && creds.ApplicationPassword);
423
+ const hasWoo = !!(creds.WooConsumerKey && creds.WooConsumerSecret);
424
+ let header = null;
425
+ if (hasAppPassword) {
426
+ header = buildBasicAuthHeaderValue({ Username: creds.Username, Password: creds.ApplicationPassword });
427
+ }
428
+ else if (hasWoo) {
429
+ // Woo accepts its key/secret as HTTP Basic over HTTPS. It reaches `wc/*` ONLY — the wp/v2 guard
430
+ // in FetchChanges makes that asymmetry explicit rather than letting core objects come back empty.
431
+ header = buildBasicAuthHeaderValue({ Username: creds.WooConsumerKey, Password: creds.WooConsumerSecret });
432
+ this.warnOnce('woo-only-credential', '[WordPress] This connection supplies a WooCommerce consumer key/secret but NO Application Password. ' +
433
+ 'A Woo key pair cannot authenticate wp/v2 at all, so every WordPress-core object will be reported as a ' +
434
+ 'capability gap rather than synced. Add an Application Password to cover wp/v2.');
435
+ }
436
+ else {
437
+ this.warnOnce('no-credential', '[WordPress] No credential supplied. Public routes (route index, OPTIONS, published content) still ' +
438
+ 'answer, but every capability-gated collection and field will be unavailable to this connection.');
439
+ }
440
+ const ctx = {
441
+ ApiRoot: root.Root,
442
+ UsesRestRouteQuery: root.UsesRestRouteQuery,
443
+ AuthorizationHeader: header,
444
+ HasApplicationPassword: hasAppPassword,
445
+ HasWooCredential: hasWoo,
446
+ WooQueryAuth: hasWoo && this.wooQueryParamAuthEnabled(companyIntegration)
447
+ ? { Key: creds.WooConsumerKey, Secret: creds.WooConsumerSecret }
448
+ : null,
449
+ IntegrationID: companyIntegration.IntegrationID,
450
+ };
451
+ this.authCache.set(companyIntegration.ID, ctx);
452
+ return ctx;
453
+ }
454
+ /** Static request headers plus the resolved Basic credential (absent on a credential-free connection). */
455
+ BuildHeaders(auth) {
456
+ const ctx = auth;
457
+ const headers = {
458
+ 'Content-Type': 'application/json',
459
+ 'Accept': 'application/json',
460
+ };
461
+ if (ctx.AuthorizationHeader)
462
+ headers['Authorization'] = ctx.AuthorizationHeader;
463
+ return headers;
464
+ }
465
+ /**
466
+ * The single wire choke point. Beyond the raw transport it owns four WordPress-specific concerns:
467
+ * 1. `?rest_route=` rewriting for a site without pretty permalinks.
468
+ * 2. The Woo consumer key/secret QUERY-PARAM fallback on `wc/*` URLs, for hosts that strip
469
+ * `Authorization` (Woo gives query params precedence over the header).
470
+ * 3. `context=edit` injection on reads, with a ONE-SHOT graceful degrade to `context=view` on 401/403
471
+ * that LOGS the fields that will now be missing rather than downgrading silently.
472
+ * 4. Throwing `429`/`503` as a typed error carrying the response headers, so the engine's adaptive
473
+ * limiter can honour `Retry-After` instead of seeing an opaque message.
474
+ */
475
+ async MakeHTTPRequest(auth, url, method, headers, body) {
476
+ const ctx = auth;
477
+ let requestURL = this.rewriteForRestRoute(ctx, url);
478
+ requestURL = this.appendWooQueryAuth(ctx, requestURL);
479
+ const routeKey = this.pathOf(requestURL);
480
+ const isRead = method.toUpperCase() === 'GET';
481
+ const contextAlreadySet = this.hasQueryParam(requestURL, 'context');
482
+ const injectContext = isRead && !contextAlreadySet && !this.contextDegraded.has(routeKey);
483
+ if (injectContext) {
484
+ requestURL = this.withQueryParam(requestURL, 'context', 'edit');
485
+ }
486
+ else if (isRead && !contextAlreadySet) {
487
+ // Already degraded on this route — say `view` on the wire rather than relying on a default,
488
+ // so the request is self-describing in a site's access log and in a captured trace.
489
+ requestURL = this.withQueryParam(requestURL, 'context', 'view');
490
+ }
491
+ let response = await this.rawRequest(requestURL, method, headers, body);
492
+ if (injectContext && (response.Status === 401 || response.Status === 403)) {
493
+ this.contextDegraded.add(routeKey);
494
+ this.warnOnce(`context-degrade:${routeKey}`, `[WordPress] CAPABILITY DEGRADE on ${routeKey}: this credential may not read context=edit ` +
495
+ `(HTTP ${response.Status} ${this.vendorCodeOf(response.Body) ?? 'no vendor code'}). Falling back to ` +
496
+ `context=view. The following declared fields are context=edit-gated and will be MISSING from every ` +
497
+ `record on this route: [${this.contextGatedFieldsForPath(ctx, routeKey).join(', ') || 'none declared'}]. ` +
498
+ `This is a thinner schema than the vendor documents — grant the credential the matching capability to close it.`);
499
+ response = await this.rawRequest(this.withQueryParam(this.stripQueryParam(requestURL, 'context'), 'context', 'view'), method, headers, body);
500
+ }
501
+ if (response.Status === 429 || response.Status === 503) {
502
+ const classified = this.ClassifyWordPressResponse(response.Status, response.Body);
503
+ throw new WordPressHTTPError(`[WordPress] HTTP ${response.Status} (${classified.Reason}) from ${routeKey}`, response.Status, response.Headers, classified.VendorCode);
504
+ }
505
+ // Remember a forbidden/absent READ. The base's paginated loop turns a 403 into a SILENT empty
506
+ // result; recording it here is what lets FetchChanges report a capability gap rather than a
507
+ // legitimately-empty object.
508
+ if (isRead && (response.Status === 401 || response.Status === 403 || response.Status === 404)) {
509
+ this.lastReadFailureByPath.set(routeKey, { Status: response.Status, Body: response.Body });
510
+ }
511
+ return response;
512
+ }
513
+ /**
514
+ * Strips the WordPress collection envelope. Three declared response shapes, discriminated STRUCTURALLY
515
+ * from the body itself (the signature receives no object row, and the shape is unambiguous in the bytes):
516
+ * - `array` → a bare JSON array of records (73 of the declared objects).
517
+ * - `object-map` → `{ "<slug>": {…}, … }` (types, statuses, taxonomies, menu-locations) → its values.
518
+ * - `single-object` → one document (settings, system status) → a one-element list.
519
+ * A `responseDataKey` still wins when the metadata declares one.
520
+ */
521
+ NormalizeResponse(rawBody, responseDataKey) {
522
+ if (rawBody == null)
523
+ return [];
524
+ if (Array.isArray(rawBody))
525
+ return rawBody;
526
+ if (typeof rawBody !== 'object')
527
+ return [];
528
+ const body = rawBody;
529
+ if (responseDataKey) {
530
+ const keyed = body[responseDataKey];
531
+ if (Array.isArray(keyed))
532
+ return keyed;
533
+ if (keyed && typeof keyed === 'object')
534
+ return [keyed];
535
+ }
536
+ // A WP_Error envelope is never a record. Non-2xx already threw; this guards a 2xx-wrapped error.
537
+ if (typeof body.code === 'string' && typeof body.message === 'string' && body.data != null)
538
+ return [];
539
+ const values = Object.values(body);
540
+ const isObjectMap = values.length > 0 &&
541
+ values.every(v => v != null && typeof v === 'object' && !Array.isArray(v));
542
+ if (isObjectMap)
543
+ return values;
544
+ return [body];
545
+ }
546
+ /**
547
+ * WordPress pagination is OFFSET-based page numbering whose termination signal lives in the RESPONSE
548
+ * HEADERS, not the body — so the headers recorded against this exact body object are consulted first:
549
+ * `X-WP-TotalPages` (authoritative), else the RFC-5988 `Link rel="next"`, else a short page.
550
+ * `X-WP-Total` is surfaced as the expected count. Completeness on a deep scan is BEST-EFFORT: page
551
+ * numbering is offset arithmetic, so concurrent writes shift rows across page boundaries; the
552
+ * `orderby=id&order=asc` stable sort reduces but does not eliminate that drift, and exactness is never claimed.
553
+ */
554
+ ExtractPaginationInfo(rawBody, paginationType, currentPage, _currentOffset, pageSize) {
555
+ if (paginationType !== 'PageNumber')
556
+ return { HasMore: false };
557
+ const headers = rawBody != null && typeof rawBody === 'object'
558
+ ? this.headersByBody.get(rawBody)
559
+ : undefined;
560
+ const total = this.toPositiveInt(headers?.['x-wp-total']);
561
+ const totalPages = this.toPositiveInt(headers?.['x-wp-totalpages']);
562
+ if (totalPages != null) {
563
+ return { HasMore: currentPage < totalPages, NextPage: currentPage + 1, TotalRecords: total ?? undefined };
564
+ }
565
+ const link = headers?.['link'];
566
+ if (link != null) {
567
+ const hasNext = link.includes('rel="next"');
568
+ return { HasMore: hasNext, NextPage: currentPage + 1, TotalRecords: total ?? undefined };
569
+ }
570
+ const received = Array.isArray(rawBody) ? rawBody.length : 0;
571
+ return {
572
+ HasMore: received > 0 && pageSize > 0 && received >= pageSize,
573
+ NextPage: currentPage + 1,
574
+ TotalRecords: total ?? undefined,
575
+ };
576
+ }
577
+ /** The DERIVED per-connection REST API root (resolved in Authenticate — never `siteUrl + '/wp-json'`). */
578
+ GetBaseURL(_companyIntegration, auth) {
579
+ const ctx = auth;
580
+ return ctx.UsesRestRouteQuery
581
+ ? `${ctx.ApiRoot.replace(/\/+$/, '')}${REST_ROUTE_SENTINEL}`
582
+ : ctx.ApiRoot.replace(/\/+$/, '');
583
+ }
584
+ /**
585
+ * `page` + `per_page`, with `per_page` CLAMPED to the cap the object's own metadata declares
586
+ * (`Configuration.pagination.maxPageSize`, 100 on every in-scope collection). WordPress REJECTS a
587
+ * request above the cap rather than clamping it server-side, so the clamp has to happen here.
588
+ */
589
+ BuildPaginatedURL(basePath, obj, page, offset, cursor, effectivePageSize) {
590
+ if (obj.PaginationType !== 'PageNumber') {
591
+ return super.BuildPaginatedURL(basePath, obj, page, offset, cursor, effectivePageSize);
592
+ }
593
+ const cfg = this.objectConfig(obj);
594
+ const cap = this.toPositiveInt(cfg?.pagination?.maxPageSize) ?? WP_DEFAULT_MAX_PER_PAGE;
595
+ const wanted = effectivePageSize ?? obj.DefaultPageSize ?? cap;
596
+ const size = Math.max(1, Math.min(wanted, cap));
597
+ const sep = basePath.includes('?') ? '&' : '?';
598
+ return `${basePath}${sep}page=${page}&per_page=${size}`;
599
+ }
600
+ /**
601
+ * Appends the DECLARED default query params (base behaviour) and then the per-object params this fetch
602
+ * staged: the stable sort and the metadata-declared incremental filter. Params already present in the
603
+ * URL are never duplicated.
604
+ */
605
+ AppendDefaultQueryParams(url, obj) {
606
+ let out = super.AppendDefaultQueryParams(url, obj);
607
+ const params = this.activeFetchParams.get(obj.Name);
608
+ if (!params)
609
+ return out;
610
+ for (const [key, value] of Object.entries(params)) {
611
+ if (this.hasQueryParam(out, key))
612
+ continue;
613
+ out = this.withQueryParam(out, key, value);
614
+ }
615
+ return out;
616
+ }
617
+ /** Reads the vendor's `message` out of the `{ code, message, data:{ status } }` envelope. */
618
+ ExtractErrorMessage(response) {
619
+ const envelope = this.errorEnvelope(response.Body);
620
+ if (envelope?.message) {
621
+ return envelope.code ? `${envelope.code}: ${envelope.message}` : envelope.message;
622
+ }
623
+ return super.ExtractErrorMessage(response);
624
+ }
625
+ // ── CRUD ──────────────────────────────────────────────────────────────────
626
+ //
627
+ // Create / Update / Get use the INHERITED generic per-operation path: every write-capable IO carries
628
+ // CreateAPIPath+CreateMethod / UpdateAPIPath+UpdateMethod in metadata, and WordPress takes ordinary flat
629
+ // JSON bodies — there is nothing idiosyncratic for those verbs to override.
630
+ /**
631
+ * OVERRIDDEN for the one genuinely idiosyncratic verb: WordPress DELETE semantics are PER-OBJECT and
632
+ * parameterised, which the generic path (a bare `DELETE <path>`) cannot express.
633
+ * - `requiresForce` (revisions, terms, users, widgets, Woo terms/notes/webhooks…) → `?force=true`;
634
+ * without it those routes reject the request outright.
635
+ * - `requiresReassign` (wp/v2/users) → `&reassign=<user id>`; WordPress requires it because deleting a
636
+ * user must say where their content goes. The id comes from the connection's
637
+ * `Configuration.userDeleteReassignID` and is NEVER guessed — an unset value is a loud error, because
638
+ * inventing one would silently reassign a customer's content to an arbitrary account.
639
+ * - Everything else keeps the vendor default, which for post types is a SOFT delete to `status=trash`.
640
+ * All of this is READ FROM METADATA (`Configuration.deleteSemantics`), not decided here.
641
+ */
642
+ async DeleteRecord(ctx) {
643
+ const ci = ctx.CompanyIntegration;
644
+ const contextUser = ctx.ContextUser;
645
+ const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
646
+ if (!obj.DeleteAPIPath || !obj.DeleteMethod)
647
+ return super.DeleteRecord(ctx);
648
+ const cfg = this.objectConfig(obj);
649
+ const semantics = cfg?.deleteSemantics;
650
+ if (!semantics?.requiresForce && !semantics?.requiresReassign)
651
+ return super.DeleteRecord(ctx);
652
+ const auth = await this.Authenticate(ci, contextUser);
653
+ const baseURL = this.GetBaseURL(ci, auth);
654
+ const headers = this.BuildHeaders(auth);
655
+ let path = this.SubstituteIDInPath(obj.DeleteAPIPath, ctx.ExternalID, obj.DeleteIDLocation);
656
+ if (semantics.requiresForce)
657
+ path = this.withQueryParam(path, 'force', 'true');
658
+ if (semantics.requiresReassign) {
659
+ const reassign = this.readConnectionConfigString(ci, ['userDeleteReassignID', 'UserDeleteReassignID']);
660
+ if (!reassign) {
661
+ return {
662
+ Success: false,
663
+ StatusCode: 400,
664
+ ErrorMessage: `DeleteRecord("${ctx.ObjectName}") requires a "reassign" target: WordPress will not delete a ` +
665
+ `user without saying which user inherits their content. Set Configuration.userDeleteReassignID ` +
666
+ `on this connection. The connector will not choose one.`,
667
+ };
668
+ }
669
+ path = this.withQueryParam(path, 'reassign', reassign);
670
+ }
671
+ const url = `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
672
+ const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod, headers);
673
+ if (response.Status >= 200 && response.Status < 300) {
674
+ return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
675
+ }
676
+ return {
677
+ Success: false,
678
+ StatusCode: response.Status,
679
+ ErrorMessage: this.ExtractErrorMessage(response) ?? `HTTP ${response.Status} on delete`,
680
+ };
681
+ }
682
+ // ── Connection test ───────────────────────────────────────────────────────
683
+ /**
684
+ * Two-part test, because "reachable" and "authenticated" are different facts:
685
+ * 1. The site's REST root must answer with a route index (proves it IS a WordPress REST API, and that
686
+ * the derived API root — Link header / `/wp-json/` / `?rest_route=/` — was resolved correctly).
687
+ * 2. The supplied credential must actually authenticate: `wp/v2/users/me` for an Application Password,
688
+ * or a `wc/v3` read for a Woo-only key pair.
689
+ * A credential-free connection reports failure with an explicit message — discovery works without a
690
+ * secret, but a connection is not "successful" when nothing can be authorised.
691
+ */
692
+ async TestConnection(companyIntegration, contextUser) {
693
+ try {
694
+ const auth = await this.Authenticate(companyIntegration, contextUser);
695
+ const index = await this.loadRouteIndex(auth);
696
+ const namespaces = index.namespaces ?? [];
697
+ const site = index.name ?? 'the site';
698
+ if (!auth.HasApplicationPassword && !auth.HasWooCredential) {
699
+ return {
700
+ Success: false,
701
+ Message: `Reached the WordPress REST API for "${site}" at ${auth.ApiRoot} (${namespaces.length} namespace(s) ` +
702
+ `registered), but NO credential is configured. Supply a WordPress username + Application Password ` +
703
+ `(covers wp/v2 and wc/v3), or a WooCommerce consumer key/secret (wc/v3 only).`,
704
+ };
705
+ }
706
+ const headers = this.BuildHeaders(auth);
707
+ const probePath = auth.HasApplicationPassword ? '/wp/v2/users/me' : '/wc/v3/data';
708
+ const probe = await this.MakeHTTPRequest(auth, `${this.GetBaseURL(companyIntegration, auth)}${probePath}`, 'GET', headers);
709
+ if (probe.Status >= 200 && probe.Status < 300) {
710
+ const scope = auth.HasApplicationPassword
711
+ ? 'wp/v2 + wc/v3 (Application Password authenticates every namespace)'
712
+ : 'wc/v3 ONLY (a WooCommerce key pair cannot read wp/v2)';
713
+ return {
714
+ Success: true,
715
+ Message: `WordPress connection to "${site}" successful at ${auth.ApiRoot}. Authorised scope: ${scope}. ` +
716
+ `Namespaces registered on this site: ${namespaces.join(', ') || 'none reported'}.`,
717
+ };
718
+ }
719
+ const classified = this.ClassifyWordPressResponse(probe.Status, probe.Body);
720
+ return {
721
+ Success: false,
722
+ Message: `Reached ${auth.ApiRoot} but the credential was rejected on ${probePath}: HTTP ${probe.Status} ` +
723
+ `(${classified.VendorCode ?? classified.Reason}). ` +
724
+ (auth.HasApplicationPassword
725
+ ? 'Check the username + Application Password, and note that Application Passwords are unavailable ' +
726
+ 'over plain HTTP outside a local environment.'
727
+ : 'Check the WooCommerce consumer key/secret and its read/write permission scope.'),
728
+ };
729
+ }
730
+ catch (err) {
731
+ return { Success: false, Message: `WordPress connection test error: ${this.errText(err)}` };
732
+ }
733
+ }
734
+ // ── Error classification ──────────────────────────────────────────────────
735
+ /**
736
+ * Classifies from the ERROR ENVELOPE, not the status alone. WordPress serialises every failure across
737
+ * wp/v2, wc/v3 and /batch/v1 as `{ code, message, data:{ status } }`, and `code` is the stable
738
+ * machine-readable discriminator (`message` is localised and must never be parsed).
739
+ *
740
+ * The distinction that matters operationally: a `403` carrying a WordPress JSON envelope is the API
741
+ * refusing a capability (fix the credential), while a `403` carrying an HTML body never reached the API
742
+ * at all — it is a WAF / host / mod_security block, and retrying it is pointless. Anything unrecognised
743
+ * falls through to the engine's own `ClassifyError`.
744
+ */
745
+ ClassifyWordPressResponse(status, body) {
746
+ const envelope = this.errorEnvelope(body);
747
+ const vendorCode = envelope?.code ?? null;
748
+ const isHtml = typeof body === 'string' && /<\s*(html|!doctype|head|body)/i.test(body);
749
+ if (status === 429) {
750
+ return { Code: 'RATE_LIMIT_EXCEEDED', Severity: 'Warning', VendorCode: vendorCode, Retryable: true, Reason: 'throttled' };
751
+ }
752
+ if (status === 503) {
753
+ return { Code: 'NETWORK_TIMEOUT', Severity: 'Warning', VendorCode: vendorCode, Retryable: true, Reason: 'service-unavailable' };
754
+ }
755
+ if ((status === 403 || status === 406 || status === 401) && isHtml) {
756
+ return {
757
+ Code: 'CONNECTOR_ERROR', Severity: 'Critical', VendorCode: null, Retryable: false,
758
+ Reason: 'waf-or-host-block-html-body',
759
+ };
760
+ }
761
+ if (status === 401 || status === 403) {
762
+ return {
763
+ Code: 'CONFIGURATION_ERROR', Severity: 'Critical', VendorCode: vendorCode, Retryable: false,
764
+ Reason: 'capability-or-credential',
765
+ };
766
+ }
767
+ if (status === 404) {
768
+ return {
769
+ Code: 'CONFIGURATION_ERROR', Severity: 'Warning', VendorCode: vendorCode, Retryable: false,
770
+ Reason: vendorCode === 'rest_no_route' ? 'route-not-registered' : 'not-found',
771
+ };
772
+ }
773
+ if (status === 400) {
774
+ return { Code: 'VALIDATION_ERROR', Severity: 'Warning', VendorCode: vendorCode, Retryable: false, Reason: 'invalid-param' };
775
+ }
776
+ if (status === 413) {
777
+ return { Code: 'VALIDATION_ERROR', Severity: 'Warning', VendorCode: vendorCode, Retryable: false, Reason: 'payload-too-large' };
778
+ }
779
+ if (status >= 500) {
780
+ return { Code: 'CONNECTOR_ERROR', Severity: 'Critical', VendorCode: vendorCode, Retryable: false, Reason: 'server-error' };
781
+ }
782
+ const fallback = ClassifyError(new Error(envelope?.message ?? `HTTP ${status}`));
783
+ return { Code: fallback.Code, Severity: fallback.Severity, VendorCode: vendorCode, Retryable: false, Reason: 'unclassified' };
784
+ }
785
+ // ── Transport internals ───────────────────────────────────────────────────
786
+ /** The raw HTTP call. Isolated so test subclasses can capture the wire without losing the WP behaviours above. */
787
+ async rawRequest(url, method, headers, body) {
788
+ const response = await fetch(url, {
789
+ method,
790
+ headers,
791
+ body: body !== undefined ? JSON.stringify(body) : undefined,
792
+ });
793
+ const respHeaders = {};
794
+ response.headers.forEach((v, k) => { respHeaders[k.toLowerCase()] = v; });
795
+ const text = await response.text();
796
+ let parsed = null;
797
+ if (text.length > 0) {
798
+ try {
799
+ parsed = JSON.parse(text);
800
+ }
801
+ catch {
802
+ parsed = text;
803
+ }
804
+ }
805
+ if (parsed != null && typeof parsed === 'object')
806
+ this.headersByBody.set(parsed, respHeaders);
807
+ return { Status: response.status, Body: parsed, Headers: respHeaders };
808
+ }
809
+ /** Rewrites `<site>/__mj_rest_route__/wp/v2/posts?x=1` → `<site>/?rest_route=/wp/v2/posts&x=1`. */
810
+ rewriteForRestRoute(auth, url) {
811
+ if (!auth.UsesRestRouteQuery || !url.includes(REST_ROUTE_SENTINEL))
812
+ return url;
813
+ const [beforeQuery, query] = this.splitQuery(url);
814
+ const idx = beforeQuery.indexOf(REST_ROUTE_SENTINEL);
815
+ const origin = beforeQuery.slice(0, idx);
816
+ const route = beforeQuery.slice(idx + REST_ROUTE_SENTINEL.length) || '/';
817
+ const rest = `rest_route=${encodeURIComponent(route.startsWith('/') ? route : `/${route}`)}`;
818
+ return `${origin}/?${rest}${query ? `&${query}` : ''}`;
819
+ }
820
+ /**
821
+ * WooCommerce accepts `?consumer_key=&consumer_secret=` and gives them PRECEDENCE over the
822
+ * `Authorization` header — the one hard functional advantage of the Woo key pair, for hosts that strip
823
+ * `Authorization`. Applied ONLY to `wc/` routes and ONLY when the connection opts in.
824
+ */
825
+ appendWooQueryAuth(auth, url) {
826
+ if (!auth.WooQueryAuth)
827
+ return url;
828
+ if (!/\/wc[/-]/.test(url) && !/rest_route=%2Fwc/i.test(url))
829
+ return url;
830
+ if (this.hasQueryParam(url, 'consumer_key'))
831
+ return url;
832
+ return this.withQueryParam(this.withQueryParam(url, 'consumer_key', auth.WooQueryAuth.Key), 'consumer_secret', auth.WooQueryAuth.Secret);
833
+ }
834
+ // ── API-root derivation ───────────────────────────────────────────────────
835
+ /**
836
+ * Derives the REST API root the way WordPress itself advertises it — NEVER by concatenating
837
+ * `siteUrl + '/wp-json'`. The REST prefix is filterable via `rest_get_url_prefix()`, so a site can serve
838
+ * it from anywhere, and a site without pretty permalinks answers only at `?rest_route=/`. Order:
839
+ * 1. an explicit `apiRoot` on the connection (sandbox/mock redirection by DATA);
840
+ * 2. the `Link: <…>; rel="https://api.w.org/"` response header on the site root (HEAD, then GET);
841
+ * 3. the same `<link>` element in the homepage HTML;
842
+ * 4. `{siteUrl}/wp-json/`, verified by an actual route index;
843
+ * 5. `{siteUrl}/?rest_route=/`, verified the same way.
844
+ */
845
+ async deriveApiRoot(siteUrl, explicit) {
846
+ if (explicit && /^https?:\/\//i.test(explicit.trim())) {
847
+ const root = explicit.trim().replace(/\/+$/, '');
848
+ return { Root: root, UsesRestRouteQuery: /[?&]rest_route=/.test(root) };
849
+ }
850
+ for (const method of ['HEAD', 'GET']) {
851
+ try {
852
+ const probe = await this.rawRequest(siteUrl || '/', method, { 'Accept': 'text/html,*/*' });
853
+ const advertised = this.readAdvertisedApiRoot(probe);
854
+ if (advertised) {
855
+ return { Root: advertised.replace(/\/+$/, ''), UsesRestRouteQuery: /[?&]rest_route=/.test(advertised) };
856
+ }
857
+ }
858
+ catch { /* fall through to the conventional roots */ }
859
+ }
860
+ for (const candidate of [`${siteUrl}/wp-json`, `${siteUrl}/?rest_route=/`]) {
861
+ try {
862
+ const probe = await this.rawRequest(candidate, 'GET', { 'Accept': 'application/json' });
863
+ if (probe.Status >= 200 && probe.Status < 300 && this.looksLikeRouteIndex(probe.Body)) {
864
+ const usesQuery = candidate.includes('rest_route=');
865
+ return { Root: usesQuery ? `${siteUrl}` : candidate, UsesRestRouteQuery: usesQuery };
866
+ }
867
+ }
868
+ catch { /* try the next form */ }
869
+ }
870
+ throw new Error(`Could not derive a WordPress REST API root from "${siteUrl}". The site advertised no ` +
871
+ `Link rel="${WP_API_LINK_REL}" header or homepage <link>, and neither ${siteUrl}/wp-json nor ` +
872
+ `${siteUrl}/?rest_route=/ returned a route index. The REST API may be disabled or blocked by the host.`);
873
+ }
874
+ /** Reads the advertised REST root out of a `Link` response header or a homepage `<link>` element. */
875
+ readAdvertisedApiRoot(response) {
876
+ const linkHeader = response.Headers?.['link'];
877
+ if (linkHeader) {
878
+ const fromHeader = this.matchApiLink(linkHeader);
879
+ if (fromHeader)
880
+ return fromHeader;
881
+ }
882
+ if (typeof response.Body === 'string') {
883
+ const m = response.Body.match(new RegExp(`<link[^>]+rel=["']${WP_API_LINK_REL.replace(/[/.]/g, '\\$&')}["'][^>]+href=["']([^"']+)["']`, 'i')) ?? response.Body.match(new RegExp(`<link[^>]+href=["']([^"']+)["'][^>]+rel=["']${WP_API_LINK_REL.replace(/[/.]/g, '\\$&')}["']`, 'i'));
884
+ if (m)
885
+ return m[1];
886
+ }
887
+ return null;
888
+ }
889
+ /** Extracts the `<url>` whose `rel` is the WordPress API rel from an RFC-5988 Link header value. */
890
+ matchApiLink(linkHeader) {
891
+ for (const part of linkHeader.split(',')) {
892
+ if (!part.includes(WP_API_LINK_REL))
893
+ continue;
894
+ const m = part.match(/<([^>]+)>/);
895
+ if (m)
896
+ return m[1].trim();
897
+ }
898
+ return null;
899
+ }
900
+ /** A body is a route index when it carries the `routes` map (and usually `namespaces`). */
901
+ looksLikeRouteIndex(body) {
902
+ if (!body || typeof body !== 'object' || Array.isArray(body))
903
+ return false;
904
+ const b = body;
905
+ return b.routes != null && typeof b.routes === 'object';
906
+ }
907
+ // ── Route index ───────────────────────────────────────────────────────────
908
+ /** Fetches (and caches per API root) the site's own route index. `context=view` is explicit so the
909
+ * `context=edit` injector leaves this public discovery call alone. */
910
+ async loadRouteIndex(auth) {
911
+ const ctx = auth;
912
+ const cached = this.routeIndexCache.get(ctx.ApiRoot);
913
+ if (cached)
914
+ return cached;
915
+ const url = this.withQueryParam(`${this.GetBaseURL({}, ctx)}/`, 'context', 'view');
916
+ const response = await this.MakeHTTPRequest(ctx, url, 'GET', this.BuildHeaders(ctx));
917
+ if (response.Status < 200 || response.Status >= 300 || !this.looksLikeRouteIndex(response.Body)) {
918
+ throw new Error(`Route index at ${ctx.ApiRoot} returned HTTP ${response.Status} without a routes map ` +
919
+ `(${this.vendorCodeOf(response.Body) ?? 'no vendor code'}).`);
920
+ }
921
+ const index = response.Body;
922
+ this.routeIndexCache.set(ctx.ApiRoot, index);
923
+ return index;
924
+ }
925
+ /**
926
+ * Derives LISTABLE COLLECTIONS from the route index. A route qualifies when it is readable, carries no
927
+ * path capture of its own, is not a namespace root, and registers `per_page` — the discriminator that
928
+ * separates a record collection from the RPC routes WordPress also registers.
929
+ */
930
+ deriveCollectionRoutes(index) {
931
+ const out = [];
932
+ for (const [path, entry] of Object.entries(index.routes ?? {})) {
933
+ if (path === '/' || /\(\?P</.test(path))
934
+ continue;
935
+ const ns = (entry.namespace ?? '').trim();
936
+ if (!ns)
937
+ continue; // e.g. /batch/v1 — a write TRANSPORT, not a record family
938
+ if (path === `/${ns}`)
939
+ continue; // the namespace root index
940
+ const methods = (entry.methods ?? []).map(m => m.toUpperCase());
941
+ if (!methods.includes('GET'))
942
+ continue;
943
+ const readEndpoint = (entry.endpoints ?? []).find(e => (e.methods ?? []).map(m => m.toUpperCase()).includes('GET'));
944
+ if (!readEndpoint?.args || !('per_page' in readEndpoint.args))
945
+ continue;
946
+ out.push({
947
+ Path: path,
948
+ Namespace: ns,
949
+ SupportsWrite: methods.includes('POST') || methods.includes('PUT') || methods.includes('PATCH'),
950
+ });
951
+ }
952
+ return out;
953
+ }
954
+ /**
955
+ * Namespaces the OPERATOR scoped out of 1.0.0, read from the Integration row's own
956
+ * `Configuration.OutOfScopeObjectFamilies[].kind`. First-party RPC / admin / analytics / legacy /
957
+ * transport / alias namespaces are not record collections and stay out. `third-party-plugin` and the
958
+ * per-site remainder are NOT excluded — the metadata's own reasons say those are "reachable at runtime
959
+ * via route-index discovery", which is precisely this path.
960
+ */
961
+ readScopedOutNamespaces(companyIntegration) {
962
+ const out = new Set();
963
+ const integration = this.tryGetIntegration(companyIntegration.IntegrationID);
964
+ const cfg = this.parseJsonObject(integration?.Configuration ?? null);
965
+ const families = cfg?.['OutOfScopeObjectFamilies'];
966
+ if (!Array.isArray(families))
967
+ return out;
968
+ for (const raw of families) {
969
+ if (!raw || typeof raw !== 'object')
970
+ continue;
971
+ const fam = raw;
972
+ const kind = typeof fam.kind === 'string' ? fam.kind : '';
973
+ const ns = typeof fam.namespace === 'string' ? fam.namespace.trim() : '';
974
+ if (!ns || ns === '(per-site)')
975
+ continue;
976
+ if (kind.startsWith('first-party') || kind === 'vendored-third-party-namespace')
977
+ out.add(ns);
978
+ }
979
+ return out;
980
+ }
981
+ // ── OPTIONS field description ─────────────────────────────────────────────
982
+ /** Issues `OPTIONS <route>` and maps the endpoint's JSON Schema properties to ExternalFieldSchema. */
983
+ async describeRoute(auth, routePath) {
984
+ const ctx = auth;
985
+ const url = `${this.GetBaseURL({}, ctx)}${routePath}`;
986
+ const response = await this.MakeHTTPRequest(ctx, url, 'OPTIONS', this.BuildHeaders(ctx));
987
+ if (response.Status < 200 || response.Status >= 300) {
988
+ const classified = this.ClassifyWordPressResponse(response.Status, response.Body);
989
+ throw new Error(`OPTIONS ${routePath} → HTTP ${response.Status} (${classified.VendorCode ?? classified.Reason})`);
990
+ }
991
+ const body = response.Body;
992
+ const properties = body?.schema?.properties;
993
+ if (!properties)
994
+ return [];
995
+ const pkName = this.itemRouteKeyName(routePath);
996
+ const out = [];
997
+ for (const [name, prop] of Object.entries(properties)) {
998
+ out.push(this.schemaPropertyToField(name, prop, pkName));
999
+ }
1000
+ return out;
1001
+ }
1002
+ /**
1003
+ * Maps ONE JSON Schema property to a field schema, provable-only throughout: `IsPrimaryKey` is set only
1004
+ * when the property is the one the route's own ITEM path addresses records by (Tier-1 addressing-path
1005
+ * evidence, the same class the extractor used); `AllowsNull` only when the declared type union contains
1006
+ * `null`; `IsForeignKey` is never inferred, because WordPress publishes no machine-readable FK model.
1007
+ */
1008
+ schemaPropertyToField(name, prop, pkName) {
1009
+ const types = Array.isArray(prop.type) ? prop.type : (prop.type ? [prop.type] : []);
1010
+ const nullable = types.includes('null');
1011
+ const concrete = types.filter(t => t !== 'null');
1012
+ const isPK = pkName != null && name === pkName;
1013
+ return {
1014
+ Name: name,
1015
+ Label: name,
1016
+ Description: prop.description,
1017
+ DataType: prop.format === 'date-time' ? 'datetime' : (concrete[0] ?? 'string'),
1018
+ IsRequired: prop.required === true,
1019
+ AllowsNull: nullable ? true : undefined,
1020
+ IsPrimaryKey: isPK ? true : undefined,
1021
+ IsUniqueKey: isPK,
1022
+ IsReadOnly: prop.readonly === true,
1023
+ IsForeignKey: false,
1024
+ ForeignKeyTarget: null,
1025
+ MaxLength: this.toPositiveInt(prop.maxLength) ?? null,
1026
+ };
1027
+ }
1028
+ /**
1029
+ * UNION of declared × described, keyed by field name. Declared WINS on every attribute (it is the
1030
+ * docs-provable maximum a fully-privileged credential sees); a described-only field is APPENDED as a
1031
+ * per-site custom. Nothing is ever removed — a thinner runtime schema is a capability artefact.
1032
+ */
1033
+ unionFieldSchemas(declared, described) {
1034
+ const byName = new Map(declared.map(f => [f.Name, f]));
1035
+ const out = [...declared];
1036
+ for (const field of described) {
1037
+ const existing = byName.get(field.Name);
1038
+ if (!existing) {
1039
+ out.push(field);
1040
+ byName.set(field.Name, field);
1041
+ continue;
1042
+ }
1043
+ if (existing.MaxLength == null && field.MaxLength != null)
1044
+ existing.MaxLength = field.MaxLength;
1045
+ if (!existing.Description && field.Description)
1046
+ existing.Description = field.Description;
1047
+ }
1048
+ return out;
1049
+ }
1050
+ /** The literal (untemplated) collection path to OPTIONS for an object, or null when it is parent-templated. */
1051
+ optionsRoutePathFor(integrationID, objectName) {
1052
+ const obj = this.tryGetCachedObject(integrationID, objectName);
1053
+ // A route-index-discovered object is NAMED by its path, so it can be described directly.
1054
+ const path = obj ? this.declaredListPath(obj) : (objectName.startsWith('/') ? objectName : null);
1055
+ if (!path)
1056
+ return null;
1057
+ if (/\{\w+\}/.test(path))
1058
+ return null;
1059
+ return path;
1060
+ }
1061
+ /** Name of the key an item route addresses records by, e.g. `/wp/v2/posts` → `id` via its `{id}` sibling. */
1062
+ itemRouteKeyName(collectionPath) {
1063
+ const index = [...this.routeIndexCache.values()][0];
1064
+ if (!index?.routes)
1065
+ return null;
1066
+ const prefix = `${collectionPath}/`;
1067
+ for (const path of Object.keys(index.routes)) {
1068
+ if (!path.startsWith(prefix))
1069
+ continue;
1070
+ const tail = path.slice(prefix.length);
1071
+ const m = tail.match(/^\(\?P<(\w+)>/);
1072
+ if (m)
1073
+ return m[1] === 'id' ? 'id' : m[1];
1074
+ }
1075
+ return null;
1076
+ }
1077
+ // ── Query-param construction ──────────────────────────────────────────────
1078
+ /**
1079
+ * The per-object read parameters, every one of them READ FROM METADATA:
1080
+ * - STABLE SORT (`orderby=id&order=asc`) on the page-numbered collections whose declared
1081
+ * `StableOrderingKey` is `id`, to minimise the offset drift page arithmetic is prone to.
1082
+ * - The INCREMENTAL filter, and ONLY where the object declares one. The six objects that register
1083
+ * `modified_after` but expose no modified column, plus `wp/v2/users` and `wc/v3/customers` (whose
1084
+ * controllers inherit no date params at all), carry a null watermark in metadata and therefore get
1085
+ * NO delta path here. The connector never synthesises one, and never rounds an insert-only
1086
+ * high-water up to "incremental supported".
1087
+ */
1088
+ async buildObjectQueryParams(obj, cfg, ctx, auth) {
1089
+ const params = {};
1090
+ if (obj.SupportsPagination && obj.PaginationType === 'PageNumber' && obj.StableOrderingKey === 'id') {
1091
+ params.orderby = 'id';
1092
+ params.order = 'asc';
1093
+ }
1094
+ const watermark = cfg?.incrementalWatermark;
1095
+ if (watermark?.filterParam && this.hasLiveIncrementalWatermark(obj, cfg) && ctx.WatermarkValue) {
1096
+ const since = await this.watermarkFilterValue(obj, watermark, ctx.WatermarkValue, auth);
1097
+ if (since) {
1098
+ params[watermark.filterParam] = since;
1099
+ if (watermark.datesAreGmt)
1100
+ params.dates_are_gmt = 'true';
1101
+ // An incremental pass orders by the watermark so the max-seen advances monotonically.
1102
+ if (watermark.orderby) {
1103
+ params.orderby = watermark.orderby;
1104
+ params.order = 'asc';
1105
+ }
1106
+ // `beforeParam` (`modified_before`) is DELIBERATELY not sent. `FetchContext` carries no
1107
+ // upper bound — the engine never bounds the window — so any `modified_before` this
1108
+ // connector invented would be a fabricated ceiling that could drop records committed
1109
+ // mid-run. The param stays declared in metadata for the day the engine supplies a bound.
1110
+ }
1111
+ }
1112
+ return params;
1113
+ }
1114
+ /**
1115
+ * Renders the stored watermark into the exact string the object's declared filter param compares
1116
+ * against, with ROUND-TRIP FIDELITY as the first rule: the value normally came FROM this object's own
1117
+ * watermark field, so it is already in the vendor's own representation and goes back verbatim.
1118
+ *
1119
+ * THE ONE CASE THAT IS NOT A ROUND TRIP: after a CLEAN FULL sync the engine deliberately replaces the
1120
+ * connector's max-seen value with wall-clock `new Date().toISOString()` — an INSTANT, in UTC, carrying
1121
+ * a `Z`. For a `dates_are_gmt` object that is exactly right: WooCommerce compares against the `_gmt`
1122
+ * columns, and this connector already sends `dates_are_gmt=true` alongside. For a SITE-LOCAL object it
1123
+ * is not: `WP_REST_Posts_Controller` date_query's `modified_after` against `post_modified`, the
1124
+ * site-local column, so on a site behind UTC a UTC instant is LATER than the same moment's local wall
1125
+ * clock and the next incremental would silently SKIP everything modified inside the offset window.
1126
+ * Project it into the site's own wall clock using the `gmt_offset` the REST index publishes.
1127
+ *
1128
+ * When the site publishes no usable offset the value is passed through UNSHIFTED and the gap is said
1129
+ * out loud — a guessed offset would fabricate a vendor fact, and an unshifted filter is no worse than
1130
+ * the value the engine stored.
1131
+ */
1132
+ async watermarkFilterValue(obj, watermark, stored, auth) {
1133
+ const normalized = this.toWordPressDate(stored);
1134
+ if (!normalized)
1135
+ return null;
1136
+ // No designator → it round-tripped from the object's own field, which is already in the column's
1137
+ // own representation. GMT-column object → a UTC instant is exactly what it compares against.
1138
+ if (!/(Z|[+-]\d{2}:?\d{2})$/.test(normalized) || watermark.datesAreGmt === true)
1139
+ return normalized;
1140
+ const offsetMinutes = await this.siteGmtOffsetMinutes(auth);
1141
+ if (offsetMinutes === null) {
1142
+ this.warnOnce('watermark-site-offset-unknown', `[WordPress] The stored watermark for "${obj.Name}" is an absolute UTC instant (the engine advances ` +
1143
+ `to wall-clock "now" after a clean full sync) but "${watermark.filterParam}" compares against the ` +
1144
+ `SITE-LOCAL "${watermark.field ?? obj.IncrementalWatermarkField}" column, and this site publishes no ` +
1145
+ `"gmt_offset" in its REST index. The filter is sent UNSHIFTED: on a site behind UTC the next ` +
1146
+ `incremental can skip records modified inside the offset window. Re-run a full sync, or set the ` +
1147
+ `site's timezone, to close it — the connector will not guess an offset.`);
1148
+ return normalized;
1149
+ }
1150
+ const instant = Date.parse(normalized);
1151
+ if (!Number.isFinite(instant))
1152
+ return normalized;
1153
+ return new Date(instant + offsetMinutes * 60_000).toISOString().replace(/\.\d{3}Z$/, '');
1154
+ }
1155
+ /**
1156
+ * The site's UTC offset in MINUTES, read from the `gmt_offset` the public REST index advertises
1157
+ * (hours, possibly fractional — India is 5.5, Chatham is 12.75). Null when the site does not publish
1158
+ * one or the index is unreachable; callers must then decline to shift rather than assume UTC.
1159
+ */
1160
+ async siteGmtOffsetMinutes(auth) {
1161
+ try {
1162
+ const index = await this.loadRouteIndex(auth);
1163
+ const raw = index.gmt_offset;
1164
+ if (raw === null || raw === undefined)
1165
+ return null;
1166
+ const hours = typeof raw === 'number' ? raw : Number(String(raw).trim());
1167
+ if (!Number.isFinite(hours) || String(raw).trim() === '')
1168
+ return null;
1169
+ return Math.round(hours * 60);
1170
+ }
1171
+ catch {
1172
+ return null;
1173
+ }
1174
+ }
1175
+ /**
1176
+ * Normalises a stored watermark to the ISO-8601 form WordPress's date params accept — with ROUND-TRIP
1177
+ * FIDELITY as the first rule. The watermark came FROM the object's own `modified` / `date_modified_gmt`
1178
+ * field, which WordPress renders WITHOUT a timezone designator and interprets in the SITE's timezone
1179
+ * (unless `dates_are_gmt` is set). Re-projecting such a value through UTC would silently shift the
1180
+ * filter by the site's offset and skip or re-fetch records, so an already-ISO value is handed back
1181
+ * verbatim; only a non-ISO representation is converted.
1182
+ */
1183
+ toWordPressDate(value) {
1184
+ const trimmed = value.trim();
1185
+ if (!trimmed)
1186
+ return null;
1187
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/.test(trimmed)) {
1188
+ return trimmed.replace(' ', 'T').replace(/\.\d+/, '');
1189
+ }
1190
+ const parsed = Date.parse(trimmed);
1191
+ if (!Number.isFinite(parsed))
1192
+ return null;
1193
+ return new Date(parsed).toISOString().replace(/\.\d{3}Z$/, '');
1194
+ }
1195
+ /** Max value of the object's declared watermark field across a batch — max-SEEN, never most-recent. */
1196
+ maxWatermark(obj, records) {
1197
+ const field = obj.IncrementalWatermarkField;
1198
+ if (!obj.SupportsIncrementalSync || !field || records.length === 0)
1199
+ return null;
1200
+ let best = null;
1201
+ let bestMs = Number.NEGATIVE_INFINITY;
1202
+ for (const record of records) {
1203
+ const raw = record.Fields[field];
1204
+ if (typeof raw !== 'string' || raw.length === 0)
1205
+ continue;
1206
+ const ms = Date.parse(raw);
1207
+ if (!Number.isFinite(ms) || ms <= bestMs)
1208
+ continue;
1209
+ bestMs = ms;
1210
+ best = raw;
1211
+ }
1212
+ return best;
1213
+ }
1214
+ // ── Namespace credential guard ────────────────────────────────────────────
1215
+ /**
1216
+ * A WooCommerce consumer key/secret CANNOT read `wp/v2` — `WC_REST_Authentication::is_request_to_rest_api()`
1217
+ * matches only URIs whose path after the REST prefix begins `wc/` or `wc-`. So a Woo-only connection must
1218
+ * report every core object as an explicit CAPABILITY GAP rather than sync it empty and green.
1219
+ */
1220
+ namespaceCredentialGuard(obj, cfg, auth) {
1221
+ const ctx = auth;
1222
+ if (ctx.HasApplicationPassword || !ctx.HasWooCredential)
1223
+ return null;
1224
+ const ns = cfg?.namespace ?? '';
1225
+ if (ns.startsWith('wc/') || ns.startsWith('wc-'))
1226
+ return null;
1227
+ return {
1228
+ Code: 'CAPABILITY_WOO_ONLY_CREDENTIAL',
1229
+ Message: `"${obj.Name}" lives in namespace "${ns || 'wp/v2'}", which a WooCommerce consumer key/secret cannot ` +
1230
+ `authenticate at all. This connection has NO WordPress Application Password, so no record was fetched — ` +
1231
+ `this is a credential gap, not an empty object. Add a username + Application Password to sync wp/v2.`,
1232
+ Data: { object: obj.Name, namespace: ns, hasApplicationPassword: false, hasWooCredential: true },
1233
+ };
1234
+ }
1235
+ // ── Graceful degrades ─────────────────────────────────────────────────────
1236
+ /**
1237
+ * Turns the two failures that are FACTS ABOUT THE SITE (rather than sync faults) into a warned
1238
+ * zero-record result:
1239
+ * - 404 / `rest_no_route` — the route is not registered on THIS site. WooCommerce order fulfillments
1240
+ * sit behind `FeaturesUtil::feature_is_enabled('fulfillments')`, and any gated or plugin-provided
1241
+ * route can be absent the same way. That is correct behaviour for the site, not a broken sync.
1242
+ * - 401 / 403 with a WordPress envelope — the credential lacks the capability for this collection.
1243
+ * Everything else (5xx, transport, throttling) propagates so the engine retries or fails loudly.
1244
+ */
1245
+ gracefulFetchFailure(obj, status, body) {
1246
+ if (status == null)
1247
+ return null;
1248
+ const classified = this.ClassifyWordPressResponse(status, body);
1249
+ if (status === 404) {
1250
+ return { Records: [], HasMore: false, Warnings: [{
1251
+ Code: 'ROUTE_NOT_REGISTERED',
1252
+ Message: `"${obj.Name}" (${obj.APIPath}) is not registered on this site — the route index has no such route ` +
1253
+ `(${classified.VendorCode ?? 'HTTP 404'}). WordPress route registration is per-site and feature-gated, ` +
1254
+ `so this is an ABSENT capability on this install, not a sync failure. Nothing was deactivated.`,
1255
+ Data: { object: obj.Name, path: obj.APIPath, status, vendorCode: classified.VendorCode },
1256
+ }] };
1257
+ }
1258
+ if (status === 401 || status === 403) {
1259
+ return { Records: [], HasMore: false, Warnings: [{
1260
+ Code: classified.Reason === 'waf-or-host-block-html-body' ? 'BLOCKED_BY_HOST_OR_WAF' : 'CAPABILITY_FORBIDDEN',
1261
+ Message: classified.Reason === 'waf-or-host-block-html-body'
1262
+ ? `"${obj.Name}" was blocked BEFORE reaching the WordPress REST API — HTTP ${status} with an HTML body ` +
1263
+ `is a WAF / host / mod_security block, not an API capability refusal. Retrying will not help; the ` +
1264
+ `site's host or firewall must allow this request.`
1265
+ : `"${obj.Name}" is forbidden to this credential (HTTP ${status}, ${classified.VendorCode ?? 'no vendor code'}). ` +
1266
+ `WordPress capability checks are per-route, so this is a permission gap on the credential — no record ` +
1267
+ `was fetched and nothing was deactivated.`,
1268
+ Data: { object: obj.Name, status, vendorCode: classified.VendorCode, reason: classified.Reason },
1269
+ }] };
1270
+ }
1271
+ return null;
1272
+ }
1273
+ /**
1274
+ * A NESTED collection (`/wc/v3/orders/{order_id}/notes`, `/wp/v2/posts/{parent}/revisions`, …) fans out
1275
+ * one request per parent, and the base resolves those parent ids from the ALREADY-SYNCED rows in the MJ
1276
+ * target database. When no metadata provider is reachable — the parent object has not been mapped or
1277
+ * synced yet, or the connector is running outside a database context — that is a DAG-ordering fact, not
1278
+ * a connector fault: surface it as a named warning so the run artifact shows WHY the object is empty,
1279
+ * instead of failing the whole sync or reporting a silent zero.
1280
+ */
1281
+ parentResolutionUnavailable(obj, err) {
1282
+ const message = this.errText(err);
1283
+ if (!/RunView|Metadata\.?Provider|No provider|provider is not set/i.test(message))
1284
+ return null;
1285
+ return { Records: [], HasMore: false, Warnings: [{
1286
+ Code: 'PARENT_RESOLUTION_UNAVAILABLE',
1287
+ Message: `"${obj.Name}" is a nested collection (${obj.APIPath}) whose parent ids are read from the already-synced ` +
1288
+ `parent records, and that lookup was unavailable (${message}). Sync the parent object first, or map it, ` +
1289
+ `so this object has parents to iterate. No record was fetched and nothing was deactivated.`,
1290
+ Data: { object: obj.Name, path: obj.APIPath, reason: 'parent-id-lookup-unavailable' },
1291
+ }] };
1292
+ }
1293
+ /** Clears any read failure remembered for this object's route, so a retry starts from a clean slate. */
1294
+ clearReadFailures(obj, cfg) {
1295
+ const path = cfg?.listPath ?? obj.APIPath;
1296
+ for (const key of [...this.lastReadFailureByPath.keys()]) {
1297
+ if (key.endsWith(path))
1298
+ this.lastReadFailureByPath.delete(key);
1299
+ }
1300
+ }
1301
+ /** The non-2xx read outcome recorded for this object's route during the pass just completed. */
1302
+ recordedReadFailure(obj, cfg) {
1303
+ const path = cfg?.listPath ?? obj.APIPath;
1304
+ for (const [key, value] of this.lastReadFailureByPath) {
1305
+ if (key.endsWith(path))
1306
+ return value;
1307
+ }
1308
+ return null;
1309
+ }
1310
+ // ── Soft-delete (trash) capture ───────────────────────────────────────────
1311
+ /**
1312
+ * WordPress post types SOFT-delete: `DELETE` moves the row to `status=trash` and trashed rows stay
1313
+ * listable via `status=trash`. This sweep captures them so a soft delete is visible to the engine.
1314
+ *
1315
+ * HONEST LIMITS, stated rather than papered over: there is NO deleted-records feed anywhere in wp/v2 or
1316
+ * wc/v3, and a HARD delete (`?force=true`) leaves no tombstone at all — so full deletion reconciliation
1317
+ * remains a declared KEY SWEEP, not detection. The sweep runs only on a fully-drained pass, only for
1318
+ * objects whose metadata declares trash semantics in `wp/v2`, and only for an AUTHENTICATED connection
1319
+ * (an anonymous caller can never see trash, and `status` is itself capability-gated).
1320
+ */
1321
+ async fetchTrashedRecords(obj, cfg, ctx, auth, mainPass) {
1322
+ const wpAuth = auth;
1323
+ if (mainPass.HasMore)
1324
+ return { Records: [] };
1325
+ if (!wpAuth.HasApplicationPassword)
1326
+ return { Records: [] };
1327
+ const semantics = cfg?.deleteSemantics?.semantics ?? '';
1328
+ if (!semantics.startsWith('soft-delete-to-trash') && !semantics.startsWith('trash-gated'))
1329
+ return { Records: [] };
1330
+ if ((cfg?.namespace ?? '') !== 'wp/v2')
1331
+ return { Records: [] };
1332
+ if (!this.GetCachedFields(obj.ID).some(f => f.Name === 'status'))
1333
+ return { Records: [] };
1334
+ const seen = new Set(mainPass.Records.map(r => r.ExternalID));
1335
+ this.activeFetchParams.set(obj.Name, { ...await this.buildObjectQueryParams(obj, cfg, ctx, auth), status: 'trash' });
1336
+ try {
1337
+ const pass = await super.FetchChanges({ ...ctx, CurrentPage: undefined, CurrentOffset: undefined, AfterKeyValue: null });
1338
+ return { Records: pass.Records.filter(r => !seen.has(r.ExternalID)) };
1339
+ }
1340
+ catch (err) {
1341
+ return { Records: [], Warning: {
1342
+ Code: 'SOFT_DELETE_SWEEP_UNAVAILABLE',
1343
+ Message: `Soft-delete (status=trash) sweep for "${obj.Name}" could not run (${this.errText(err)}). The ` +
1344
+ `\`status\` parameter is capability-gated, so trashed records are invisible to this credential. ` +
1345
+ `WordPress publishes NO deleted-records feed and a hard delete leaves no tombstone, so deletion ` +
1346
+ `reconciliation for this object remains a declared KEY SWEEP — the connector does not claim delete detection.`,
1347
+ Data: { object: obj.Name },
1348
+ } };
1349
+ }
1350
+ finally {
1351
+ this.activeFetchParams.delete(obj.Name);
1352
+ }
1353
+ }
1354
+ /** Flags any record carrying WordPress's `trash` status so the engine applies the connection's DeleteBehavior. */
1355
+ markTrashedAsDeleted(records) {
1356
+ for (const record of records) {
1357
+ if (record.Fields['status'] === 'trash')
1358
+ record.IsDeleted = true;
1359
+ }
1360
+ }
1361
+ // ── Global styles (declared KnownGap: "wp/v2 global styles enumeration") ──
1362
+ /** True for the one declared object whose collection route WordPress never registers. */
1363
+ isGlobalStylesObject(obj, cfg) {
1364
+ const path = cfg?.listPath ?? obj.APIPath;
1365
+ return path.startsWith('/wp/v2/global-styles/') && /\{\w+\}/.test(path);
1366
+ }
1367
+ /**
1368
+ * WordPress core registers NO collection route for global styles — only `/wp/v2/global-styles/{id}` and
1369
+ * `/revisions` — so the ids cannot be listed and the object would otherwise sync zero rows while looking
1370
+ * healthy (the declared metadata records this in `KnownGaps: "wp/v2 global styles enumeration"`).
1371
+ *
1372
+ * The ids ARE reachable through WordPress's own HAL links: each theme record advertises its user global
1373
+ * styles post as `_links["wp:user-global-styles"]`. This walks that link — the source's own model, not a
1374
+ * guessed URL — and fetches each id. When the link is absent the object reports an EXPLICIT warning
1375
+ * rather than a silent empty success.
1376
+ */
1377
+ async fetchGlobalStyles(obj, cfg, ctx, auth) {
1378
+ const baseURL = this.GetBaseURL(ctx.CompanyIntegration, auth);
1379
+ const headers = this.BuildHeaders(auth);
1380
+ const fields = this.GetCachedFields(obj.ID);
1381
+ const pkNames = fields.filter(f => f.IsPrimaryKey).sort((a, b) => a.Sequence - b.Sequence).map(f => f.Name);
1382
+ let themes;
1383
+ try {
1384
+ const response = await this.MakeHTTPRequest(auth, `${baseURL}/wp/v2/themes?status=active`, 'GET', headers);
1385
+ if (response.Status < 200 || response.Status >= 300) {
1386
+ throw new Error(`HTTP ${response.Status} (${this.vendorCodeOf(response.Body) ?? 'no vendor code'})`);
1387
+ }
1388
+ themes = this.NormalizeResponse(response.Body, null);
1389
+ }
1390
+ catch (err) {
1391
+ return { Records: [], HasMore: false, Warnings: [this.globalStylesWarning(obj, `active theme lookup failed: ${this.errText(err)}`)] };
1392
+ }
1393
+ const ids = new Set();
1394
+ for (const theme of themes) {
1395
+ for (const id of this.userGlobalStyleIDs(theme))
1396
+ ids.add(id);
1397
+ }
1398
+ if (ids.size === 0) {
1399
+ return { Records: [], HasMore: false, Warnings: [this.globalStylesWarning(obj, 'no theme advertised a wp:user-global-styles link')] };
1400
+ }
1401
+ const itemPath = cfg?.listPath ?? obj.APIPath;
1402
+ const records = [];
1403
+ for (const id of ids) {
1404
+ try {
1405
+ const url = `${baseURL}${itemPath.replace(/\{\w+\}/, encodeURIComponent(id))}`;
1406
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1407
+ if (response.Status < 200 || response.Status >= 300)
1408
+ continue;
1409
+ for (const raw of this.NormalizeResponse(response.Body, obj.ResponseDataKey)) {
1410
+ records.push(this.buildRecord(raw, obj.Name, pkNames));
1411
+ }
1412
+ }
1413
+ catch { /* one unreadable global-styles row must not fail the object */ }
1414
+ }
1415
+ return { Records: records, HasMore: false };
1416
+ }
1417
+ /** Extracts global-styles ids from a theme record's HAL `_links`. */
1418
+ userGlobalStyleIDs(theme) {
1419
+ const links = theme['_links'];
1420
+ if (!links || typeof links !== 'object')
1421
+ return [];
1422
+ const out = [];
1423
+ for (const [rel, value] of Object.entries(links)) {
1424
+ if (!/user-global-styles$/i.test(rel) || !Array.isArray(value))
1425
+ continue;
1426
+ for (const entry of value) {
1427
+ const href = entry && typeof entry === 'object' ? entry.href : null;
1428
+ if (typeof href !== 'string')
1429
+ continue;
1430
+ const m = href.match(/\/global-styles\/([^/?#]+)/);
1431
+ if (m)
1432
+ out.push(decodeURIComponent(m[1]));
1433
+ }
1434
+ }
1435
+ return out;
1436
+ }
1437
+ /** The honest, named warning for an unenumerable global-styles object. */
1438
+ globalStylesWarning(obj, detail) {
1439
+ return {
1440
+ Code: 'GLOBAL_STYLES_NOT_ENUMERABLE',
1441
+ Message: `"${obj.Name}" cannot be listed: WordPress core registers NO collection route for global styles, only ` +
1442
+ `/wp/v2/global-styles/{id}. The connector resolves ids from the active theme's own ` +
1443
+ `wp:user-global-styles link, and that failed here (${detail}). Zero records is a KNOWN vendor ` +
1444
+ `enumeration gap for this object, not an empty data set.`,
1445
+ Data: { object: obj.Name, detail },
1446
+ };
1447
+ }
1448
+ /**
1449
+ * Builds an ExternalRecord with the FULL source record in `Fields` — never a narrow literal. The
1450
+ * framework's custom-column capture diffs `keys(Fields)` against the active field maps, so anything
1451
+ * dropped here (per-site `meta`, plugin-added properties) would be invisible and unrecoverable.
1452
+ */
1453
+ buildRecord(raw, objectType, pkFieldNames) {
1454
+ const usable = pkFieldNames.length > 0
1455
+ && pkFieldNames.every(n => raw[n] != null && String(raw[n]).length > 0);
1456
+ const externalID = usable
1457
+ ? pkFieldNames.map(n => String(raw[n])).join('|')
1458
+ : (raw.id != null ? String(raw.id) : '');
1459
+ return { ExternalID: externalID, ObjectType: objectType, Fields: raw };
1460
+ }
1461
+ // ── Credential loading ────────────────────────────────────────────────────
1462
+ /** Reads the credential from the linked `MJ: Credentials` row, merged over the connection Configuration JSON. */
1463
+ async loadCredentials(companyIntegration, contextUser) {
1464
+ let fromCredential = null;
1465
+ if (companyIntegration.CredentialID) {
1466
+ try {
1467
+ const md = new Metadata();
1468
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
1469
+ const loaded = await credential.Load(companyIntegration.CredentialID);
1470
+ if (loaded && credential.Values)
1471
+ fromCredential = this.parseCredentialJson(credential.Values);
1472
+ }
1473
+ catch {
1474
+ // A credential the connection cannot load is a configuration problem, not a crash: the
1475
+ // Configuration fallback below still applies and Authenticate reports what is missing.
1476
+ }
1477
+ }
1478
+ const fromConfig = companyIntegration.Configuration
1479
+ ? this.parseCredentialJson(companyIntegration.Configuration)
1480
+ : null;
1481
+ return {
1482
+ SiteUrl: fromCredential?.SiteUrl ?? fromConfig?.SiteUrl,
1483
+ ApiRoot: fromCredential?.ApiRoot ?? fromConfig?.ApiRoot,
1484
+ Username: fromCredential?.Username ?? fromConfig?.Username,
1485
+ ApplicationPassword: fromCredential?.ApplicationPassword ?? fromConfig?.ApplicationPassword,
1486
+ WooConsumerKey: fromCredential?.WooConsumerKey ?? fromConfig?.WooConsumerKey,
1487
+ WooConsumerSecret: fromCredential?.WooConsumerSecret ?? fromConfig?.WooConsumerSecret,
1488
+ };
1489
+ }
1490
+ /** Extracts the WordPress credential fields from a credential/Configuration JSON string. */
1491
+ parseCredentialJson(json) {
1492
+ const parsed = this.parseJsonObject(json);
1493
+ if (!parsed)
1494
+ return null;
1495
+ return {
1496
+ SiteUrl: this.firstString(parsed, ['siteUrl', 'SiteUrl', 'site_url', 'BaseURL', 'baseURL', 'BaseUrl', 'baseUrl']),
1497
+ ApiRoot: this.firstString(parsed, ['apiRoot', 'ApiRoot', 'restApiRoot', 'RestApiRoot', 'APIBaseURL']),
1498
+ Username: this.firstString(parsed, ['username', 'Username', 'user', 'login']),
1499
+ ApplicationPassword: this.firstString(parsed, ['applicationPassword', 'ApplicationPassword', 'appPassword', 'password', 'Password']),
1500
+ WooConsumerKey: this.firstString(parsed, ['wooConsumerKey', 'WooConsumerKey', 'consumer_key', 'consumerKey']),
1501
+ WooConsumerSecret: this.firstString(parsed, ['wooConsumerSecret', 'WooConsumerSecret', 'consumer_secret', 'consumerSecret']),
1502
+ };
1503
+ }
1504
+ /** Whether this connection opted into Woo's query-param credential fallback (hosts that strip Authorization). */
1505
+ wooQueryParamAuthEnabled(companyIntegration) {
1506
+ const cfg = this.parseJsonObject(companyIntegration.Configuration);
1507
+ if (!cfg)
1508
+ return false;
1509
+ for (const key of ['wooAuthViaQueryParams', 'WooAuthViaQueryParams', 'wooQueryParamAuth']) {
1510
+ const v = cfg[key];
1511
+ if (v === true || v === 'true')
1512
+ return true;
1513
+ }
1514
+ return false;
1515
+ }
1516
+ /** Reads a trimmed string value from the connection Configuration JSON. */
1517
+ readConnectionConfigString(companyIntegration, keys) {
1518
+ const cfg = this.parseJsonObject(companyIntegration.Configuration);
1519
+ if (!cfg)
1520
+ return null;
1521
+ const v = this.firstString(cfg, keys);
1522
+ return v != null && v.length > 0 ? v : null;
1523
+ }
1524
+ // ── Metadata accessors (seams; test subclasses override these) ────────────
1525
+ /** All ACTIVE IntegrationObjects for the integration; `[]` when the engine cache is unavailable. */
1526
+ getCachedObjects(integrationID) {
1527
+ try {
1528
+ return IntegrationEngineBase.Instance.GetActiveIntegrationObjects(integrationID);
1529
+ }
1530
+ catch {
1531
+ return [];
1532
+ }
1533
+ }
1534
+ /** The Integration row itself (for the Integration-level Configuration blob); null when unavailable. */
1535
+ tryGetIntegration(_integrationID) {
1536
+ try {
1537
+ return IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName) ?? null;
1538
+ }
1539
+ catch {
1540
+ return null;
1541
+ }
1542
+ }
1543
+ /** Non-throwing IntegrationObject lookup by integration + name. */
1544
+ tryGetCachedObject(integrationID, objectName) {
1545
+ try {
1546
+ return this.GetCachedObject(integrationID, objectName);
1547
+ }
1548
+ catch {
1549
+ return null;
1550
+ }
1551
+ }
1552
+ /** Non-throwing IntegrationObject lookup by name alone (used by StableOrderingKey, called early). */
1553
+ tryGetCachedObjectByName(objectName) {
1554
+ const integrationID = this.tryGetIntegrationID();
1555
+ return integrationID ? this.tryGetCachedObject(integrationID, objectName) : null;
1556
+ }
1557
+ /** This connector's own `MJ: Integrations.ID`; null when the engine cache is not loaded yet. */
1558
+ tryGetIntegrationID() {
1559
+ try {
1560
+ return IntegrationEngineBase.Instance.GetIntegrationByName(this.IntegrationName)?.ID ?? null;
1561
+ }
1562
+ catch {
1563
+ return null;
1564
+ }
1565
+ }
1566
+ /** The declared collection path for an object (`Configuration.listPath`, else its APIPath). */
1567
+ declaredListPath(obj) {
1568
+ return this.objectConfig(obj)?.listPath ?? obj.APIPath;
1569
+ }
1570
+ /** Typed view of an IntegrationObject's Configuration JSON. */
1571
+ objectConfig(obj) {
1572
+ const parsed = this.parseJsonObject(obj.Configuration);
1573
+ return parsed ? parsed : null;
1574
+ }
1575
+ /** The `context=edit`-gated field names declared for whichever object owns this request path. */
1576
+ contextGatedFieldsForPath(auth, routePath) {
1577
+ for (const obj of this.getCachedObjects(auth.IntegrationID)) {
1578
+ const cfg = this.objectConfig(obj);
1579
+ const declared = cfg?.listPath ?? obj.APIPath;
1580
+ if (!declared || !routePath.endsWith(declared))
1581
+ continue;
1582
+ return cfg?.contextGatedFields ?? [];
1583
+ }
1584
+ return [];
1585
+ }
1586
+ // ── Small helpers ─────────────────────────────────────────────────────────
1587
+ /** Canonical form of a route path with its captures collapsed, so `{id}` and `(?P<id>[\\d]+)` compare equal. */
1588
+ canonicalRoutePath(path) {
1589
+ return path
1590
+ .replace(/\(\?P<[^>]+>[^)]*\)/g, '{}')
1591
+ .replace(/\{\w+\}/g, '{}')
1592
+ .replace(/\/+$/, '');
1593
+ }
1594
+ /** A readable label for a discovered route, e.g. `/wp/v2/my-events` → `My Events (wp/v2)`. */
1595
+ humanLabelForRoute(path, namespace) {
1596
+ const tail = path.startsWith(`/${namespace}/`) ? path.slice(namespace.length + 2) : path.replace(/^\//, '');
1597
+ const words = tail.split('/').filter(Boolean).join(' ').replace(/[_-]+/g, ' ').trim();
1598
+ const label = words.replace(/\b\w/g, c => c.toUpperCase());
1599
+ return `${label || tail} (${namespace})`;
1600
+ }
1601
+ /** Builds a SourceObjectInfo for a route-index-discovered object. */
1602
+ toSourceObjectInfo(obj, fields) {
1603
+ return {
1604
+ ExternalName: obj.Name,
1605
+ ExternalLabel: obj.Label,
1606
+ Description: obj.Description,
1607
+ Fields: fields.map(f => ({
1608
+ Name: f.Name,
1609
+ Label: f.Label,
1610
+ Description: f.Description,
1611
+ SourceType: f.DataType,
1612
+ IsRequired: f.IsRequired,
1613
+ AllowsNull: f.AllowsNull,
1614
+ MaxLength: f.MaxLength ?? null,
1615
+ Precision: f.Precision ?? null,
1616
+ Scale: f.Scale ?? null,
1617
+ DefaultValue: f.DefaultValue ?? null,
1618
+ IsPrimaryKey: f.IsPrimaryKey ?? false,
1619
+ IsUniqueKey: f.IsUniqueKey,
1620
+ IsReadOnly: f.IsReadOnly,
1621
+ IsForeignKey: f.IsForeignKey ?? false,
1622
+ ForeignKeyTarget: f.ForeignKeyTarget ?? null,
1623
+ })),
1624
+ PrimaryKeyFields: fields.filter(f => f.IsPrimaryKey === true).map(f => f.Name),
1625
+ Relationships: [],
1626
+ };
1627
+ }
1628
+ /** Parses the `{ code, message, data:{ status } }` envelope out of a response body. */
1629
+ errorEnvelope(body) {
1630
+ if (!body || typeof body !== 'object' || Array.isArray(body))
1631
+ return null;
1632
+ const b = body;
1633
+ if (typeof b.code !== 'string' && typeof b.message !== 'string')
1634
+ return null;
1635
+ return {
1636
+ code: typeof b.code === 'string' ? b.code : undefined,
1637
+ message: typeof b.message === 'string' ? b.message : undefined,
1638
+ data: (b.data && typeof b.data === 'object' ? b.data : undefined),
1639
+ };
1640
+ }
1641
+ /** The vendor's stable machine code from a response body, when it carries one. */
1642
+ vendorCodeOf(body) {
1643
+ return this.errorEnvelope(body)?.code ?? null;
1644
+ }
1645
+ /**
1646
+ * Reads the HTTP status back out of a failure. `WordPressHTTPError` carries it directly; the base
1647
+ * class's paginated loop raises `HTTP <status> from <url>: <body preview>`, which is our own package's
1648
+ * stable message format.
1649
+ */
1650
+ statusFromError(err) {
1651
+ if (err instanceof WordPressHTTPError)
1652
+ return err.Status;
1653
+ const m = this.errText(err).match(/HTTP (\d{3})\b/);
1654
+ return m ? Number(m[1]) : null;
1655
+ }
1656
+ /**
1657
+ * Recovers the response body from a failure so it can be classified from the WordPress error ENVELOPE
1658
+ * rather than the bare status. The base class's paginated loop raises
1659
+ * `HTTP <status> from <url>: <body preview>`, so the preview is parsed back out; an HTML body (a WAF
1660
+ * block) is returned as the raw string, which is exactly what the classifier keys on.
1661
+ */
1662
+ bodyFromError(err) {
1663
+ const message = this.errText(err);
1664
+ const jsonAt = message.indexOf('{');
1665
+ if (jsonAt >= 0) {
1666
+ const slice = message.slice(jsonAt);
1667
+ try {
1668
+ return JSON.parse(slice);
1669
+ }
1670
+ catch {
1671
+ return slice;
1672
+ }
1673
+ }
1674
+ const htmlAt = message.search(/<\s*(!doctype|html)/i);
1675
+ return htmlAt >= 0 ? message.slice(htmlAt) : message;
1676
+ }
1677
+ /** Best-effort header extraction from an arbitrary thrown value (for ExtractRetryAfterMs). */
1678
+ headersFromUnknownError(error) {
1679
+ if (!error || typeof error !== 'object')
1680
+ return undefined;
1681
+ const e = error;
1682
+ const source = (e.response && typeof e.response === 'object' ? e.response : e);
1683
+ const headers = source.headers ?? source.Headers;
1684
+ if (headers && typeof headers === 'object')
1685
+ return headers;
1686
+ return undefined;
1687
+ }
1688
+ /** Parses a JSON string into a plain object; null for absent/invalid/non-object input. */
1689
+ parseJsonObject(json) {
1690
+ if (!json || typeof json !== 'string')
1691
+ return null;
1692
+ try {
1693
+ const parsed = JSON.parse(json);
1694
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
1695
+ ? parsed
1696
+ : null;
1697
+ }
1698
+ catch {
1699
+ return null;
1700
+ }
1701
+ }
1702
+ /** First present, non-empty string among the given keys. */
1703
+ firstString(obj, keys) {
1704
+ for (const k of keys) {
1705
+ const v = obj[k];
1706
+ if (typeof v === 'string' && v.length > 0)
1707
+ return v;
1708
+ if (typeof v === 'number' && Number.isFinite(v))
1709
+ return String(v);
1710
+ }
1711
+ return undefined;
1712
+ }
1713
+ /** Coerces a header/metadata value to a positive integer, or null. */
1714
+ toPositiveInt(value) {
1715
+ if (value == null)
1716
+ return null;
1717
+ const n = typeof value === 'number' ? value : Number(String(value).trim());
1718
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
1719
+ }
1720
+ /** Path component of a URL (no query), tolerant of a non-absolute input. */
1721
+ pathOf(url) {
1722
+ try {
1723
+ return new URL(url).pathname;
1724
+ }
1725
+ catch {
1726
+ return this.splitQuery(url)[0];
1727
+ }
1728
+ }
1729
+ /** Splits a URL into `[beforeQuery, query]`. */
1730
+ splitQuery(url) {
1731
+ const i = url.indexOf('?');
1732
+ return i < 0 ? [url, ''] : [url.slice(0, i), url.slice(i + 1)];
1733
+ }
1734
+ /** Whether the URL already carries a query param of this name (case-insensitive). */
1735
+ hasQueryParam(url, name) {
1736
+ const [, query] = this.splitQuery(url);
1737
+ if (!query)
1738
+ return false;
1739
+ const lower = name.toLowerCase();
1740
+ return query.split('&').some(pair => decodeURIComponent(pair.split('=')[0] ?? '').toLowerCase() === lower);
1741
+ }
1742
+ /** Appends a query param (URL-encoded), preserving anything already present. */
1743
+ withQueryParam(url, name, value) {
1744
+ const sep = url.includes('?') ? '&' : '?';
1745
+ return `${url}${sep}${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
1746
+ }
1747
+ /** Removes every occurrence of a query param from a URL. */
1748
+ stripQueryParam(url, name) {
1749
+ const [before, query] = this.splitQuery(url);
1750
+ if (!query)
1751
+ return url;
1752
+ const lower = name.toLowerCase();
1753
+ const kept = query.split('&').filter(pair => decodeURIComponent(pair.split('=')[0] ?? '').toLowerCase() !== lower);
1754
+ return kept.length > 0 ? `${before}?${kept.join('&')}` : before;
1755
+ }
1756
+ /** Logs a capability/diagnostic warning exactly once per key, so an honest signal never becomes noise. */
1757
+ warnOnce(key, message) {
1758
+ if (this.warnedOnce.has(key))
1759
+ return;
1760
+ this.warnedOnce.add(key);
1761
+ console.warn(message);
1762
+ }
1763
+ /** Message text of an arbitrary thrown value. */
1764
+ errText(err) {
1765
+ return err instanceof Error ? err.message : String(err);
1766
+ }
1767
+ };
1768
+ WordPressConnector = __decorate([
1769
+ RegisterClass(BaseIntegrationConnector, 'WordPressConnector')
1770
+ ], WordPressConnector);
1771
+ export { WordPressConnector };
1772
+ /** Non-2xx that must reach the ENGINE with its headers intact (429/503 → the adaptive AIMD bucket). */
1773
+ class WordPressHTTPError extends Error {
1774
+ constructor(message, status, headers, vendorCode) {
1775
+ super(message);
1776
+ this.name = 'WordPressHTTPError';
1777
+ this.Status = status;
1778
+ this.Headers = headers;
1779
+ this.VendorCode = vendorCode;
1780
+ }
1781
+ }
1782
+ //# sourceMappingURL=WordPressConnector.js.map