@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.
- package/CHANGELOG.md +89 -0
- package/TOOL_CATALOG.md +36 -36
- package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.d.ts +2 -0
- package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.d.ts.map +1 -0
- package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js +91 -0
- package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js.map +1 -0
- package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.d.ts +30 -0
- package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.d.ts.map +1 -0
- package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.js +162 -0
- package/dist/__tests__/139f7353-put-fallback-dedicated-methods.test.js.map +1 -0
- package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.d.ts +2 -0
- package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.d.ts.map +1 -0
- package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.js +207 -0
- package/dist/__tests__/44fcf6b5-basic-auth-is-not-a-blocked-verb.test.js.map +1 -0
- package/dist/__tests__/e105ed25-report-issue-builder-context.test.d.ts +2 -0
- package/dist/__tests__/e105ed25-report-issue-builder-context.test.d.ts.map +1 -0
- package/dist/__tests__/e105ed25-report-issue-builder-context.test.js +115 -0
- package/dist/__tests__/e105ed25-report-issue-builder-context.test.js.map +1 -0
- package/dist/__tests__/respira-outage-blamed-on-the-site.test.d.ts +2 -0
- package/dist/__tests__/respira-outage-blamed-on-the-site.test.d.ts.map +1 -0
- package/dist/__tests__/respira-outage-blamed-on-the-site.test.js +83 -0
- package/dist/__tests__/respira-outage-blamed-on-the-site.test.js.map +1 -0
- package/dist/__tests__/upload-media-path-detection.test.js +35 -1
- package/dist/__tests__/upload-media-path-detection.test.js.map +1 -1
- package/dist/__tests__/v2-negotiation-ttl-retry.test.d.ts +2 -0
- package/dist/__tests__/v2-negotiation-ttl-retry.test.d.ts.map +1 -0
- package/dist/__tests__/v2-negotiation-ttl-retry.test.js +108 -0
- package/dist/__tests__/v2-negotiation-ttl-retry.test.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +59 -4
- package/dist/config.js.map +1 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +54 -4
- package/dist/server.js.map +1 -1
- package/dist/wordpress-client.d.ts +108 -1
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +467 -77
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +2 -2
- package/skills/woo-marketing-campaigns/SKILL.md +128 -0
- package/skills/woo-pricing-promotions/SKILL.md +119 -0
- package/tool-capabilities.json +92 -344
package/dist/wordpress-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
267
|
-
(failure.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
`
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1423
|
+
const extraHeaders = (config.headers || {});
|
|
1424
|
+
const firstAttempt = { ...config, headers: { ...extraHeaders, 'Idempotency-Key': callId } };
|
|
1165
1425
|
const fallbackAttempt = {
|
|
1166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
'
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|