@respira/wordpress-mcp-server 8.3.19 → 8.3.20

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/TOOL_CATALOG.md +36 -36
  3. package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.d.ts +2 -0
  4. package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.d.ts.map +1 -0
  5. package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js +91 -0
  6. package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js.map +1 -0
  7. package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.d.ts +30 -0
  8. package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.d.ts.map +1 -0
  9. package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.js +162 -0
  10. package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.js.map +1 -0
  11. package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.d.ts +2 -0
  12. package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.d.ts.map +1 -0
  13. package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.js +207 -0
  14. package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.js.map +1 -0
  15. package/dist/__tests__/e105ed25-report-issue-builder-context.test.d.ts +2 -0
  16. package/dist/__tests__/e105ed25-report-issue-builder-context.test.d.ts.map +1 -0
  17. package/dist/__tests__/e105ed25-report-issue-builder-context.test.js +115 -0
  18. package/dist/__tests__/e105ed25-report-issue-builder-context.test.js.map +1 -0
  19. package/dist/__tests__/respira-outage-blamed-on-the-site.test.d.ts +2 -0
  20. package/dist/__tests__/respira-outage-blamed-on-the-site.test.d.ts.map +1 -0
  21. package/dist/__tests__/respira-outage-blamed-on-the-site.test.js +83 -0
  22. package/dist/__tests__/respira-outage-blamed-on-the-site.test.js.map +1 -0
  23. package/dist/__tests__/v2-negotiation-ttl-retry.test.d.ts +2 -0
  24. package/dist/__tests__/v2-negotiation-ttl-retry.test.d.ts.map +1 -0
  25. package/dist/__tests__/v2-negotiation-ttl-retry.test.js +108 -0
  26. package/dist/__tests__/v2-negotiation-ttl-retry.test.js.map +1 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +59 -4
  29. package/dist/config.js.map +1 -1
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +25 -3
  32. package/dist/server.js.map +1 -1
  33. package/dist/wordpress-client.d.ts +77 -1
  34. package/dist/wordpress-client.d.ts.map +1 -1
  35. package/dist/wordpress-client.js +385 -74
  36. package/dist/wordpress-client.js.map +1 -1
  37. package/package.json +2 -2
  38. package/skills/woo-marketing-campaigns/SKILL.md +128 -0
  39. package/skills/woo-pricing-promotions/SKILL.md +119 -0
  40. package/tool-capabilities.json +92 -344
@@ -286,12 +286,62 @@ const PUT_FALLBACK_CONNECTION_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ECO
286
286
  export function isRetryableAsPost(error) {
287
287
  if (PUT_FALLBACK_STATUSES.has(error?.response?.status))
288
288
  return true;
289
- if (error?.name === 'http_403_non_wordpress')
289
+ if (PUT_FALLBACK_STATUSES.has(codedNonWordPressStatus(error)))
290
290
  return true;
291
291
  if (error?.response)
292
292
  return false;
293
293
  return PUT_FALLBACK_CONNECTION_CODES.has(String(error?.code || '').toUpperCase());
294
294
  }
295
+ /**
296
+ * The HTTP status behind a `http_<status>_non_wordpress` coded error, or NaN.
297
+ *
298
+ * `handleError` rebuilds every non-2xx into a fresh `Error` via `codedError`,
299
+ * which does not carry `.response`. So check (1) in `isRetryableAsPost` —
300
+ * `error.response.status` — can only ever fire on an error that has NOT been
301
+ * through the interceptor. Through the real client it never fires, and the
302
+ * hardcoded `error.name === 'http_403_non_wordpress'` check that patched
303
+ * around that covered exactly one of the three statuses in
304
+ * PUT_FALLBACK_STATUSES.
305
+ *
306
+ * That is why ticket 139f7353's host was not rescued by the fallback even
307
+ * where the fallback was already wired in: its edge answers PUT with 405 and
308
+ * an HTML body, which arrives as `http_405_non_wordpress` with no `.response`
309
+ * and no matching name, so the retry was skipped and the tool surfaced the
310
+ * 405. Reading the status back out of the code keeps PUT_FALLBACK_STATUSES
311
+ * the single source of truth instead of growing a second hardcoded list.
312
+ *
313
+ * Deliberately only matches the `_non_wordpress` codes. A 403/404/405 raised
314
+ * BY WordPress arrives with its own WP_Error code stamped on `.name`, and
315
+ * those are real decisions ("you may not edit this", "no such route"), not
316
+ * verb-level routing facts, so they must still not be retried.
317
+ */
318
+ function codedNonWordPressStatus(error) {
319
+ const match = /^http_(\d{3})_non_wordpress$/.exec(String(error?.name || ''));
320
+ return match ? Number(match[1]) : NaN;
321
+ }
322
+ /**
323
+ * Error codes that mean the failure came from the RESPIRA account service, not
324
+ * from the customer's WordPress install.
325
+ *
326
+ * The plugin cannot validate a dashboard site token or an OAuth access token
327
+ * locally: it POSTs to respira.press/api/mcp/site-token/introspect. When that
328
+ * call fails, the plugin still has to answer the agent with something, and the
329
+ * something is a 5xx carrying one of these codes.
330
+ *
331
+ * `respira_invalid_site_token` is on the list for a reason that looks wrong at
332
+ * first. On plugin builds shipped before this fix, ANY introspection failure —
333
+ * including a Respira-side Supabase outage — came back with that code and the
334
+ * upstream HTTP status adopted verbatim. Those builds are in the field. A 500
335
+ * carrying `respira_invalid_site_token` is therefore a Respira outage, not a
336
+ * bad token: a genuinely invalid token comes back 401/403, which classifies as
337
+ * `auth`, not `server`.
338
+ */
339
+ const RESPIRA_ACCOUNT_SERVICE_CODES = new Set([
340
+ 'respira_site_token_validation_failed',
341
+ 'respira_invalid_site_token',
342
+ 'introspection_unavailable',
343
+ 'introspection_failed',
344
+ ]);
295
345
  /**
296
346
  * Classify a failed v2 status probe.
297
347
  *
@@ -308,45 +358,125 @@ export function classifyV2Failure(error) {
308
358
  return { kind: 'auth', status, code, detail };
309
359
  if (status === 404)
310
360
  return { kind: 'missing', status };
361
+ // `code` was being dropped here while the auth branch kept it, so a 5xx that
362
+ // said in plain text "this came from the Respira account service" arrived at
363
+ // describeV2Failure as an anonymous server error and got site-side advice.
311
364
  if (Number.isFinite(status) && status > 0)
312
- return { kind: 'server', status, detail };
365
+ return { kind: 'server', status, code, detail };
313
366
  return { kind: 'network', detail: typeof error?.message === 'string' ? error.message : undefined };
314
367
  }
368
+ /**
369
+ * Default: how long a FAILED v2 negotiation stays cached before the next
370
+ * v2-gated call re-probes instead of reusing the cached failure.
371
+ *
372
+ * A SUCCESSFUL negotiation ('v2') is cached for the life of the
373
+ * WordPressClient — respira/v2 does not appear and disappear on a healthy
374
+ * site, and re-probing it on every call would be pure overhead. A FAILURE is
375
+ * different: it can mean "this site really is v1-only" (worth caching, so a
376
+ * genuinely v1-only site isn't re-probed on every single tool call) or "the
377
+ * host had a transient 500 / hiccup" (must NOT stay cached, or every retry
378
+ * for the rest of the session repeats a stale verdict). The client cannot
379
+ * tell those two cases apart from here, so both get this same short TTL
380
+ * rather than the permanent latch that stranded tickets 7dce1a8a and
381
+ * f3310791 for 15+ minutes: every retry returned the identical cached
382
+ * message with zero new HTTP requests and nothing in the site's server logs.
383
+ *
384
+ * Overridable via RESPIRA_V2_NEGOTIATION_TTL_MS (ms). Not a documented
385
+ * customer-facing setting — it exists so tests don't need to sleep for a
386
+ * full production TTL.
387
+ */
388
+ export const DEFAULT_V2_NEGOTIATION_FAILURE_TTL_MS = 60_000;
389
+ export function v2NegotiationFailureTtlMs(env = process.env) {
390
+ const raw = env.RESPIRA_V2_NEGOTIATION_TTL_MS;
391
+ if (raw === undefined || raw === '')
392
+ return DEFAULT_V2_NEGOTIATION_FAILURE_TTL_MS;
393
+ const parsed = Number(raw);
394
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_V2_NEGOTIATION_FAILURE_TTL_MS;
395
+ }
315
396
  /**
316
397
  * Turn a negotiation failure into something the reader can act on.
317
398
  *
318
399
  * Each branch names what is actually known, and only the `missing` branch
319
400
  * keeps the old list of environmental causes, because that list is only ever
320
401
  * true for a real 404.
402
+ *
403
+ * @param observedAt When set, this failure is being reported from cache
404
+ * rather than from a probe made during this call — pass the timestamp
405
+ * (`Date.now()` epoch ms) the failure was first observed so the reader isn't
406
+ * told about a request that didn't happen. `null`/`undefined` means the probe
407
+ * ran during this call and the result is fresh.
321
408
  */
