@respira/wordpress-mcp-server 8.3.18 → 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 (43) hide show
  1. package/CHANGELOG.md +89 -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__/upload-media-path-detection.test.js +35 -1
  24. package/dist/__tests__/upload-media-path-detection.test.js.map +1 -1
  25. package/dist/__tests__/v2-negotiation-ttl-retry.test.d.ts +2 -0
  26. package/dist/__tests__/v2-negotiation-ttl-retry.test.d.ts.map +1 -0
  27. package/dist/__tests__/v2-negotiation-ttl-retry.test.js +108 -0
  28. package/dist/__tests__/v2-negotiation-ttl-retry.test.js.map +1 -0
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +59 -4
  31. package/dist/config.js.map +1 -1
  32. package/dist/server.d.ts +7 -0
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +54 -4
  35. package/dist/server.js.map +1 -1
  36. package/dist/wordpress-client.d.ts +108 -1
  37. package/dist/wordpress-client.d.ts.map +1 -1
  38. package/dist/wordpress-client.js +467 -77
  39. package/dist/wordpress-client.js.map +1 -1
  40. package/package.json +2 -2
  41. package/skills/woo-marketing-campaigns/SKILL.md +128 -0
  42. package/skills/woo-pricing-promotions/SKILL.md +119 -0
  43. package/tool-capabilities.json +92 -344
@@ -35,6 +35,21 @@ const WINDOWS_DRIVE_PATH_RE = /^[a-zA-Z]:[\\/]/;
35
35
  * report comes in.
36
36
  */
37
37
  export function isLocalFilePath(file) {
38
+ // Ticket 7c1cd8ac. Every JPEG on earth starts with the bytes FF D8 FF, and
39
+ // those three bytes base64-encode to the four characters `/9j/`. So a bare
40
+ // base64 JPEG ALWAYS begins with a slash, matched `startsWith('/')` below,
41
+ // and was routed into the local-file branch: `existsSync()` failed and the
42
+ // upload died with `File not found: <the entire 10KB payload>` without ever
43
+ // reaching WordPress. The existing unit test missed it because its sample
44
+ // blob (`aGVsbG8...`) happens to start with a letter.
45
+ //
46
+ // Guard first: a well-formed base64 payload is never a path. A real path
47
+ // cannot pass looksLikeBase64Payload() — `.`, `-`, `_`, `\` and spaces are
48
+ // all outside the base64 alphabet, so anything with a file extension or a
49
+ // directory separator that is not `/` is excluded by construction.
50
+ if (looksLikeBase64Payload(file)) {
51
+ return false;
52
+ }
38
53
  return (file.startsWith('/') ||
39
54
  file.startsWith('./') ||
40
55
  file.startsWith('../') ||
@@ -42,6 +57,35 @@ export function isLocalFilePath(file) {
42
57
  WINDOWS_DRIVE_PATH_RE.test(file) ||
43
58
  file.startsWith('file://'));
44
59
  }
60
+ /**
61
+ * True when the string is, on its own terms, a base64 payload rather than a
62
+ * path or a URL: base64 alphabet only, correct padding, and long enough that a
63
+ * short path like `/tmp` or `/a/b` cannot collide with it by accident.
64
+ *
65
+ * Deliberately conservative, because being wrong in this direction means
66
+ * refusing to read a file the user really has. Three conditions together:
67
+ *
68
+ * - 256+ characters (192 decoded bytes). Long enough that no real path can
69
+ * reach it while still using only `[A-Za-z0-9+/]`, and far below any file
70
+ * a person would actually upload.
71
+ * - valid base64: alphabet, padding, length divisible by four.
72
+ * - at least one uppercase, one lowercase and one digit. Path segments are
73
+ * words; base64 of compressed image data is not.
74
+ *
75
+ * A path such as `/home/user/pictures/holiday` fails on length. One long
76
+ * enough to pass fails on the character mix, or on `.`, `-`, `_`, `\` or a
77
+ * space, none of which are in the base64 alphabet.
78
+ */
79
+ export function looksLikeBase64Payload(file) {
80
+ const compact = file.replace(/\s+/g, '');
81
+ if (compact.length < 256 || compact.length % 4 !== 0) {
82
+ return false;
83
+ }
84
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) {
85
+ return false;
86
+ }
87
+ return /[A-Z]/.test(compact) && /[a-z]/.test(compact) && /[0-9]/.test(compact);
88
+ }
45
89
  /**
46
90
  * Turns an `isLocalFilePath()`-matched string into a real filesystem path.
47
91
  * Handles `file://` URIs (unwrapping via `fileURLToPath`, forcing Windows
@@ -136,6 +180,21 @@ export function ensureWriteIdempotencyKey(config, makeId = randomUUID) {
136
180
  else
137
181
  headers['Idempotency-Key'] = makeId();
138
182
  }
183
+ /**
184
+ * Whether this request body survives being sent a second time.
185
+ *
186
+ * A stream does not. Node streams (and `form-data`, which is a CombinedStream)
187
+ * are consumed on the first pipe, so replaying the same axios config sends a
188
+ * body of zero bytes while the original Content-Length still promises the full
189
+ * payload. The socket then sits open until a timeout with the request never
190
+ * reaching the server at all — a silent multi-minute stall, not an error.
191
+ * Ticket 7c1cd8ac. Refuse the retry instead and let the real failure surface.
192
+ */
193
+ export function isReplayableBody(data) {
194
+ if (data == null)
195
+ return true;
196
+ return typeof data.pipe !== 'function';
197
+ }
139
198
  /** Whether a failed request is safe for the connector to repeat automatically. */
