@respira/wordpress-mcp-server 8.3.4 → 8.3.6
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/CHANGELOG.md +20 -0
- package/README.md +6 -5
- package/TOOL_CATALOG.md +2 -1
- package/dist/__tests__/e2d884cb-put-dropped-post-fallback.test.d.ts +35 -0
- package/dist/__tests__/e2d884cb-put-dropped-post-fallback.test.d.ts.map +1 -0
- package/dist/__tests__/e2d884cb-put-dropped-post-fallback.test.js +362 -0
- package/dist/__tests__/e2d884cb-put-dropped-post-fallback.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +42 -4
- package/dist/server.js.map +1 -1
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/wordpress-client.d.ts +78 -9
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +247 -108
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +2 -2
- package/skills/html-to-breakdance/README.md +66 -0
- package/skills/html-to-breakdance/SKILL.md +268 -0
- package/skills/html-to-breakdance/metadata.json +51 -0
- package/tool-capabilities.json +16 -4
package/dist/wordpress-client.js
CHANGED
|
@@ -163,24 +163,75 @@ export function isReplaySafeRetry(args) {
|
|
|
163
163
|
* Reported, root-caused and verified live against a real write by O.Q.
|
|
164
164
|
*/
|
|
165
165
|
const PUT_FALLBACK_STATUSES = new Set([403, 404, 405]);
|
|
166
|
+
/**
|
|
167
|
+
* Header the fallback POST carries so the plugin can dedupe it against the PUT
|
|
168
|
+
* it is replacing.
|
|
169
|
+
*
|
|
170
|
+
* The plugin's idempotency cache key and request fingerprint both include the
|
|
171
|
+
* HTTP method (`class-respira-mcp-reliability.php`, `cache_key()` /
|
|
172
|
+
* `request_fingerprint()`). Without this header a PUT that DID reach PHP and
|
|
173
|
+
* executed, and whose response was then dropped on the way back, would land in
|
|
174
|
+
* a different dedupe bucket than the fallback POST — and `update_post` is not
|
|
175
|
+
* idempotent in every branch (the duplicate-first path mints a draft duplicate
|
|
176
|
+
* on an original post). The plugin keys on the declared original method when
|
|
177
|
+
* this header is present, so PUT-then-fallback-POST with one Idempotency-Key
|
|
178
|
+
* dedupes to exactly one execution.
|
|
179
|
+
*/
|
|
180
|
+
export const ORIGINAL_METHOD_HEADER = 'X-Respira-Original-Method';
|
|
181
|
+
/**
|
|
182
|
+
* Transport failures that mean "the PUT never came back", as opposed to "the
|
|
183
|
+
* host is not there at all".
|
|
184
|
+
*
|
|
185
|
+
* A strict subset of RETRYABLE_CONNECTION_CODES on purpose. ECONNREFUSED,
|
|
186
|
+
* ENOTFOUND, EHOSTUNREACH and friends say nothing reached a listening socket,
|
|
187
|
+
* so the HTTP verb cannot be the discriminator and a fallback POST would only
|
|
188
|
+
* burn a second timeout. These three are the shapes a verb-level drop takes:
|
|
189
|
+
* the client-side timeout firing (ECONNABORTED, which is what axios raises on
|
|
190
|
+
* `timeout`), the OS timing the connection out (ETIMEDOUT), or the edge
|
|
191
|
+
* closing it (ECONNRESET).
|
|
192
|
+
*/
|
|
193
|
+
const PUT_FALLBACK_CONNECTION_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET']);
|
|
166
194
|
/**
|
|
167
195
|
* Should this failed PUT be retried as a POST?
|
|
168
196
|
*
|
|
169
|
-
*
|
|
170
|
-
* this fix useless. The response interceptor rewrites a 403 that did not come
|
|
171
|
-
* from WordPress into a friendly coded Error and does NOT carry `response`
|
|
172
|
-
* across, so a plain `error.response?.status === 403` check never fires on the
|
|
173
|
-
* exact case this exists for. Verified live against a host shim returning 403
|
|
174
|
-
* on PUT: the status-only check still failed the write.
|
|
197
|
+
* Three shapes have to be recognised.
|
|
175
198
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
199
|
+
* 1. A status in PUT_FALLBACK_STATUSES.
|
|
200
|
+
*
|
|
201
|
+
* 2. `http_403_non_wordpress`. The response interceptor rewrites a 403 that did
|
|
202
|
+
* not come from WordPress into a friendly coded Error and does NOT carry
|
|
203
|
+
* `response` across, so a plain `error.response?.status === 403` check never
|
|
204
|
+
* fires on the exact case this exists for — which is what made the first
|
|
205
|
+
* attempt at this fix useless. A 403 raised BY WordPress is a real
|
|
206
|
+
* permission decision and is deliberately still not retried.
|
|
207
|
+
*
|
|
208
|
+
* 3. No response at all, plus a connection code that means the request was
|
|
209
|
+
* dropped rather than answered. This is the third way the same host
|
|
210
|
+
* behaviour arrives, and it was the one gap left: a host can DROP PUT
|
|
211
|
+
* instead of rejecting it, holding the connection until the connector's own
|
|
212
|
+
* timeout fires. There is no status, so (1) never matched; the error is
|
|
213
|
+
* rebuilt by handleError as `respira_write_outcome_unknown`, so (2) never
|
|
214
|
+
* matched either, and the fallback that would have fixed the write was never
|
|
215
|
+
* reached. `respira_update_post` then hung forever while `respira_read_post`
|
|
216
|
+
* (GET) and `respira_create_post_duplicate` (POST) worked, because the verb
|
|
217
|
+
* was the only difference between them. Reported by O.Q. on
|
|
218
|
+
* linvestisseurantifragile.com (Apache), 3/3 reproducible on two posts;
|
|
219
|
+
* ticket e2d884cb. Same host, one behaviour further on, as ticket 2b3a706b.
|
|
220
|
+
*
|
|
221
|
+
* The retry is bounded to exactly one POST at every call site: no loop, no
|
|
222
|
+
* second PUT. The fallback POST reuses the PUT's Idempotency-Key and declares
|
|
223
|
+
* ORIGINAL_METHOD_HEADER, so if the dropped PUT had in fact executed
|
|
224
|
+
* server-side the plugin replays the stored response instead of running the
|
|
225
|
+
* write a second time.
|
|
179
226
|
*/
|
|
180
227
|
export function isRetryableAsPost(error) {
|
|
181
228
|
if (PUT_FALLBACK_STATUSES.has(error?.response?.status))
|
|
182
229
|
return true;
|
|
183
|
-
|
|
230
|
+
if (error?.name === 'http_403_non_wordpress')
|
|
231
|
+
return true;
|
|
232
|
+
if (error?.response)
|
|
233
|
+
return false;
|
|
234
|
+
return PUT_FALLBACK_CONNECTION_CODES.has(String(error?.code || '').toUpperCase());
|
|
184
235
|
}
|
|
185
236
|
/**
|
|
186
237
|
* Classify a failed v2 status probe.
|
|
@@ -979,6 +1030,52 @@ export class WordPressClient {
|
|
|
979
1030
|
getVersionWarning() {
|
|
980
1031
|
return this.versionWarning;
|
|
981
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Send a Respira write as PUT, falling back to POST once if the PUT is
|
|
1035
|
+
* rejected or dropped by something in front of WordPress.
|
|
1036
|
+
*
|
|
1037
|
+
* Every PUT write in this client goes through here so the three properties
|
|
1038
|
+
* that make the fallback safe hold in one place rather than at seven call
|
|
1039
|
+
* sites:
|
|
1040
|
+
*
|
|
1041
|
+
* - **Exactly one POST attempt, and never a second PUT.** No loop. The
|
|
1042
|
+
* response interceptor's own retry ladder (`isReplaySafeRetry`) refuses to
|
|
1043
|
+
* repeat unsafe methods on a connection failure, so this does not stack on
|
|
1044
|
+
* top of it and a single tool call cannot fan out into many requests.
|
|
1045
|
+
* - **One Idempotency-Key for both attempts.** The key is minted here rather
|
|
1046
|
+
* than by the request interceptor, which would mint a fresh one per
|
|
1047
|
+
* request and put the two attempts in different dedupe buckets.
|
|
1048
|
+
* - **The POST declares ORIGINAL_METHOD_HEADER: PUT.** The plugin keys its
|
|
1049
|
+
* idempotency cache and fingerprint on the declared original method, so a
|
|
1050
|
+
* PUT that reached PHP and executed before its response was dropped is
|
|
1051
|
+
* replayed from cache instead of running a second time. That matters
|
|
1052
|
+
* because `update_post` is not idempotent in every branch: on an original
|
|
1053
|
+
* post the duplicate-first path mints a draft duplicate.
|
|
1054
|
+
*
|
|
1055
|
+
* `forceWriteMethod: 'post'` on the site config skips the PUT entirely, for
|
|
1056
|
+
* hosts already known to drop it (support can set it without a plugin
|
|
1057
|
+
* change). Ticket e2d884cb.
|
|
1058
|
+
*/
|
|
1059
|
+
async putWithPostFallback(client, url, data) {
|
|
1060
|
+
const callId = randomUUID();
|
|
1061
|
+
const firstAttempt = { headers: { 'Idempotency-Key': callId } };
|
|
1062
|
+
const fallbackAttempt = {
|
|
1063
|
+
headers: { 'Idempotency-Key': callId, [ORIGINAL_METHOD_HEADER]: 'PUT' },
|
|
1064
|
+
};
|
|
1065
|
+
if (this.siteConfig.forceWriteMethod === 'post') {
|
|
1066
|
+
// No PUT was sent, so there is no earlier attempt to alias onto: a plain
|
|
1067
|
+
// POST with its own key is the whole request.
|
|
1068
|
+
return client.post(url, data, firstAttempt);
|
|
1069
|
+
}
|
|
1070
|
+
try {
|
|
1071
|
+
return await client.put(url, data, firstAttempt);
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
if (!isRetryableAsPost(error))
|
|
1075
|
+
throw error;
|
|
1076
|
+
return client.post(url, data, fallbackAttempt);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
982
1079
|
/**
|
|
983
1080
|
* Generic v1 REST caller for endpoints under /wp-json/respira/v1/.
|
|
984
1081
|
* Mirror of callRestV2 but for the existing v1 surface — useful for new tools
|
|
@@ -991,17 +1088,8 @@ export class WordPressClient {
|
|
|
991
1088
|
*/
|
|
992
1089
|
async callRestV1(method, path, data) {
|
|
993
1090
|
if (method === 'PUT') {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
return response.data;
|
|
997
|
-
}
|
|
998
|
-
catch (error) {
|
|
999
|
-
if (isRetryableAsPost(error)) {
|
|
1000
|
-
const response = await this.client.post(path, data);
|
|
1001
|
-
return response.data;
|
|
1002
|
-
}
|
|
1003
|
-
throw error;
|
|
1004
|
-
}
|
|
1091
|
+
const response = await this.putWithPostFallback(this.client, path, data);
|
|
1092
|
+
return response.data;
|
|
1005
1093
|
}
|
|
1006
1094
|
const response = method === 'GET'
|
|
1007
1095
|
? await this.client.get(path, { params: data })
|
|
@@ -1041,17 +1129,8 @@ export class WordPressClient {
|
|
|
1041
1129
|
await this.ensureV2();
|
|
1042
1130
|
const url = `/wp-json/respira/v2${path}`;
|
|
1043
1131
|
if (method === 'PUT') {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
return response.data;
|
|
1047
|
-
}
|
|
1048
|
-
catch (error) {
|
|
1049
|
-
if (isRetryableAsPost(error)) {
|
|
1050
|
-
const response = await this.rootClient.post(url, data);
|
|
1051
|
-
return response.data;
|
|
1052
|
-
}
|
|
1053
|
-
throw error;
|
|
1054
|
-
}
|
|
1132
|
+
const response = await this.putWithPostFallback(this.rootClient, url, data);
|
|
1133
|
+
return response.data;
|
|
1055
1134
|
}
|
|
1056
1135
|
const response = method === 'GET'
|
|
1057
1136
|
? await this.rootClient.get(url, { params: data })
|
|
@@ -1388,6 +1467,14 @@ export class WordPressClient {
|
|
|
1388
1467
|
netError.name = errorName;
|
|
1389
1468
|
if (isAmbiguousWrite)
|
|
1390
1469
|
netError.name = 'respira_write_outcome_unknown';
|
|
1470
|
+
// Carry the transport code across the rewrite. Without this the original
|
|
1471
|
+
// ECONNABORTED/ETIMEDOUT/ECONNRESET is the one thing this branch throws
|
|
1472
|
+
// away, and for a write `errorName` is then overwritten with the generic
|
|
1473
|
+
// `respira_write_outcome_unknown` — so a dropped PUT arrived at the
|
|
1474
|
+
// PUT->POST fallback with no response, no status and no code, and looked
|
|
1475
|
+
// exactly like an unrecoverable failure. Ticket e2d884cb.
|
|
1476
|
+
if (code)
|
|
1477
|
+
netError.code = code;
|
|
1391
1478
|
return netError;
|
|
1392
1479
|
}
|
|
1393
1480
|
return this.codedError(`Unknown error: ${error.message}`, 'unknown_error');
|
|
@@ -1721,33 +1808,9 @@ export class WordPressClient {
|
|
|
1721
1808
|
}
|
|
1722
1809
|
const version = await this.getApiVersion();
|
|
1723
1810
|
// Try PUT first, fall back to POST for compatibility.
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
response = await this.rootClient.put(`/wp-json/respira/v2/pages/${id}`, payload);
|
|
1728
|
-
}
|
|
1729
|
-
catch (error) {
|
|
1730
|
-
if (isRetryableAsPost(error)) {
|
|
1731
|
-
response = await this.rootClient.post(`/wp-json/respira/v2/pages/${id}`, payload);
|
|
1732
|
-
}
|
|
1733
|
-
else {
|
|
1734
|
-
throw error;
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
else {
|
|
1739
|
-
try {
|
|
1740
|
-
response = await this.client.put(`/pages/${id}`, payload);
|
|
1741
|
-
}
|
|
1742
|
-
catch (error) {
|
|
1743
|
-
if (isRetryableAsPost(error)) {
|
|
1744
|
-
response = await this.client.post(`/pages/${id}`, payload);
|
|
1745
|
-
}
|
|
1746
|
-
else {
|
|
1747
|
-
throw error;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1811
|
+
const response = version === 'v2'
|
|
1812
|
+
? await this.putWithPostFallback(this.rootClient, `/wp-json/respira/v2/pages/${id}`, payload)
|
|
1813
|
+
: await this.putWithPostFallback(this.client, `/pages/${id}`, payload);
|
|
1751
1814
|
// Check if duplicate was created and format message accordingly
|
|
1752
1815
|
if (response.data.duplicate_created) {
|
|
1753
1816
|
const duplicateId = response.data.duplicate_id;
|
|
@@ -1977,33 +2040,9 @@ export class WordPressClient {
|
|
|
1977
2040
|
delete payload.appendTerms;
|
|
1978
2041
|
}
|
|
1979
2042
|
const version = await this.getApiVersion();
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
response = await this.rootClient.put(`/wp-json/respira/v2/posts/${id}`, payload);
|
|
1984
|
-
}
|
|
1985
|
-
catch (error) {
|
|
1986
|
-
if (isRetryableAsPost(error)) {
|
|
1987
|
-
response = await this.rootClient.post(`/wp-json/respira/v2/posts/${id}`, payload);
|
|
1988
|
-
}
|
|
1989
|
-
else {
|
|
1990
|
-
throw error;
|
|
1991
|
-
}
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
else {
|
|
1995
|
-
try {
|
|
1996
|
-
response = await this.client.put(`/posts/${id}`, payload);
|
|
1997
|
-
}
|
|
1998
|
-
catch (error) {
|
|
1999
|
-
if (isRetryableAsPost(error)) {
|
|
2000
|
-
response = await this.client.post(`/posts/${id}`, payload);
|
|
2001
|
-
}
|
|
2002
|
-
else {
|
|
2003
|
-
throw error;
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2043
|
+
const response = version === 'v2'
|
|
2044
|
+
? await this.putWithPostFallback(this.rootClient, `/wp-json/respira/v2/posts/${id}`, payload)
|
|
2045
|
+
: await this.putWithPostFallback(this.client, `/posts/${id}`, payload);
|
|
2007
2046
|
// Check if duplicate was created and format message accordingly
|
|
2008
2047
|
if (response.data.duplicate_created) {
|
|
2009
2048
|
const duplicateId = response.data.duplicate_id;
|
|
@@ -3009,6 +3048,19 @@ export class WordPressClient {
|
|
|
3009
3048
|
// signal that the edge is intercepting writes. Pretty path first; if the
|
|
3010
3049
|
// rest_route fallback is in play, the auto-fallback covers writes too.
|
|
3011
3050
|
await probe('respira_ping_options', 'OPTIONS', `${baseUrl}/wp-json/respira/v1/ping`);
|
|
3051
|
+
// Probe 6 (ticket e2d884cb): the actual verb. OPTIONS alone was never
|
|
3052
|
+
// enough. A host can allow OPTIONS and still drop PUT, and — the reason
|
|
3053
|
+
// this probe exists — a host that drops PUT does not answer it at all, so
|
|
3054
|
+
// there is no status to read and the old `typeof status === 'number'`
|
|
3055
|
+
// guard silently graded the site as fine. That is exactly what
|
|
3056
|
+
// O.Q.'s own diagnose_connection run reported while every
|
|
3057
|
+
// respira_update_post against linvestisseurantifragile.com hung forever.
|
|
3058
|
+
//
|
|
3059
|
+
// Safe to send: /respira/v1/ping registers no EDITABLE handler, so a PUT
|
|
3060
|
+
// that reaches WordPress comes back as a JSON 404 `rest_no_route` without
|
|
3061
|
+
// touching anything. What is being measured is whether the verb reaches
|
|
3062
|
+
// PHP at all, not whether the route exists.
|
|
3063
|
+
await probe('respira_ping_put', 'PUT', `${baseUrl}/wp-json/respira/v1/ping`);
|
|
3012
3064
|
// Plugin diagnostic — go through the standard client so we share auth and
|
|
3013
3065
|
// pick up errors via the existing handler if the endpoint isn't routed.
|
|
3014
3066
|
let pluginDiagnostic = null;
|
|
@@ -3074,17 +3126,83 @@ export class WordPressClient {
|
|
|
3074
3126
|
const optionsBlocked = !!optionsProbe &&
|
|
3075
3127
|
typeof optionsProbe.status === 'number' &&
|
|
3076
3128
|
optionsProbe.status >= 400;
|
|
3077
|
-
|
|
3078
|
-
|
|
3129
|
+
// Ticket e2d884cb. A probe that never came back has no `status` field, so
|
|
3130
|
+
// `typeof status === 'number'` is false and the old single boolean reported
|
|
3131
|
+
// write_method_blocked: false — the diagnostic read green precisely when
|
|
3132
|
+
// the site was broken. A dropped request is not a passing request. Three
|
|
3133
|
+
// states now: proven blocked, proven reachable, and unknown.
|
|
3134
|
+
const putProbe = probes.find((p) => p.label === 'respira_ping_put');
|
|
3135
|
+
const probeDropped = (p) => !!p &&
|
|
3136
|
+
typeof p.status !== 'number' &&
|
|
3137
|
+
PUT_FALLBACK_CONNECTION_CODES.has(String(p.code || '').toUpperCase());
|
|
3138
|
+
const putDropped = probeDropped(putProbe);
|
|
3139
|
+
const optionsDropped = probeDropped(optionsProbe);
|
|
3140
|
+
// A JSON answer means the verb reached WordPress, whatever the status:
|
|
3141
|
+
// /ping has no PUT handler, so `rest_no_route` 404 is the healthy result.
|
|
3142
|
+
// An HTML 4xx/5xx is something in front of WordPress answering instead.
|
|
3143
|
+
const putReachedWordPress = !!putProbe && typeof putProbe.status === 'number' && putProbe.looks_like_html === false;
|
|
3144
|
+
const putBlockedAtEdge = !!putProbe && typeof putProbe.status === 'number' && putProbe.status >= 400 && !putReachedWordPress;
|
|
3145
|
+
const writeMethodVerdict = (() => {
|
|
3146
|
+
// If plain GET is not healthy, nothing can be concluded about writes
|
|
3147
|
+
// specifically — the whole connection is the problem.
|
|
3148
|
+
if (!getOk)
|
|
3149
|
+
return 'unknown';
|
|
3150
|
+
if (optionsBlocked || putBlockedAtEdge)
|
|
3151
|
+
return 'blocked';
|
|
3152
|
+
if (putDropped || optionsDropped)
|
|
3153
|
+
return 'unknown';
|
|
3154
|
+
if (putReachedWordPress)
|
|
3155
|
+
return 'not_blocked';
|
|
3156
|
+
return 'unknown';
|
|
3157
|
+
})();
|
|
3158
|
+
// Kept as a tri-state boolean rather than a string so an existing truthiness
|
|
3159
|
+
// check still means "proven blocked" and never accidentally fires on the
|
|
3160
|
+
// unknown case. null = could not tell.
|
|
3161
|
+
const writeMethodBlocked = writeMethodVerdict === 'blocked' ? true : writeMethodVerdict === 'not_blocked' ? false : null;
|
|
3162
|
+
const forceWriteMethodHint = ' Unblock this connection immediately without touching the host: add ' +
|
|
3163
|
+
`"forceWriteMethod": "post" to the site object in ${CONFIG_FILE}. The Respira plugin ` +
|
|
3164
|
+
'registers the same handler on PUT and POST for every updatable resource, so POST does ' +
|
|
3165
|
+
'identical work and the connector stops sending PUT at all. Fixing the host is still the ' +
|
|
3166
|
+
'better end state, this just stops the bleeding.';
|
|
3167
|
+
if (writeMethodVerdict === 'unknown' && (putDropped || optionsDropped)) {
|
|
3168
|
+
const droppedLabel = putDropped ? 'PUT' : 'OPTIONS';
|
|
3169
|
+
const droppedCode = String((putDropped ? putProbe : optionsProbe)?.code || 'no response');
|
|
3170
|
+
recommendations.push(`${droppedLabel} /wp-json/respira/v1/ping was DROPPED (${droppedCode}) while GET returned 2xx. ` +
|
|
3171
|
+
'The request was never answered, not even with an error, so this diagnostic cannot prove the ' +
|
|
3172
|
+
'verb is blocked and will not claim it is fine either. A silent drop of PUT is the known ' +
|
|
3173
|
+
'fingerprint of an Apache `<LimitExcept GET POST>` block, a ModSecurity rule, or a WAF that ' +
|
|
3174
|
+
'discards non-GET methods instead of rejecting them. It is also what a plain overloaded origin ' +
|
|
3175
|
+
'looks like, so check both: retry with a longer probe_timeout_ms, and ask the host whether ' +
|
|
3176
|
+
'PUT/PATCH/DELETE are allowed for /wp-json/respira/*. Symptom to expect meanwhile: reads and ' +
|
|
3177
|
+
'create/duplicate work while every update hangs until it times out, because those are the only ' +
|
|
3178
|
+
'calls that use PUT.' +
|
|
3179
|
+
forceWriteMethodHint);
|
|
3180
|
+
}
|
|
3181
|
+
if (writeMethodVerdict === 'unknown' && !putDropped && !optionsDropped && getOk) {
|
|
3182
|
+
recommendations.push('Write-method state is unknown: the PUT probe did not come back with a readable answer. ' +
|
|
3183
|
+
'Re-run with a larger probe_timeout_ms before drawing any conclusion about writes.');
|
|
3184
|
+
}
|
|
3185
|
+
if (writeMethodVerdict === 'blocked') {
|
|
3079
3186
|
const status = optionsProbe?.status ?? '4xx';
|
|
3080
|
-
if (
|
|
3081
|
-
recommendations.push(`
|
|
3187
|
+
if (putBlockedAtEdge && !optionsBlocked) {
|
|
3188
|
+
recommendations.push(`PUT /wp-json/respira/v1/ping returned ${putProbe?.status} from something that is not WordPress ` +
|
|
3189
|
+
'(the body was not JSON) while GET returned 2xx and OPTIONS was allowed. The edge blocks the PUT ' +
|
|
3190
|
+
'verb specifically, so every Respira update fails while reads and create/duplicate succeed. ' +
|
|
3191
|
+
'Allow PUT/PATCH/DELETE for /wp-json/respira/* on the layer in front of WordPress ' +
|
|
3192
|
+
'(Apache <LimitExcept>, ModSecurity, CDN or WAF rule).' +
|
|
3193
|
+
forceWriteMethodHint);
|
|
3082
3194
|
}
|
|
3083
|
-
|
|
3084
|
-
recommendations.push(`OPTIONS /wp-json/respira/v1/ping returned ${status} while GET returned 2xx
|
|
3195
|
+
if (optionsBlocked && edgeLayerDetected === 'cloudflare') {
|
|
3196
|
+
recommendations.push(`OPTIONS /wp-json/respira/v1/ping returned ${status} while GET returned 2xx, and Cloudflare is in front of the site (cf-ray header present). The edge is blocking non-GET methods before the request reaches WordPress — every Respira write (update_page, update_element, inject_builder_content, delete_page) will fail at the edge with no plugin-side trace. Append \`and not (http.request.uri.path contains "/wp-json/respira/")\` to the CF WAF / Custom Rule that matches non-GET methods (commonly named "Bad Bot - Action Block" or similar), or add a Skip rule for /wp-json/respira/* that bypasses the Managed Rules + Custom Rules phases.` +
|
|
3197
|
+
forceWriteMethodHint);
|
|
3085
3198
|
}
|
|
3086
|
-
else {
|
|
3087
|
-
recommendations.push(`OPTIONS /wp-json/respira/v1/ping returned ${status} while GET returned 2xx.
|
|
3199
|
+
else if (optionsBlocked && edgeLayerDetected === 'wordfence') {
|
|
3200
|
+
recommendations.push(`OPTIONS /wp-json/respira/v1/ping returned ${status} while GET returned 2xx. Wordfence is in front of the site and is blocking non-GET methods to Respira routes. In Wordfence > Firewall > All Firewall Options > Whitelisted URLs, add /wp-json/respira/.* and re-run.` +
|
|
3201
|
+
forceWriteMethodHint);
|
|
3202
|
+
}
|
|
3203
|
+
else if (optionsBlocked) {
|
|
3204
|
+
recommendations.push(`OPTIONS /wp-json/respira/v1/ping returned ${status} while GET returned 2xx. An edge layer or origin firewall is blocking non-GET methods to Respira routes — every Respira write will fail at the edge with no plugin-side trace. Identify the layer (check CDN, WAF, .htaccess <LimitExcept GET POST>, ModSecurity rules) and allow PUT/PATCH/DELETE/OPTIONS for /wp-json/respira/*.` +
|
|
3205
|
+
forceWriteMethodHint);
|
|
3088
3206
|
}
|
|
3089
3207
|
}
|
|
3090
3208
|
if (restRouteFallbackWorked) {
|
|
@@ -3113,7 +3231,21 @@ export class WordPressClient {
|
|
|
3113
3231
|
rest_route_fallback_worked: restRouteFallbackWorked,
|
|
3114
3232
|
rest_route_fallback_active: this.useRestRouteFallback,
|
|
3115
3233
|
force_rest_route_configured: this.siteConfig.forceRestRoute === true,
|
|
3234
|
+
force_write_method_configured: this.siteConfig.forceWriteMethod ?? null,
|
|
3235
|
+
// true = proven blocked, false = proven reachable, null = could not tell
|
|
3236
|
+
// (the probe was dropped rather than answered). Read
|
|
3237
|
+
// `write_method_verdict` for the named state; never treat null as false.
|
|
3116
3238
|
write_method_blocked: writeMethodBlocked,
|
|
3239
|
+
write_method_verdict: writeMethodVerdict,
|
|
3240
|
+
write_method_probe: {
|
|
3241
|
+
options_status: optionsProbe?.status ?? null,
|
|
3242
|
+
options_dropped: optionsDropped,
|
|
3243
|
+
put_status: putProbe?.status ?? null,
|
|
3244
|
+
put_dropped: putDropped,
|
|
3245
|
+
put_reached_wordpress: putReachedWordPress,
|
|
3246
|
+
put_error: putProbe?.error ?? null,
|
|
3247
|
+
put_error_code: putProbe?.code ?? null,
|
|
3248
|
+
},
|
|
3117
3249
|
target_post_id: opts.post_id ?? null,
|
|
3118
3250
|
recommendations,
|
|
3119
3251
|
};
|
|
@@ -3452,30 +3584,19 @@ export class WordPressClient {
|
|
|
3452
3584
|
const version = await this.getApiVersion();
|
|
3453
3585
|
if (version === 'v2') {
|
|
3454
3586
|
try {
|
|
3455
|
-
|
|
3456
|
-
try {
|
|
3457
|
-
response = await this.rootClient.put(`/wp-json/respira/v2/post-types/${type}/posts/${id}`, payload);
|
|
3458
|
-
}
|
|
3459
|
-
catch (error) {
|
|
3460
|
-
if (isRetryableAsPost(error)) {
|
|
3461
|
-
response = await this.rootClient.post(`/wp-json/respira/v2/post-types/${type}/posts/${id}`, payload);
|
|
3462
|
-
}
|
|
3463
|
-
else {
|
|
3464
|
-
throw error;
|
|
3465
|
-
}
|
|
3466
|
-
}
|
|
3587
|
+
const response = await this.putWithPostFallback(this.rootClient, `/wp-json/respira/v2/post-types/${type}/posts/${id}`, payload);
|
|
3467
3588
|
return response.data;
|
|
3468
3589
|
}
|
|
3469
3590
|
catch (e) {
|
|
3470
3591
|
// Fallback to v1 if v2 returns auth/permission error.
|
|
3471
3592
|
if (e.response && (e.response.status === 401 || e.response.status === 403)) {
|
|
3472
|
-
const response = await this.client
|
|
3593
|
+
const response = await this.putWithPostFallback(this.client, `/post-types/${type}/posts/${id}`, payload);
|
|
3473
3594
|
return response.data;
|
|
3474
3595
|
}
|
|
3475
3596
|
throw e;
|
|
3476
3597
|
}
|
|
3477
3598
|
}
|
|
3478
|
-
const response = await this.client
|
|
3599
|
+
const response = await this.putWithPostFallback(this.client, `/post-types/${type}/posts/${id}`, payload);
|
|
3479
3600
|
return response.data;
|
|
3480
3601
|
}
|
|
3481
3602
|
async deleteCustomPost(type, id, opts) {
|
|
@@ -3505,6 +3626,24 @@ export class WordPressClient {
|
|
|
3505
3626
|
});
|
|
3506
3627
|
return response.data;
|
|
3507
3628
|
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Purge page and object caches.
|
|
3631
|
+
*
|
|
3632
|
+
* post_id purges one page; omitting it purges the whole site. The response
|
|
3633
|
+
* names every caching layer the plugin knows, both the ones it purged and
|
|
3634
|
+
* the ones it looked for and did not find.
|
|
3635
|
+
*/
|
|
3636
|
+
async purgeCache(postId, scope) {
|
|
3637
|
+
const body = {};
|
|
3638
|
+
if (typeof postId === 'number' && postId > 0) {
|
|
3639
|
+
body.post_id = postId;
|
|
3640
|
+
}
|
|
3641
|
+
if (scope) {
|
|
3642
|
+
body.scope = scope;
|
|
3643
|
+
}
|
|
3644
|
+
const response = await this.client.post('/cache/purge', body);
|
|
3645
|
+
return response.data;
|
|
3646
|
+
}
|
|
3508
3647
|
// Media enhancements
|
|
3509
3648
|
async getMedia(id) {
|
|
3510
3649
|
const response = await this.client.get(`/media/${id}`);
|