322
- export function describeV2Failure(failure, siteUrl) {
409
+ export function describeV2Failure(failure, siteUrl, observedAt) {
323
410
  const probe = `${siteUrl}/wp-json/respira/v2/status`;
411
+ let message;
324
412
  if (failure?.kind === 'auth') {
325
- return (`respira/v2 rejected this API key (HTTP ${failure.status}${failure.code ? `, ${failure.code}` : ''}).\n\n` +
326
- (failure.detail ? `The site said: ${failure.detail}\n\n` : '') +
327
- 'The v2 routes exist and are reachable — this is an authentication result, not a missing namespace. ' +
328
- 'Most often the key was deactivated (a lapsed licence disables API keys, and renewing does not ' +
329
- 'currently re-enable them) or it was minted for a different site.\n\n' +
330
- 'To fix: reconnect the site from respira.press/dashboard/sites to mint a fresh key, then restart your MCP client.');
331
- }
332
- if (failure?.kind === 'server') {
333
- return (`respira/v2 returned HTTP ${failure.status} on this site.\n\n` +
334
- (failure.detail ? `The site said: ${failure.detail}\n\n` : '') +
335
- `The namespace is registered but the request failed server-side. Check wp-content/debug.log, then open ${probe}.`);
336
- }
337
- if (failure?.kind === 'network') {
338
- return ('Could not reach respira/v2 on this site.\n\n' +
339
- (failure.detail ? `${failure.detail}\n\n` : '') +
340
- `This is a connectivity failure, not a plugin problem. Confirm the site is up, then open ${probe}.`);
341
- }
342
- // kind === 'missing', or we never got to probe at all.
343
- return ('respira/v2 is not available on this site.\n\n' +
344
- 'Common causes:\n' +
345
- ' A security plugin (Wordfence, Sucuri, iThemes Security) is blocking the /wp-json/respira/v2/ path whitelist that path in the plugin settings.\n' +
346
- ' WordPress permalinks are set to "Plain" go to Settings Permalinks and choose any option except Plain.\n' +
347
- '• A WAF or CDN (e.g. Cloudflare) is blocking requests with the X-Respira-API-Key header — add a WAF exception for /wp-json/respira/.\n' +
348
- '• The Respira plugin is outdated — update to the latest version from your WordPress admin.\n\n' +
349
- `To diagnose: open ${probe} in a browser. If you see a 404 or blocked page, one of the above applies.`);
413
+ message =
414
+ `respira/v2 rejected this API key (HTTP ${failure.status}${failure.code ? `, ${failure.code}` : ''}).\n\n` +
415
+ (failure.detail ? `The site said: ${failure.detail}\n\n` : '') +
416
+ 'The v2 routes exist and are reachable this is an authentication result, not a missing namespace. ' +
417
+ 'Most often the key was deactivated (a lapsed licence disables API keys, and renewing does not ' +
418
+ 'currently re-enable them) or it was minted for a different site.\n\n' +
419
+ 'To fix: reconnect the site from respira.press/dashboard/sites to mint a fresh key, then restart your MCP client.';
420
+ }
421
+ else if (failure?.kind === 'server' && failure.code && RESPIRA_ACCOUNT_SERVICE_CODES.has(failure.code)) {
422
+ // The customer who triggered this fix read "The site said: Could not query
423
+ // the database for the schema cache. Retrying." plus "Check
424
+ // wp-content/debug.log" and spent hours hunting a fault in his own install.
425
+ // That sentence was PostgREST PGRST002 from the Respira Supabase. Say whose
426
+ // fault it is, and never send the reader to their own logs for it.
427
+ message =
428
+ `respira/v2 returned HTTP ${failure.status} on this site (${failure.code}).\n\n` +
429
+ 'This is a Respira account-service problem, not a problem with this WordPress site. ' +
430
+ 'The plugin cannot validate a dashboard or OAuth token on its own, so it asks respira.press; ' +
431
+ 'respira.press did not answer, and the plugin refused the call rather than guess.\n\n' +
432
+ (failure.detail ? `respira.press reported: ${failure.detail}\n\n` : '') +
433
+ 'There is nothing to fix on this site, and nothing about this will appear in its logs. ' +
434
+ 'Retry in a minute. If it persists, contact https://respira.press/support with the time and the code above.';
435
+ }
436
+ else if (failure?.kind === 'server') {
437
+ message =
438
+ `respira/v2 returned HTTP ${failure.status} on this site${failure.code ? ` (${failure.code})` : ''}.\n\n` +
439
+ (failure.detail ? `The site said: ${failure.detail}\n\n` : '') +
440
+ `The namespace is registered but the request failed server-side. Check wp-content/debug.log, then open ${probe}.`;
441
+ }
442
+ else if (failure?.kind === 'network') {
443
+ message =
444
+ 'Could not reach respira/v2 on this site.\n\n' +
445
+ (failure.detail ? `${failure.detail}\n\n` : '') +
446
+ `This is a connectivity failure, not a plugin problem. Confirm the site is up, then open ${probe}.`;
447
+ }
448
+ else {
449
+ // kind === 'missing', or we never got to probe at all.
450
+ message =
451
+ 'respira/v2 is not available on this site.\n\n' +
452
+ 'Common causes:\n' +
453
+ '• A security plugin (Wordfence, Sucuri, iThemes Security) is blocking the /wp-json/respira/v2/ path — whitelist that path in the plugin settings.\n' +
454
+ '• WordPress permalinks are set to "Plain" — go to Settings → Permalinks and choose any option except Plain.\n' +
455
+ '• A WAF or CDN (e.g. Cloudflare) is blocking requests with the X-Respira-API-Key header — add a WAF exception for /wp-json/respira/.\n' +
456
+ '• The Respira plugin is outdated — update to the latest version from your WordPress admin.\n\n' +
457
+ `To diagnose: open ${probe} in a browser. If you see a 404 or blocked page, one of the above applies.`;
458
+ }
459
+ // Ticket 7dce1a8a / f3310791: a transient 500 latched this client to 'v1'
460
+ // for the rest of the session, and every retry repeated this exact message
461
+ // as if it had just re-checked — the plugin's server logs showed nothing
462
+ // because nothing was ever actually resent. A cached failure now says so.
463
+ if (observedAt) {
464
+ const ageMs = Math.max(0, Date.now() - observedAt);
465
+ message +=
466
+ `\n\n(This is a cached result, not a fresh check: v2 was first seen failing at ` +
467
+ `${new Date(observedAt).toISOString()} (${formatFailureAge(ageMs)} ago). ` +
468
+ 'A later call will automatically re-probe once the cache expires.)';
469
+ }
470
+ return message;
471
+ }
472
+ /** Render a cached-failure age for `describeV2Failure`'s honesty note. */
473
+ function formatFailureAge(ms) {
474
+ if (ms < 1000)
475
+ return `${ms}ms`;
476
+ const seconds = ms / 1000;
477
+ if (seconds < 60)
478
+ return `${seconds.toFixed(1)}s`;
479
+ return `${(seconds / 60).toFixed(1)}m`;
350
480
  }
