@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.
@@ -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
- * Two shapes have to be recognised, which is what made the first attempt at
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
- * `http_403_non_wordpress` is deliberately the only coded name accepted. A 403
177
- * raised BY WordPress is a real permission decision and retrying it as a POST
178
- * would just fail again more slowly.
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
- return error?.name === 'http_403_non_wordpress';
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
- try {
995
- const response = await this.client.put(path, data);
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
- try {
1045
- const response = await this.rootClient.put(url, data);
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
- let response;
1725
- if (version === 'v2') {
1726
- try {
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
- let response;
1981
- if (version === 'v2') {
1982
- try {
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
- const writeMethodBlocked = getOk && optionsBlocked;
3078
- if (writeMethodBlocked) {
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 (edgeLayerDetected === 'cloudflare') {
3081
- 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.`);
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
- else if (edgeLayerDetected === 'wordfence') {
3084
- 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.`);
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. 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/*.`);
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
- let response;
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.put(`/post-types/${type}/posts/${id}`, payload);
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.put(`/post-types/${type}/posts/${id}`, payload);
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}`);