@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.
- package/dist/WordPressConnector.d.ts +499 -0
- package/dist/WordPressConnector.js +1782 -0
- package/dist/WordPressConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
|
@@ -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
|