351
481
  /**
352
482
  * Guarantee a delete call answers with SOMETHING an agent can act on.
@@ -443,6 +573,14 @@ export class WordPressClient {
443
573
  * of blaming the namespace for what was actually an auth rejection.
444
574
  */
445
575
  negotiationFailure = null;
576
+ /**
577
+ * `Date.now()` when `negotiationFailure` was set. Drives the TTL that
578
+ * expires a cached FAILURE (see `v2NegotiationFailureTtlMs`) and lets
579
+ * `describeV2Failure` say when a cached result was first observed instead
580
+ * of reading as a fresh check. Always `null` when `negotiatedApiVersion`
581
+ * is `'v2'` or the client hasn't negotiated yet.
582
+ */
583
+ negotiationFailureAt = null;
446
584
  // Version compatibility check — populated lazily on first API call.
447
585
  compatibilityChecked = false;
448
586
  compatibilityPromise = null;
@@ -1022,10 +1160,45 @@ export class WordPressClient {
1022
1160
  }
1023
1161
  /**
1024
1162
  * Determine whether site supports respira/v2 and cache the result.
1163
+ *
1164
+ * Wraps `negotiateApiVersion()`, which callers that need to know whether
1165
+ * THIS call made a fresh HTTP request (vs. reused a cached failure) use
1166
+ * directly — see `ensureV2()`.
1025
1167
  */