140
199
  export function isReplaySafeRetry(args) {
141
200
  const method = String(args.method || 'GET').toUpperCase();
@@ -227,12 +286,62 @@ const PUT_FALLBACK_CONNECTION_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ECO
227
286
  export function isRetryableAsPost(error) {
228
287
  if (PUT_FALLBACK_STATUSES.has(error?.response?.status))
229
288
  return true;
230
- if (error?.name === 'http_403_non_wordpress')
289
+ if (PUT_FALLBACK_STATUSES.has(codedNonWordPressStatus(error)))
231
290
  return true;
232
291
  if (error?.response)
233
292
  return false;
234
293
  return PUT_FALLBACK_CONNECTION_CODES.has(String(error?.code || '').toUpperCase());
235
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
+ ]);
236
345
  /**
237
346
  * Classify a failed v2 status probe.
238
347
  *
@@ -249,45 +358,125 @@ export function classifyV2Failure(error) {
249
358
  return { kind: 'auth', status, code, detail };
250
359
  if (status === 404)
251
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.
252
364
  if (Number.isFinite(status) && status > 0)
253
- return { kind: 'server', status, detail };
365
+ return { kind: 'server', status, code, detail };
254
366
  return { kind: 'network', detail: typeof error?.message === 'string' ? error.message : undefined };
255
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
+ }
256
396
  /**
257
397
  * Turn a negotiation failure into something the reader can act on.
258
398
  *
259
399
  * Each branch names what is actually known, and only the `missing` branch
260
400
  * keeps the old list of environmental causes, because that list is only ever
261
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.
262
408
  */
263
- export function describeV2Failure(failure, siteUrl) {
409
+ export function describeV2Failure(failure, siteUrl, observedAt) {
264
410
  const probe = `${siteUrl}/wp-json/respira/v2/status`;
411
+ let message;
265
412
  if (failure?.kind === 'auth') {
266
- return (`respira/v2 rejected this API key (HTTP ${failure.status}${failure.code ? `, ${failure.code}` : ''}).\n\n` +
267
- (failure.detail ? `The site said: ${failure.detail}\n\n` : '') +
268
- 'The v2 routes exist and are reachable — this is an authentication result, not a missing namespace. ' +
269
- 'Most often the key was deactivated (a lapsed licence disables API keys, and renewing does not ' +
270
- 'currently re-enable them) or it was minted for a different site.\n\n' +
271
- 'To fix: reconnect the site from respira.press/dashboard/sites to mint a fresh key, then restart your MCP client.');
272
- }
273
- if (failure?.kind === 'server') {
274
- return (`respira/v2 returned HTTP ${failure.status} on this site.\n\n` +
275
- (failure.detail ? `The site said: ${failure.detail}\n\n` : '') +
276
- `The namespace is registered but the request failed server-side. Check wp-content/debug.log, then open ${probe}.`);
277
- }
278
- if (failure?.kind === 'network') {
279
- return ('Could not reach respira/v2 on this site.\n\n' +
280
- (failure.detail ? `${failure.detail}\n\n` : '') +
281
- `This is a connectivity failure, not a plugin problem. Confirm the site is up, then open ${probe}.`);
282
- }
283
- // kind === 'missing', or we never got to probe at all.
284
- return ('respira/v2 is not available on this site.\n\n' +
285
- 'Common causes:\n' +
286
- ' A security plugin (Wordfence, Sucuri, iThemes Security) is blocking the /wp-json/respira/v2/ path whitelist that path in the plugin settings.\n' +
287
- ' WordPress permalinks are set to "Plain" go to Settings Permalinks and choose any option except Plain.\n' +
288
- '• 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' +
289
- '• The Respira plugin is outdated — update to the latest version from your WordPress admin.\n\n' +
290
- `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`;
291
480
  }
292
481
  /**
293
482
  * Guarantee a delete call answers with SOMETHING an agent can act on.
@@ -384,6 +573,14 @@ export class WordPressClient {
384
573
  * of blaming the namespace for what was actually an auth rejection.
385
574
  */
386
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;
387
584
  // Version compatibility check — populated lazily on first API call.
388
585
  compatibilityChecked = false;
389
586
  compatibilityPromise = null;
@@ -574,7 +771,10 @@ export class WordPressClient {
574
771
  idempotencySupported,
575
772
  connectionCode: error.response === undefined ? error.code : undefined,
576
773
  });
577
- if (attempt < RETRY_MAX_ATTEMPTS && retryable && config.url !== undefined) {
774
+ if (attempt < RETRY_MAX_ATTEMPTS &&
775
+ retryable &&
776
+ config.url !== undefined &&
777
+ isReplayableBody(config.data)) {
578
778
  const backoffMs = RETRY_BASE_MS * Math.pow(RETRY_FACTOR, attempt);
579
779
  await new Promise((resolve) => setTimeout(resolve, backoffMs));
580
780
  config._respiraRetryAttempt = attempt + 1;
@@ -605,7 +805,9 @@ export class WordPressClient {
605
805
  // Try the `?rest_route=` fallback only once per request.
606
806
  const requestConfig = response.config || {};
607
807
  const isAlreadyFallback = requestConfig._restRouteFallback === true;
608
- const canFallback = !isAlreadyFallback && this.canRewriteToRestRoute(requestConfig);
808
+ const canFallback = !isAlreadyFallback &&
809
+ this.canRewriteToRestRoute(requestConfig) &&
810
+ isReplayableBody(requestConfig.data);
609
811
  if (canFallback) {
610
812
  try {
611
813
  const retryResp = await this.retryViaRestRoute(requestConfig);
@@ -958,10 +1160,45 @@ export class WordPressClient {
958
1160
  }
959
1161
  /**
960
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()`.
961
1167
  */
962
1168
  async getApiVersion() {
963
- if (this.negotiatedApiVersion) {
964
- 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;
965
1202
  }
966
1203
  if (!this.negotiationPromise) {
967
1204
  this.negotiationPromise = (async () => {
@@ -982,6 +1219,7 @@ export class WordPressClient {
982
1219
  if (response.status >= 200 && response.status < 300) {
983
1220
  this.negotiatedApiVersion = 'v2';
984
1221
  this.negotiationFailure = null;
1222
+ this.negotiationFailureAt = null;
985
1223
  return 'v2';
986
1224
  }
987
1225
  this.negotiationFailure = classifyV2Failure({ response });
@@ -992,10 +1230,12 @@ export class WordPressClient {
992
1230
  this.negotiationFailure = classifyV2Failure(error);
993
1231
  }
994
1232
  this.negotiatedApiVersion = 'v1';
1233
+ this.negotiationFailureAt = Date.now();
995
1234
  return 'v1';
996
1235
  })();
997
1236
  }
998
- return this.negotiationPromise;
1237
+ const version = await this.negotiationPromise;
1238
+ return { version, freshlyProbed: true };
999
1239
  }
1000
1240
  /**
1001
1241
  * Expose negotiated API version for diagnostics.
@@ -1007,9 +1247,9 @@ export class WordPressClient {
1007
1247
  * Ensure connected site supports respira/v2.
1008
1248
  */
1009
1249
  async ensureV2() {
1010
- const version = await this.getApiVersion();
1250
+ const { version, freshlyProbed } = await this.negotiateApiVersion();
1011
1251
  if (version !== 'v2') {
1012
- throw new Error(describeV2Failure(this.negotiationFailure, this.siteConfig.url));
1252
+ throw new Error(describeV2Failure(this.negotiationFailure, this.siteConfig.url, freshlyProbed ? null : this.negotiationFailureAt));
1013
1253
  }
1014
1254
  }
1015
1255
  // ---------------------------------------------------------------------------
@@ -1158,12 +1398,33 @@ export class WordPressClient {
1158
1398
  * `forceWriteMethod: 'post'` on the site config skips the PUT entirely, for
1159
1399
  * hosts already known to drop it (support can set it without a plugin
1160
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.
1161
1420
  */
1162
- async putWithPostFallback(client, url, data) {
1421
+ async putWithPostFallback(client, url, data, config = {}) {
1163
1422
  const callId = randomUUID();
1164
- const firstAttempt = { headers: { 'Idempotency-Key': callId } };
1423
+ const extraHeaders = (config.headers || {});
1424
+ const firstAttempt = { ...config, headers: { ...extraHeaders, 'Idempotency-Key': callId } };
1165
1425
  const fallbackAttempt = {
1166
- headers: { 'Idempotency-Key': callId, [ORIGINAL_METHOD_HEADER]: 'PUT' },
1426
+ ...config,
1427
+ headers: { ...extraHeaders, 'Idempotency-Key': callId, [ORIGINAL_METHOD_HEADER]: 'PUT' },
1167
1428
  };
1168
1429
  if (this.siteConfig.forceWriteMethod === 'post') {
1169
1430
  // No PUT was sent, so there is no earlier attempt to alias onto: a plain
@@ -1311,9 +1572,27 @@ export class WordPressClient {
1311
1572
  const res = await axios.get(probeUrl, {
1312
1573
  timeout: 10000,
1313
1574
  validateStatus: () => true,
1314
- 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' },
1315
1587
  });
1316
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;
1317
1596
  // A registered namespace answers with its own index. Anything else,
1318
1597
  // including another rest_no_route, means it is not there.
1319
1598
  const registered = res.status === 200
@@ -1340,18 +1619,56 @@ export class WordPressClient {
1340
1619
  // Handle specific error codes
1341
1620
  if (status === 401) {
1342
1621
  if (isNonWordPressResponse) {
1343
- // Likely nginx/Apache HTTP Basic Auth or a CDN/WAF challenge page
1344
- 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'] || '');
1345
1647
  const isBasicAuth = /basic/i.test(wwwAuth);
1346
- const hint = isBasicAuth
1347
- ? 'The site requires HTTP Basic Authentication (username/password) this is separate from your Respira API key. ' +
1348
- 'This is common on staging sites. To fix this, add "httpAuth" to your site config:\n\n' +
1349
- ' "httpAuth": { "username": "your-user", "password": "your-pass" }\n\n' +
1350
- 'Or disable HTTP Basic Auth for the /wp-json/respira/* path in your server config.'
1351
- : 'The site returned an HTML error page instead of a WordPress REST API response. ' +
1352
- 'A firewall, CDN, or maintenance mode may be blocking API access. ' +
1353
- 'Verify that ' + this.siteConfig.url + '/wp-json/respira/v1/context/site-info is accessible in your browser.';
1354
- 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');
1355
1672
  }
1356
1673
  const reason = data?.message || data?.code || 'Invalid API key';
1357
1674
  const authError = this.codedError(`Authentication failed: ${reason}`, typeof data?.code === 'string' && data.code ? data.code : 'http_401', data);
@@ -2448,10 +2765,25 @@ export class WordPressClient {
2448
2765
  const controller = new AbortController();
2449
2766
  const killer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
2450
2767
  try {
2451
- const response = await this.client.post('/media/upload', formData, {
2768
+ // Ticket 7c1cd8ac. Send the multipart body as a Buffer, not as the
2769
+ // FormData stream. FormData extends CombinedStream, which SHIFTS each
2770
+ // part off an internal array as it is piped, so the instance is
2771
+ // single-use. Two paths in this file re-send the same axios config after
2772
+ // a failure — the 5xx/idempotency retry in errorInterceptor and
2773
+ // retryViaRestRoute() for the `?rest_route=` fallback — and both handed
2774
+ // the exhausted stream back to axios along with the original
2775
+ // Content-Length. Node then opened a request that promised N bytes and
2776
+ // wrote zero: the request never even reached the server, and the call sat
2777
+ // there until a socket timeout. Measured at 85-89 seconds per retry
2778
+ // against a local mock. Stack that on top of the first attempt and one
2779
+ // upload burns more than the 120s tool watchdog. A Buffer is replayable,
2780
+ // so a retry now sends the same bytes twice instead of nothing.
2781
+ const formBuffer = formData.getBuffer();
2782
+ const response = await this.client.post('/media/upload', formBuffer, {
2452
2783
  headers: {
2453
2784
  ...formData.getHeaders(),
2454
2785
  'Content-Type': formData.getHeaders()['content-type'],
2786
+ 'Content-Length': String(formBuffer.length),
2455
2787
  },
2456
2788
  maxContentLength: MAX_UPLOAD_BYTES,
2457
2789
  maxBodyLength: MAX_UPLOAD_BYTES,
@@ -2914,7 +3246,7 @@ export class WordPressClient {
2914
3246
  const timeout = updates.content && updates.content.length > 50000
2915
3247
  ? 60000 // 60 seconds for large content
2916
3248
  : 30000; // 30 seconds default
2917
- const response = await this.client.put(`/builder/${builder}/modules/${pageId}`, {
3249
+ const response = await this.putWithPostFallback(this.client, `/builder/${builder}/modules/${pageId}`, {
2918
3250
  module_identifier: moduleIdentifier,
2919
3251
  updates,
2920
3252
  editTarget,
@@ -3227,6 +3559,12 @@ export class WordPressClient {
3227
3559
  server: respHeaders['server'] ?? null,
3228
3560
  'x-respira-auth-arrived': respHeaders['x-respira-auth-arrived'] ?? null,
3229
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,
3230
3568
  },
3231
3569
  });
3232
3570
  }
@@ -3358,11 +3696,31 @@ export class WordPressClient {
3358
3696
  // An HTML 4xx/5xx is something in front of WordPress answering instead.
3359
3697
  const putReachedWordPress = !!putProbe && typeof putProbe.status === 'number' && putProbe.looks_like_html === false;
3360
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);
3361
3713
  const writeMethodVerdict = (() => {
3362
3714
  // If plain GET is not healthy, nothing can be concluded about writes
3363
3715
  // specifically — the whole connection is the problem.
3364
3716
  if (!getOk)
3365
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';
3366
3724
  if (optionsBlocked || putBlockedAtEdge)
3367
3725
  return 'blocked';
3368
3726
  if (putDropped || optionsDropped)
@@ -3373,13 +3731,36 @@ export class WordPressClient {
3373
3731
  })();
3374
3732
  // Kept as a tri-state boolean rather than a string so an existing truthiness
3375
3733
  // check still means "proven blocked" and never accidentally fires on the
3376
- // 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.
3377
3736
  const writeMethodBlocked = writeMethodVerdict === 'blocked' ? true : writeMethodVerdict === 'not_blocked' ? false : null;
3378
3737
  const forceWriteMethodHint = ' Unblock this connection immediately without touching the host: add ' +
3379
3738
  `"forceWriteMethod": "post" to the site object in ${CONFIG_FILE}. The Respira plugin ` +
3380
3739
  'registers the same handler on PUT and POST for every updatable resource, so POST does ' +
3381
3740
  'identical work and the connector stops sending PUT at all. Fixing the host is still the ' +
3382
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
+ }
3383
3764
  if (writeMethodVerdict === 'unknown' && (putDropped || optionsDropped)) {
3384
3765
  const droppedLabel = putDropped ? 'PUT' : 'OPTIONS';
3385
3766
  const droppedCode = String((putDropped ? putProbe : optionsProbe)?.code || 'no response');
@@ -3453,14 +3834,23 @@ export class WordPressClient {
3453
3834
  // `write_method_verdict` for the named state; never treat null as false.
3454
3835
  write_method_blocked: writeMethodBlocked,
3455
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,
3456
3842
  write_method_probe: {
3457
3843
  options_status: optionsProbe?.status ?? null,
3458
3844
  options_dropped: optionsDropped,
3845
+ options_auth_challenged: optionsAuthChallenged,
3459
3846
  put_status: putProbe?.status ?? null,
3460
3847
  put_dropped: putDropped,
3848
+ put_auth_challenged: putAuthChallenged,
3461
3849
  put_reached_wordpress: putReachedWordPress,
3462
3850
  put_error: putProbe?.error ?? null,
3463
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,
3464
3854
  },
3465
3855
  target_post_id: opts.post_id ?? null,
3466
3856
  recommendations,
@@ -3649,7 +4039,7 @@ export class WordPressClient {
3649
4039
  return response.data;
3650
4040
  }
3651
4041
  async updateUser(id, data) {
3652
- const response = await this.client.put(`/users/${id}`, data);
4042
+ const response = await this.putWithPostFallback(this.client, `/users/${id}`, data);
3653
4043
  return response.data;
3654
4044
  }
3655
4045
  async deleteUser(id, reassign, approval_token) {
@@ -3672,7 +4062,7 @@ export class WordPressClient {
3672
4062
  return response.data;
3673
4063
  }
3674
4064
  async updateComment(id, data) {
3675
- const response = await this.client.put(`/comments/${id}`, data);
4065
+ const response = await this.putWithPostFallback(this.client, `/comments/${id}`, data);
3676
4066
  return response.data;
3677
4067
  }
3678
4068
  async deleteComment(id, approvalToken) {
@@ -3703,7 +4093,7 @@ export class WordPressClient {
3703
4093
  return response.data;
3704
4094
  }
3705
4095
  async updateTerm(taxonomy, id, data) {
3706
- const response = await this.client.put(`/taxonomies/${taxonomy}/terms/${id}`, data);
4096
+ const response = await this.putWithPostFallback(this.client, `/taxonomies/${taxonomy}/terms/${id}`, data);
3707
4097
  return response.data;
3708
4098
  }
3709
4099
  async deleteTerm(taxonomy, id, approvalToken) {
@@ -3866,11 +4256,11 @@ export class WordPressClient {
3866
4256
  return response.data;
3867
4257
  }
3868
4258
  async updateMedia(id, data) {
3869
- const response = await this.client.put(`/media/${id}`, data);
4259
+ const response = await this.putWithPostFallback(this.client, `/media/${id}`, data);
3870
4260
  return response.data.media || response.data;
3871
4261
  }
3872
4262
  async updateMediaBatch(items) {
3873
- const response = await this.client.put('/media/batch', { items });
4263
+ const response = await this.putWithPostFallback(this.client, '/media/batch', { items });
3874
4264
  return response.data;
3875
4265
  }
3876
4266
  /**
@@ -3916,7 +4306,7 @@ export class WordPressClient {
3916
4306
  return response.data;
3917
4307
  }
3918
4308
  async updateMenu(id, data) {
3919
- const response = await this.client.put(`/menus/${id}`, data);
4309
+ const response = await this.putWithPostFallback(this.client, `/menus/${id}`, data);
3920
4310
  return response.data;
3921
4311
  }
3922
4312
  async deleteMenu(id, approval_token) {
@@ -3931,7 +4321,7 @@ export class WordPressClient {
3931
4321
  return response.data;
3932
4322
  }
3933
4323
  async assignMenuToLocation(location, menuId) {
3934
- 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 });
3935
4325
  return response.data;
3936
4326
  }
3937
4327
  // Menu Items
@@ -3948,7 +4338,7 @@ export class WordPressClient {
3948
4338
  return response.data;
3949
4339
  }
3950
4340
  async updateMenuItem(itemId, data) {
3951
- const response = await this.client.put(`/menus/items/${itemId}`, data);
4341
+ const response = await this.putWithPostFallback(this.client, `/menus/items/${itemId}`, data);
3952
4342
  return response.data;
3953
4343
  }
3954
4344
  async deleteMenuItem(itemId) {
@@ -3969,7 +4359,7 @@ export class WordPressClient {
3969
4359
  return response.data;
3970
4360
  }
3971
4361
  async woocommerceUpdateProduct(id, data) {
3972
- const response = await this.client.put(`/woocommerce/products/${id}`, data);
4362
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/products/${id}`, data);
3973
4363
  return response.data;
3974
4364
  }
3975
4365
  async woocommerceDuplicateProduct(id) {
@@ -3989,7 +4379,7 @@ export class WordPressClient {
3989
4379
  return response.data;
3990
4380
  }
3991
4381
  async woocommerceUpdateCategory(id, data) {
3992
- const response = await this.client.put(`/woocommerce/categories/${id}`, data);
4382
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/categories/${id}`, data);
3993
4383
  return response.data;
3994
4384
  }
3995
4385
  async woocommerceDeleteCategory(id) {
@@ -4009,7 +4399,7 @@ export class WordPressClient {
4009
4399
  return response.data;
4010
4400
  }
4011
4401
  async woocommerceUpdateTag(id, data) {
4012
- const response = await this.client.put(`/woocommerce/tags/${id}`, data);
4402
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/tags/${id}`, data);
4013
4403
  return response.data;
4014
4404
  }
4015
4405
  async woocommerceDeleteTag(id) {
@@ -4025,7 +4415,7 @@ export class WordPressClient {
4025
4415
  return response.data;
4026
4416
  }
4027
4417
  async woocommerceUpdateOrderStatus(id, status) {
4028
- const response = await this.client.put(`/woocommerce/orders/${id}/status`, { status });
4418
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/orders/${id}/status`, { status });
4029
4419
  return response.data;
4030
4420
  }
4031
4421
  async woocommerceGetStockStatus() {
@@ -4033,7 +4423,7 @@ export class WordPressClient {
4033
4423
  return response.data;
4034
4424
  }
4035
4425
  async woocommerceUpdateStock(id, data) {
4036
- const response = await this.client.put(`/woocommerce/products/${id}/stock`, data);
4426
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/products/${id}/stock`, data);
4037
4427
  return response.data;
4038
4428
  }
4039
4429
  async woocommerceSalesReport(params) {
@@ -4054,7 +4444,7 @@ export class WordPressClient {
4054
4444
  return response.data;
4055
4445
  }
4056
4446
  async woocommerceUpdateBrand(id, data) {
4057
- const response = await this.client.put(`/woocommerce/brands/${id}`, data);
4447
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/brands/${id}`, data);
4058
4448
  return response.data;
4059
4449
  }
4060
4450
  async woocommerceDeleteBrand(id) {
@@ -4152,7 +4542,7 @@ export class WordPressClient {
4152
4542
  return response.data;
4153
4543
  }
4154
4544
  async woocommerceSetVariationGallery(id, data) {
4155
- const response = await this.client.put(`/woocommerce/variations/${id}/gallery`, data);
4545
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/variations/${id}/gallery`, data);
4156
4546
  return response.data;
4157
4547
  }
4158
4548
  // WooCommerce Add-on v3.0 — coupons and customers
@@ -4165,7 +4555,7 @@ export class WordPressClient {
4165
4555
  return response.data;
4166
4556
  }
4167
4557
  async woocommerceUpdateCoupon(id, data) {
4168
- const response = await this.client.put(`/woocommerce/coupons/${id}`, data);
4558
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/coupons/${id}`, data);
4169
4559
  return response.data;
4170
4560
  }
4171
4561
  async woocommerceCreateCustomer(data) {
@@ -4173,7 +4563,7 @@ export class WordPressClient {
4173
4563
  return response.data;
4174
4564
  }
4175
4565
  async woocommerceUpdateCustomer(id, data) {
4176
- const response = await this.client.put(`/woocommerce/customers/${id}`, data);
4566
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/customers/${id}`, data);
4177
4567
  return response.data;
4178
4568
  }
4179
4569
  // WooCommerce Add-on v3.0 — order notes and refunds
@@ -4199,11 +4589,11 @@ export class WordPressClient {
4199
4589
  return response.data;
4200
4590
  }
4201
4591
  async woocommerceUpdateSubscriptionStatus(id, data) {
4202
- const response = await this.client.put(`/woocommerce/subscriptions/${id}/status`, data || {});
4592
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/subscriptions/${id}/status`, data || {});
4203
4593
  return response.data;
4204
4594
  }
4205
4595
  async woocommerceUpdateSubscriptionDates(id, data) {
4206
- const response = await this.client.put(`/woocommerce/subscriptions/${id}/dates`, data || {});
4596
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/subscriptions/${id}/dates`, data || {});
4207
4597
  return response.data;
4208
4598
  }
4209
4599
  async woocommerceAddSubscriptionNote(id, data) {
@@ -4219,11 +4609,11 @@ export class WordPressClient {
4219
4609
  return response.data;
4220
4610
  }
4221
4611
  async woocommerceUpdateBookingStatus(id, data) {
4222
- const response = await this.client.put(`/woocommerce/bookings/${id}/status`, data || {});
4612
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/bookings/${id}/status`, data || {});
4223
4613
  return response.data;
4224
4614
  }
4225
4615
  async woocommerceRescheduleBooking(id, data) {
4226
- const response = await this.client.put(`/woocommerce/bookings/${id}/reschedule`, data || {});
4616
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/bookings/${id}/reschedule`, data || {});
4227
4617
  return response.data;
4228
4618
  }
4229
4619
  async woocommerceListMembershipPlans(params) {
@@ -4239,11 +4629,11 @@ export class WordPressClient {
4239
4629
  return response.data;
4240
4630
  }
4241
4631
  async woocommerceUpdateMembershipStatus(id, data) {
4242
- const response = await this.client.put(`/woocommerce/memberships/${id}/status`, data || {});
4632
+ const response = await this.putWithPostFallback(this.client, `/woocommerce/memberships/${id}/status`, data || {});
4243
4633
  return response.data;
4244
4634
  }
4245
4635
  async woocommerceSetMembershipEndDate(id, data) {
4246
- 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 || {});
4247
4637
  return response.data;
4248
4638
  }
4249
4639
  // WooCommerce Add-on v3.1 — assisted checkout
@@ -4456,7 +4846,7 @@ export class WordPressClient {
4456
4846
  const { method, path, body } = resolver(args);
4457
4847
  const req = method === 'GET' ? this.client.get(path)
4458
4848
  : method === 'POST' ? this.client.post(path, body || {})
4459
- : method === 'PUT' ? this.client.put(path, body || {})
4849
+ : method === 'PUT' ? this.putWithPostFallback(this.client, path, body || {})
4460
4850
  : this.client.request({ method: 'DELETE', url: path, data: body || {} });
4461
4851
  const response = await req;
4462
4852
  return response.data;