1026
1168
  async getApiVersion() {
1027
- if (this.negotiatedApiVersion) {
1028
- return this.negotiatedApiVersion;
1169
+ return (await this.negotiateApiVersion()).version;
1170
+ }
1171
+ /**
1172
+ * Core negotiation. A successful 'v2' result is cached for the life of the
1173
+ * client. A failure (falls back to 'v1') is cached only for
1174
+ * `v2NegotiationFailureTtlMs()` — once that ages out, the cached failure is
1175
+ * discarded and the next call below re-probes for real, rather than
1176
+ * repeating a verdict from a possibly long-gone transient error. See the
1177
+ * comment on `v2NegotiationFailureTtlMs` for why (tickets 7dce1a8a,
1178
+ * f3310791).
1179
+ *
1180
+ * `freshlyProbed` tells the caller whether THIS invocation actually sent
1181
+ * the HTTP request (true — the result, if a failure, is brand new) or
1182
+ * reused an existing cached failure (false — `negotiationFailureAt` names
1183
+ * when it was first observed). `ensureV2()` uses this so a cached failure
1184
+ * is reported honestly instead of reading like a request that just ran.
1185
+ */
1186
+ async negotiateApiVersion() {
1187
+ if (this.negotiatedApiVersion === 'v2') {
1188
+ return { version: 'v2', freshlyProbed: false };
1189
+ }
1190
+ if (this.negotiatedApiVersion === 'v1') {
1191
+ const ttl = v2NegotiationFailureTtlMs();
1192
+ const age = this.negotiationFailureAt !== null ? Date.now() - this.negotiationFailureAt : Infinity;
1193
+ if (age < ttl) {
1194
+ return { version: 'v1', freshlyProbed: false };
1195
+ }
1196
+ // The cached FAILURE aged out. Clear the latch so the block below runs
1197
+ // a genuinely fresh probe instead of returning stale data — this is
1198
+ // the fix for the permanent-latch bug: only a SUCCESS stays cached
1199
+ // indefinitely; a failure never does.
1200
+ this.negotiatedApiVersion = null;
1201
+ this.negotiationPromise = null;
1029
1202
  }
1030
1203
  if (!this.negotiationPromise) {
1031
1204
  this.negotiationPromise = (async () => {
@@ -1046,6 +1219,7 @@ export class WordPressClient {
1046
1219
  if (response.status >= 200 && response.status < 300) {
1047
1220
  this.negotiatedApiVersion = 'v2';
1048
1221
  this.negotiationFailure = null;
1222
+ this.negotiationFailureAt = null;
1049
1223
  return 'v2';
1050
1224
  }
1051
1225
  this.negotiationFailure = classifyV2Failure({ response });
@@ -1056,10 +1230,12 @@ export class WordPressClient {
1056
1230
  this.negotiationFailure = classifyV2Failure(error);
1057
1231
  }
1058
1232
  this.negotiatedApiVersion = 'v1';
1233
+ this.negotiationFailureAt = Date.now();
1059
1234
  return 'v1';
1060
1235
  })();
1061
1236
  }
1062
- return this.negotiationPromise;
1237
+ const version = await this.negotiationPromise;
1238
+ return { version, freshlyProbed: true };
1063
1239
  }
1064
1240
  /**
1065
1241
  * Expose negotiated API version for diagnostics.
@@ -1071,9 +1247,9 @@ export class WordPressClient {
1071
1247
  * Ensure connected site supports respira/v2.
1072
1248
  */
1073
1249
  async ensureV2() {
1074
- const version = await this.getApiVersion();
1250
+ const { version, freshlyProbed } = await this.negotiateApiVersion();
1075
1251
  if (version !== 'v2') {
1076
- throw new Error(describeV2Failure(this.negotiationFailure, this.siteConfig.url));
1252
+ throw new Error(describeV2Failure(this.negotiationFailure, this.siteConfig.url, freshlyProbed ? null : this.negotiationFailureAt));
1077
1253
  }
1078
1254
  }
1079
1255
  // ---------------------------------------------------------------------------
@@ -1222,12 +1398,33 @@ export class WordPressClient {
1222
1398
  * `forceWriteMethod: 'post'` on the site config skips the PUT entirely, for
1223
1399
  * hosts already known to drop it (support can set it without a plugin
1224
1400
  * change). Ticket e2d884cb.
1401
+ *
1402
+ * Ticket 139f7353: "every PUT write goes through here" was aspirational, not
1403
+ * true. The fallback was wired into the shared callers (callRestV1 /
1404
+ * callRestV2) and the three post-like methods, and the ~25 dedicated methods
1405
+ * that call `client.put()` directly were left on a bare PUT. On a host whose
1406
+ * edge blocks PUT (Kinsta + Cloudflare, reported against
1407
+ * `respira_update_menu_item`) `respira_create_menu_item` worked, because it
1408
+ * is a POST, and `respira_update_menu_item` returned 405, so a menu could be
1409
+ * built and never reordered. Every one of those call sites now routes
1410
+ * through here.
1411
+ *
1412
+ * This is safe for the whole set because the plugin registers all of them
1413
+ * with `WP_REST_Server::EDITABLE`, which is literally `'POST, PUT, PATCH'`:
1414
+ * the fallback POST reaches the identical handler. It is deliberately NOT
1415
+ * extended to DELETE. Those routes are registered `DELETABLE`, so a fallback
1416
+ * POST would return `rest_no_route` — a 404 that reads like "the plugin is
1417
+ * too old" and would be a worse diagnosis than the honest 405. Making delete
1418
+ * survive a blocked verb needs a plugin-side route change, not a connector
1419
+ * change; see the ticket for the enumerated list.
1225
1420
  */
1226
- async putWithPostFallback(client, url, data) {
1421
+ async putWithPostFallback(client, url, data, config = {}) {
1227
1422
  const callId = randomUUID();
1228
- const firstAttempt = { headers: { 'Idempotency-Key': callId } };
1423
+ const extraHeaders = (config.headers || {});
1424
+ const firstAttempt = { ...config, headers: { ...extraHeaders, 'Idempotency-Key': callId } };
1229
1425
  const fallbackAttempt = {
1230
- headers: { 'Idempotency-Key': callId, [ORIGINAL_METHOD_HEADER]: 'PUT' },
1426
+ ...config,
1427
+ headers: { ...extraHeaders, 'Idempotency-Key': callId, [ORIGINAL_METHOD_HEADER]: 'PUT' },
1231
1428
  };
1232
1429
  if (this.siteConfig.forceWriteMethod === 'post') {
1233
1430
  // No PUT was sent, so there is no earlier attempt to alias onto: a plain
@@ -1375,9 +1572,27 @@ export class WordPressClient {
1375
1572
  const res = await axios.get(probeUrl, {
1376
1573
  timeout: 10000,
1377
1574
  validateStatus: () => true,
1378
- headers: { Accept: 'application/json' },
1575
+ // Ticket 44fcf6b5. This probe used to send `{ Accept }` and nothing
1576
+ // else. Every other request this client makes carries
1577
+ // `this.defaultHeaders`, which is where the site's configured
1578
+ // `httpAuth` Basic credential lives — so on a site behind server-level
1579
+ // HTTP Basic Auth this was the one request that went out without it.
1580
+ // It came back 401 from the web server, `registered` evaluated false,
1581
+ // and the connector then told the operator, in as many words, that the
1582
+ // Respira REST namespace is not registered and the plugin is
1583
+ // deactivated, deleted or failing to load. The plugin was fine; the
1584
+ // connector had simply forgotten to log in for one request and then
1585
+ // reported the resulting silence as a fact about their site.
1586
+ headers: { ...this.defaultHeaders, Accept: 'application/json' },
1379
1587
  });
1380
1588
  const body = res.data;
1589
+ // A 401/403 is the web server or WordPress declining to answer the
1590
+ // question, not an answer to it. Returning `false` there is a claim the
1591
+ // probe cannot support: the namespace could be perfectly well
1592
+ // registered behind that challenge. `null` means "could not tell", and
1593
+ // every caller already handles it by staying quiet.
1594
+ if (res.status === 401 || res.status === 403)
1595
+ return null;
1381
1596
  // A registered namespace answers with its own index. Anything else,
1382
1597
  // including another rest_no_route, means it is not there.
1383
1598
  const registered = res.status === 200
@@ -1404,18 +1619,56 @@ export class WordPressClient {
1404
1619
  // Handle specific error codes
1405
1620
  if (status === 401) {
1406
1621
  if (isNonWordPressResponse) {
1407
- // Likely nginx/Apache HTTP Basic Auth or a CDN/WAF challenge page
1408
- const wwwAuth = error.response.headers?.['www-authenticate'] || '';
1622
+ // Likely nginx/Apache HTTP Basic Auth or a CDN/WAF challenge page.
1623
+ //
1624
+ // Ticket 44fcf6b5. Two things were wrong with the old wording, and
1625
+ // both of them are the same mistake: describing a guess as a fact.
1626
+ //
1627
+ // 1. When the challenge said `WWW-Authenticate: Basic`, this told
1628
+ // the operator to add "httpAuth" to their site config — WITHOUT
1629
+ // checking whether httpAuth was already there. On the reported
1630
+ // site it was, and it had been sent on this very request. So the
1631
+ // connector's advice was to do the thing that had already been
1632
+ // done, and the one fact that would have moved the investigation
1633
+ // forward (credentials were supplied and the server rejected
1634
+ // them, or the challenge covers this verb specifically) was the
1635
+ // one fact withheld.
1636
+ // 2. When the challenge did NOT say Basic, this asserted that "a
1637
+ // firewall, CDN, or maintenance mode may be blocking API
1638
+ // access". Nothing in a 401 is evidence of maintenance mode. A
1639
+ // 401 is an authentication challenge; maintenance mode is a 503.
1640
+ // Naming maintenance sent people to look for an outage that was
1641
+ // not happening.
1642
+ //
1643
+ // What this says now is only what the response actually contained:
1644
+ // the verb, the realm if the server named one, and whether this
1645
+ // connector sent a credential.
1646
+ const wwwAuth = String(error.response.headers?.['www-authenticate'] || '');
1409
1647
  const isBasicAuth = /basic/i.test(wwwAuth);
1410
- const hint = isBasicAuth
1411
- ? 'The site requires HTTP Basic Authentication (username/password) this is separate from your Respira API key. ' +
1412
- 'This is common on staging sites. To fix this, add "httpAuth" to your site config:\n\n' +
1413
- ' "httpAuth": { "username": "your-user", "password": "your-pass" }\n\n' +
1414
- 'Or disable HTTP Basic Auth for the /wp-json/respira/* path in your server config.'
1415
- : 'The site returned an HTML error page instead of a WordPress REST API response. ' +
1416
- 'A firewall, CDN, or maintenance mode may be blocking API access. ' +
1417
- 'Verify that ' + this.siteConfig.url + '/wp-json/respira/v1/context/site-info is accessible in your browser.';
1418
- return this.codedError(`Site not reachable: ${this.siteConfig.url} returned a ${status} response from the web server (not WordPress).\n\n${hint}`, 'http_401_non_wordpress');
1648
+ const realmMatch = /realm="?([^",]+)"?/i.exec(wwwAuth);
1649
+ const realm = realmMatch ? realmMatch[1].trim() : null;
1650
+ const method = String(error.config?.method || '').toUpperCase() || 'the request';
1651
+ const credentialSent = !!(this.siteConfig.httpAuth?.username && this.siteConfig.httpAuth?.password);
1652
+ const what = isBasicAuth
1653
+ ? `The web server asked for HTTP Basic Authentication${realm ? ` (realm "${realm}")` : ''} on this ${method}. ` +
1654
+ 'That is the server password, separate from your Respira API key.'
1655
+ : `The web server refused this ${method} with a 401 and an HTML page. ` +
1656
+ (wwwAuth
1657
+ ? `It sent an authentication challenge: ${wwwAuth}.`
1658
+ : 'It sent no WWW-Authenticate header, so it did not say which credential it wants.');
1659
+ const hint = credentialSent
1660
+ ? 'httpAuth IS configured for this site and those credentials were sent on this request, so this is not a missing-credential problem. ' +
1661
+ 'Either the username/password no longer match what the server expects, or the protected area covers this HTTP method and not the ones that are working. ' +
1662
+ `Run respira_diagnose_connection: it reports the status of GET, OPTIONS and PUT separately, which is what separates "wrong password" from "${method} specifically is challenged". ` +
1663
+ 'Re-check the credentials against the server config, and check whether the auth block is scoped by method (Apache <Limit>/<LimitExcept>, an nginx `limit_except` inside the protected location).'
1664
+ : isBasicAuth
1665
+ ? 'No httpAuth is configured for this site, so no credential was sent. Add one to your site config:\n\n' +
1666
+ ' "httpAuth": { "username": "your-user", "password": "your-pass" }\n\n' +
1667
+ 'Or exempt the /wp-json/respira/* path from HTTP Basic Auth in your server config.'
1668
+ : 'No httpAuth is configured for this site, so no credential was sent. If the site prompts for a username and password in a browser, add one to your site config:\n\n' +
1669
+ ' "httpAuth": { "username": "your-user", "password": "your-pass" }\n\n' +
1670
+ `Otherwise a CDN or WAF is challenging the request. Open ${this.siteConfig.url}/wp-json/respira/v1/context/site-info in a browser to see which.`;
1671
+ return this.codedError(`Authentication required: ${this.siteConfig.url} returned a ${status} from the web server (not WordPress).\n\n${what}\n\n${hint}`, 'http_401_non_wordpress');
1419
1672
  }
1420
1673
  const reason = data?.message || data?.code || 'Invalid API key';
1421
1674
  const authError = this.codedError(`Authentication failed: ${reason}`, typeof data?.code === 'string' && data.code ? data.code : 'http_401', data);
@@ -2993,7 +3246,7 @@ export class WordPressClient {
2993
3246
  const timeout = updates.content && updates.content.length > 50000
2994
3247
  ? 60000 // 60 seconds for large content
2995
3248
  : 30000; // 30 seconds default
2996
- const response = await this.client.put(`/builder/${builder}/modules/${pageId}`, {
3249
+ const response = await this.putWithPostFallback(this.client, `/builder/${builder}/modules/${pageId}`, {
2997
3250
  module_identifier: moduleIdentifier,
2998
3251
  updates,
2999
3252
  editTarget,
@@ -3306,6 +3559,12 @@ export class WordPressClient {
3306
3559
  server: respHeaders['server'] ?? null,
3307
3560
  'x-respira-auth-arrived': respHeaders['x-respira-auth-arrived'] ?? null,
3308
3561
  link: respHeaders['link'] ?? null,
3562
+ // Ticket 44fcf6b5: the one header that separates "the edge blocks
3563
+ // this verb" from "the server is asking for a password". It was
3564
+ // being read on every response and recorded on none, so a Basic
3565
+ // Auth challenge on OPTIONS/PUT graded out as a method block and
3566
+ // sent the operator into WAF rules that had nothing to do with it.
3567
+ 'www-authenticate': respHeaders['www-authenticate'] ?? null,
3309
3568
  },
3310
3569
  });
3311
3570
  }
@@ -3437,11 +3696,31 @@ export class WordPressClient {
3437
3696
  // An HTML 4xx/5xx is something in front of WordPress answering instead.
3438
3697
  const putReachedWordPress = !!putProbe && typeof putProbe.status === 'number' && putProbe.looks_like_html === false;
3439
3698
  const putBlockedAtEdge = !!putProbe && typeof putProbe.status === 'number' && putProbe.status >= 400 && !putReachedWordPress;
3699
+ // Ticket 44fcf6b5. A 401/407 is not a method block, it is the server
3700
+ // asking for a credential, and the two need opposite fixes. Grading an
3701
+ // authentication challenge as "the edge is blocking non-GET methods"
3702
+ // pointed a customer at Cloudflare and Wordfence rules on a site whose
3703
+ // only gate was an .htaccess password, and the recommendation text never
3704
+ // mentioned Basic Auth at all. `www-authenticate` on the response is the
3705
+ // discriminator; the status alone is not, because some edges challenge
3706
+ // with 403 and some WAFs reject with 401.
3707
+ const authChallenged = (p) => !!p &&
3708
+ typeof p.status === 'number' &&
3709
+ (p.status === 401 || p.status === 407 || !!p.headers?.['www-authenticate']);
3710
+ const putAuthChallenged = authChallenged(putProbe);
3711
+ const optionsAuthChallenged = authChallenged(optionsProbe);
3712
+ const httpAuthConfigured = !!(this.siteConfig.httpAuth?.username && this.siteConfig.httpAuth?.password);
3440
3713
  const writeMethodVerdict = (() => {
3441
3714
  // If plain GET is not healthy, nothing can be concluded about writes
3442
3715
  // specifically — the whole connection is the problem.
3443
3716
  if (!getOk)
3444
3717
  return 'unknown';
3718
+ // Checked BEFORE `blocked`: a 401 satisfies putBlockedAtEdge too, and
3719
+ // the old ordering meant every auth challenge was reported as a method
3720
+ // block. The verb never reached WordPress, so this diagnostic cannot
3721
+ // say whether the verb itself is allowed — only that it was challenged.
3722
+ if (putAuthChallenged || optionsAuthChallenged)
3723
+ return 'auth_challenge';
3445
3724
  if (optionsBlocked || putBlockedAtEdge)
3446
3725
  return 'blocked';
3447
3726
  if (putDropped || optionsDropped)
@@ -3452,13 +3731,36 @@ export class WordPressClient {
3452
3731
  })();
3453
3732
  // Kept as a tri-state boolean rather than a string so an existing truthiness
3454
3733
  // check still means "proven blocked" and never accidentally fires on the
3455
- // unknown case. null = could not tell.
3734
+ // unknown case. null = could not tell — which is the honest answer for an
3735
+ // auth challenge as well as for a dropped probe.
3456
3736
  const writeMethodBlocked = writeMethodVerdict === 'blocked' ? true : writeMethodVerdict === 'not_blocked' ? false : null;
3457
3737
  const forceWriteMethodHint = ' Unblock this connection immediately without touching the host: add ' +
3458
3738
  `"forceWriteMethod": "post" to the site object in ${CONFIG_FILE}. The Respira plugin ` +
3459
3739
  'registers the same handler on PUT and POST for every updatable resource, so POST does ' +
3460
3740
  'identical work and the connector stops sending PUT at all. Fixing the host is still the ' +
3461
3741
  'better end state, this just stops the bleeding.';
3742
+ if (writeMethodVerdict === 'auth_challenge') {
3743
+ const challengedProbe = putAuthChallenged ? putProbe : optionsProbe;
3744
+ const verb = putAuthChallenged ? 'PUT' : 'OPTIONS';
3745
+ const challenge = String(challengedProbe?.headers?.['www-authenticate'] || '');
3746
+ const scheme = /basic/i.test(challenge)
3747
+ ? 'HTTP Basic Auth'
3748
+ : challenge
3749
+ ? `an authentication challenge (${challenge})`
3750
+ : 'an authentication challenge with no WWW-Authenticate header';
3751
+ recommendations.push(`${verb} /wp-json/respira/v1/ping returned ${challengedProbe?.status} with ${scheme} while GET returned 2xx. ` +
3752
+ 'This is an authentication challenge, not a blocked HTTP verb: the request never reached WordPress, so nothing here ' +
3753
+ 'proves the verb is allowed OR disallowed, and no WAF or CDN method rule is implicated. ' +
3754
+ (httpAuthConfigured
3755
+ ? 'httpAuth IS configured for this site and was sent on this probe, so the credential is either wrong now or does not cover this method. ' +
3756
+ 'Check the username and password against the server, and check whether the protected block is scoped by method ' +
3757
+ '(Apache <Limit>/<LimitExcept> inside the auth block, or an nginx `limit_except` inside the protected location) — ' +
3758
+ 'a method-scoped auth block is exactly what makes GET work and everything else fail.'
3759
+ : 'No httpAuth is configured for this site, so no credential was sent. Add ' +
3760
+ `"httpAuth": { "username": "...", "password": "..." } to the site object in ${CONFIG_FILE}, ` +
3761
+ 'or exempt /wp-json/respira/* from the password in the server config.') +
3762
+ ' Do not set "forceWriteMethod": "post" for this: POST is challenged the same way, so it would change nothing.');
3763
+ }
3462
3764
  if (writeMethodVerdict === 'unknown' && (putDropped || optionsDropped)) {
3463
3765
  const droppedLabel = putDropped ? 'PUT' : 'OPTIONS';
3464
3766
  const droppedCode = String((putDropped ? putProbe : optionsProbe)?.code || 'no response');
@@ -3532,14 +3834,23 @@ export class WordPressClient {
3532
3834
  // `write_method_verdict` for the named state; never treat null as false.
3533
3835
  write_method_blocked: writeMethodBlocked,
3534
3836
  write_method_verdict: writeMethodVerdict,
3837
+ // Ticket 44fcf6b5: whether a credential was configured and sent is part
3838
+ // of the evidence, not background knowledge the reader is assumed to
3839
+ // have. Without it, "401 on PUT" and "401 on PUT with the password
3840
+ // supplied" read identically, and they mean different things.
3841
+ http_auth_configured: httpAuthConfigured,
3535
3842
  write_method_probe: {
3536
3843
  options_status: optionsProbe?.status ?? null,
3537
3844
  options_dropped: optionsDropped,
3845
+ options_auth_challenged: optionsAuthChallenged,
3538
3846
  put_status: putProbe?.status ?? null,
3539
3847
  put_dropped: putDropped,
3848
+ put_auth_challenged: putAuthChallenged,
3540
3849
  put_reached_wordpress: putReachedWordPress,
3541
3850
  put_error: putProbe?.error ?? null,
3542
3851
  put_error_code: putProbe?.code ?? null,
3852
+ put_www_authenticate: putProbe?.headers?.['www-authenticate'] ?? null,
3853
+ options_www_authenticate: optionsProbe?.headers?.['www-authenticate'] ?? null,
3543
3854
  },
3544
3855
  target_post_id: opts.post_id ?? null,
3545
3856
  recommendations,
@@ -3728,7 +4039,7 @@ export class WordPressClient {
3728
4039
  return response.data;
3729
4040
  }
3730
4041
  async updateUser(id, data) {
3731
- const response = await this.client.put(`/users/${id}`, data);
4042
+ const response = await this.putWithPostFallback(this.client, `/users/${id}`, data);
3732
4043
  return response.data;
3733
4044
  }
3734
4045
  async deleteUser(id, reassign, approval_token) {
@@ -3751,7 +4062,7 @@ export class WordPressClient {
3751
4062
  return response.data;
3752
4063
  }
3753
4064
  async updateComment(id, data) {
3754
- const response = await this.client.put(`/comments/${id}`, data);
4065
+ const response = await this.putWithPostFallback(this.client, `/comments/${id}`, data);
3755
4066
  return response.data;
3756
4067
  }
3757
4068
  async deleteComment(id, approvalToken) {
@@ -3782,7 +4093,7 @@ export class WordPressClient {
3782
4093
  return response.data;
3783
4094
  }
3784
4095
  async updateTerm(taxonomy, id, data) {
3785
- const response = await this.client.put(`/taxonomies/${taxonomy}/terms/${id}`, data);
4096
+ const response = await this.putWithPostFallback(this.client, `/taxonomies/${taxonomy}/terms/${id}`, data);
3786
4097
  return response.data;
3787
4098
  }
3788
4099
  async deleteTerm(taxonomy, id, approvalToken) {
@@ -3945,11 +4256,11 @@ export class WordPressClient {
3945
4256
  return response.data;
3946
4257
  }
3947
4258
  async updateMedia(id, data) {
3948
- const response = await this.client.put(`/media/${id}`, data);
4259
+ const response = await this.putWithPostFallback(this.client, `/media/${id}`, data);
3949
4260
  return response.data.media || response.data;
3950
4261
  }
3951
4262
  async updateMediaBatch(items) {
3952
- const response = await this.client.put('/media/batch', { items });
4263
+ const response = await this.putWithPostFallback(this.client, '/media/batch', { items });
3953
4264
  return response.data;
3954
4265
  }
3955
4266
  /**
@@ -3995,7 +4306,7 @@ export class WordPressClient {
3995
4306
  return response.data;
3996
4307
  }
3997
4308
  async updateMenu(id, data) {
3998
- const response = await this.client.put(`/menus/${id}`, data);
4309
+ const response = await this.putWithPostFallback(this.client, `/menus/${id}`, data);
3999
4310
  return response.data;
4000
4311
  }
4001
4312
  async deleteMenu(id, approval_token) {
@@ -4010,7 +4321,7 @@ export class WordPressClient {
4010
4321
  return response.data;
4011
4322
  }
4012
4323
  async assignMenuToLocation(location, menuId) {
4013
- const response = await this.client.put(`/menus/locations/${location}`, { menu_id: menuId });
4324
+ const response = await this.putWithPostFallback(this.client, `/menus/locations/${location}`, { menu_id: menuId });
4014
4325
  return response.data;
4015
4326
  }
4016
4327
  // Menu Items
@@ -4027,7 +4338,7 @@ export class WordPressClient {
4027
4338
  return response.data;
4028
4339
  }
4029
4340
  async updateMenuItem(itemId, data) {
4030
- const response = await this.client.put(`/menus/items/${itemId}`, data);
4341
+ const response = await this.putWithPostFallback(this.client, `/menus/items/${itemId}`, data);
4031
4342
  return response.data;
4032
4343
  }
4033
4344
  async deleteMenuItem(itemId) {
@@ -4048,7 +4359,7 @@ export class WordPressClient {
4048
4359
  return response.data;
4049
4360
  }
4050
4361
  async woocommerceUpdateProduct(id, data) {
4051
- const response = await this.client.put(`/woocommerce/products/${id}`, data);
4362
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/products/${id}`, data);
4052
4363
  return response.data;
4053
4364
  }
4054
4365
  async woocommerceDuplicateProduct(id) {
@@ -4068,7 +4379,7 @@ export class WordPressClient {
4068
4379
  return response.data;
4069
4380
  }
4070
4381
  async woocommerceUpdateCategory(id, data) {
4071
- const response = await this.client.put(`/woocommerce/categories/${id}`, data);
4382
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/categories/${id}`, data);
4072
4383
  return response.data;
4073
4384
  }
4074
4385
  async woocommerceDeleteCategory(id) {
@@ -4088,7 +4399,7 @@ export class WordPressClient {
4088
4399
  return response.data;
4089
4400
  }
4090
4401
  async woocommerceUpdateTag(id, data) {
4091
- const response = await this.client.put(`/woocommerce/tags/${id}`, data);
4402
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/tags/${id}`, data);
4092
4403
  return response.data;
4093
4404
  }
4094
4405
  async woocommerceDeleteTag(id) {
@@ -4104,7 +4415,7 @@ export class WordPressClient {
4104
4415
  return response.data;
4105
4416
  }
4106
4417
  async woocommerceUpdateOrderStatus(id, status) {
4107
- const response = await this.client.put(`/woocommerce/orders/${id}/status`, { status });
4418
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/orders/${id}/status`, { status });
4108
4419
  return response.data;
4109
4420
  }
4110
4421
  async woocommerceGetStockStatus() {
@@ -4112,7 +4423,7 @@ export class WordPressClient {
4112
4423
  return response.data;
4113
4424
  }
4114
4425
  async woocommerceUpdateStock(id, data) {
4115
- const response = await this.client.put(`/woocommerce/products/${id}/stock`, data);
4426
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/products/${id}/stock`, data);
4116
4427
  return response.data;
4117
4428
  }
4118
4429
  async woocommerceSalesReport(params) {
@@ -4133,7 +4444,7 @@ export class WordPressClient {
4133
4444
  return response.data;
4134
4445
  }
4135
4446
  async woocommerceUpdateBrand(id, data) {
4136
- const response = await this.client.put(`/woocommerce/brands/${id}`, data);
4447
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/brands/${id}`, data);
4137
4448
  return response.data;
4138
4449
  }
4139
4450
  async woocommerceDeleteBrand(id) {
@@ -4231,7 +4542,7 @@ export class WordPressClient {
4231
4542
  return response.data;
4232
4543
  }
4233
4544
  async woocommerceSetVariationGallery(id, data) {
4234
- const response = await this.client.put(`/woocommerce/variations/${id}/gallery`, data);
4545
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/variations/${id}/gallery`, data);
4235
4546
  return response.data;
4236
4547
  }
4237
4548
  // WooCommerce Add-on v3.0 — coupons and customers
@@ -4244,7 +4555,7 @@ export class WordPressClient {
4244
4555
  return response.data;
4245
4556
  }
4246
4557
  async woocommerceUpdateCoupon(id, data) {
4247
- const response = await this.client.put(`/woocommerce/coupons/${id}`, data);
4558
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/coupons/${id}`, data);
4248
4559
  return response.data;
4249
4560
  }
4250
4561
  async woocommerceCreateCustomer(data) {
@@ -4252,7 +4563,7 @@ export class WordPressClient {
4252
4563
  return response.data;
4253
4564
  }
4254
4565
  async woocommerceUpdateCustomer(id, data) {
4255
- const response = await this.client.put(`/woocommerce/customers/${id}`, data);
4566
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/customers/${id}`, data);
4256
4567
  return response.data;
4257
4568
  }
4258
4569
  // WooCommerce Add-on v3.0 — order notes and refunds
@@ -4278,11 +4589,11 @@ export class WordPressClient {
4278
4589
  return response.data;
4279
4590
  }
4280
4591
  async woocommerceUpdateSubscriptionStatus(id, data) {
4281
- const response = await this.client.put(`/woocommerce/subscriptions/${id}/status`, data || {});
4592
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/subscriptions/${id}/status`, data || {});
4282
4593
  return response.data;
4283
4594
  }
4284
4595
  async woocommerceUpdateSubscriptionDates(id, data) {
4285
- const response = await this.client.put(`/woocommerce/subscriptions/${id}/dates`, data || {});
4596
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/subscriptions/${id}/dates`, data || {});
4286
4597
  return response.data;
4287
4598
  }
4288
4599
  async woocommerceAddSubscriptionNote(id, data) {
@@ -4298,11 +4609,11 @@ export class WordPressClient {
4298
4609
  return response.data;
4299
4610
  }
4300
4611
  async woocommerceUpdateBookingStatus(id, data) {
4301
- const response = await this.client.put(`/woocommerce/bookings/${id}/status`, data || {});
4612
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/bookings/${id}/status`, data || {});
4302
4613
  return response.data;
4303
4614
  }
4304
4615
  async woocommerceRescheduleBooking(id, data) {
4305
- const response = await this.client.put(`/woocommerce/bookings/${id}/reschedule`, data || {});
4616
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/bookings/${id}/reschedule`, data || {});
4306
4617
  return response.data;
4307
4618
  }
4308
4619
  async woocommerceListMembershipPlans(params) {
@@ -4318,11 +4629,11 @@ export class WordPressClient {
4318
4629
  return response.data;
4319
4630
  }
4320
4631
  async woocommerceUpdateMembershipStatus(id, data) {
4321
- const response = await this.client.put(`/woocommerce/memberships/${id}/status`, data || {});
4632
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/memberships/${id}/status`, data || {});
4322
4633
  return response.data;
4323
4634
  }
4324
4635
  async woocommerceSetMembershipEndDate(id, data) {
4325
- const response = await this.client.put(`/woocommerce/memberships/${id}/end-date`, data || {});
4636
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/memberships/${id}/end-date`, data || {});
4326
4637
  return response.data;
4327
4638
  }
4328
4639
  // WooCommerce Add-on v3.1 — assisted checkout
@@ -4535,7 +4846,7 @@ export class WordPressClient {
4535
4846
  const { method, path, body } = resolver(args);
4536
4847
  const req = method === 'GET' ? this.client.get(path)
4537
4848
  : method === 'POST' ? this.client.post(path, body || {})
4538
- : method === 'PUT' ? this.client.put(path, body || {})
4849
+ : method === 'PUT' ? this.putWithPostFallback(this.client, path, body || {})
4539
4850
  : this.client.request({ method: 'DELETE', url: path, data: body || {} });
4540
4851
  const response = await req;
4541
4852
  return response.data;