@respira/wordpress-mcp-server 8.3.21 → 8.3.22
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 +25 -0
- package/dist/__tests__/303ad1de-fse-operation-schema.test.d.ts +2 -0
- package/dist/__tests__/303ad1de-fse-operation-schema.test.d.ts.map +1 -0
- package/dist/__tests__/303ad1de-fse-operation-schema.test.js +98 -0
- package/dist/__tests__/303ad1de-fse-operation-schema.test.js.map +1 -0
- package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.d.ts +29 -0
- package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.d.ts.map +1 -0
- package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.js +305 -0
- package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.js.map +1 -0
- package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.d.ts +31 -0
- package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.d.ts.map +1 -0
- package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.js +191 -0
- package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.js.map +1 -0
- package/dist/fse-operation-schema.d.ts +20 -0
- package/dist/fse-operation-schema.d.ts.map +1 -0
- package/dist/fse-operation-schema.js +75 -0
- package/dist/fse-operation-schema.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -4
- package/dist/server.js.map +1 -1
- package/dist/wordpress-client.d.ts +90 -9
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +442 -19
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +1 -1
package/dist/wordpress-client.js
CHANGED
|
@@ -536,27 +536,167 @@ function normalizeDeleteResponse(res, resourceType, id) {
|
|
|
536
536
|
* entirely. Two different payloads produced identical errors, because neither
|
|
537
537
|
* payload was ever delivered.
|
|
538
538
|
*
|
|
539
|
-
* 307 and 308 preserve the method and the body, so they are followed normally
|
|
540
|
-
*
|
|
539
|
+
* 307 and 308 preserve the method and the body, so they are followed normally
|
|
540
|
+
* while they stay on the same origin.
|
|
541
|
+
*
|
|
542
|
+
* A redirect of a write to a DIFFERENT origin (scheme, host or port) is refused
|
|
543
|
+
* whatever its status, with its own code. Following it would replay the write,
|
|
544
|
+
* API key header included, against an address nobody configured: a 307/308 is
|
|
545
|
+
* re-sent as-is, and follow-redirects keeps PUT and DELETE on a 301/302 too
|
|
546
|
+
* (only POST is downgraded). Tickets dfe2d236, 6e94282d, addc289e, 77aae005,
|
|
547
|
+
* c2dd9d87 (staging3.naa.edu, which 301s every request to its www host).
|
|
548
|
+
*
|
|
549
|
+
* The hook is called by follow-redirects as (options, responseDetails,
|
|
550
|
+
* requestDetails), after `options` has already been rewritten to the redirect
|
|
551
|
+
* target, so the origin of the request being redirected is only knowable from
|
|
552
|
+
* `requestDetails.url`. Without it, only the downgrade check can run.
|
|
553
|
+
*
|
|
554
|
+
* What is thrown here never reaches the caller as-is: follow-redirects wraps it
|
|
555
|
+
* in a RedirectionError and axios turns that into an AxiosError with no
|
|
556
|
+
* `response`. handleError() finds it again through `.cause` (see
|
|
557
|
+
* findRedirectRefusal) so the message is not rewritten into "no response
|
|
558
|
+
* received".
|
|
541
559
|
*
|
|
542
560
|
* @since mcp 8.3.18
|
|
543
561
|
*/
|
|
544
562
|
export function guardAgainstMethodDowngrade(originalMethod) {
|
|
545
|
-
const
|
|
546
|
-
|
|
563
|
+
const method = (originalMethod || 'GET').toUpperCase();
|
|
564
|
+
const isWrite = !['GET', 'HEAD', 'OPTIONS'].includes(method);
|
|
565
|
+
return (options, responseDetails, requestDetails) => {
|
|
547
566
|
if (!isWrite)
|
|
548
567
|
return;
|
|
549
568
|
const status = responseDetails?.statusCode;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const
|
|
569
|
+
const downgrades = status === 301 || status === 302 || status === 303;
|
|
570
|
+
const fromUrl = requestDetails?.url ? String(requestDetails.url) : null;
|
|
571
|
+
const rawLocation = responseDetails?.headers?.location;
|
|
572
|
+
let target = rawLocation ||
|
|
553
573
|
`${options?.protocol || ''}//${options?.host || ''}${options?.path || ''}`;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
574
|
+
try {
|
|
575
|
+
if (rawLocation && fromUrl)
|
|
576
|
+
target = new URL(String(rawLocation), fromUrl).href;
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
// Keep the raw Location if it cannot be resolved.
|
|
580
|
+
}
|
|
581
|
+
const originChange = describeOriginChange(fromUrl, target);
|
|
582
|
+
if (!downgrades && !originChange)
|
|
583
|
+
return;
|
|
584
|
+
const err = originChange
|
|
585
|
+
? new Error(`Refusing to follow a ${status} redirect on a ${method} request: the site sent it to a different address (${originChange.to_origin}), ` +
|
|
586
|
+
'and a write is only ever sent to the site URL Respira was configured with. ' +
|
|
587
|
+
(downgrades
|
|
588
|
+
? `A ${status} would also re-send it as a GET with the body discarded, so the write would silently become a read. `
|
|
589
|
+
: '') +
|
|
590
|
+
'Configure this site in Respira with the URL WordPress itself considers canonical (the Site Address in Settings > General) and the write will go straight through.')
|
|
591
|
+
: new Error(`Refusing to follow a ${status} redirect on a ${method} request: it would be re-sent as a GET with the body discarded, so the write would silently become a read. ` +
|
|
592
|
+
`The site redirected to ${target}. Configure this site in Respira with the URL WordPress itself considers canonical (the Site Address in Settings > General) and the write will go straight through.`);
|
|
593
|
+
err.code = originChange ? 'respira_site_url_redirects' : 'respira_write_redirect_downgrade';
|
|
594
|
+
err.redirectStatus = status;
|
|
595
|
+
err.redirectLocation = target;
|
|
596
|
+
err.redirectFrom = fromUrl;
|
|
597
|
+
err.redirectMethod = method;
|
|
557
598
|
throw err;
|
|
558
599
|
};
|
|
559
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* The origin change between two URLs, or null when they share an origin or
|
|
603
|
+
* either cannot be parsed. `toUrl` may be relative to `fromUrl`.
|
|
604
|
+
*/
|
|
605
|
+
function describeOriginChange(fromUrl, toUrl) {
|
|
606
|
+
if (!fromUrl || !toUrl)
|
|
607
|
+
return null;
|
|
608
|
+
try {
|
|
609
|
+
const from = new URL(String(fromUrl));
|
|
610
|
+
const to = new URL(String(toUrl), from);
|
|
611
|
+
if (from.protocol === to.protocol && from.host === to.host)
|
|
612
|
+
return null;
|
|
613
|
+
return { from_origin: from.origin, to_origin: to.origin };
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* The site URL to configure instead: the redirect target's origin, keeping the
|
|
621
|
+
* path of the configured URL (a WordPress in a subdirectory stays there).
|
|
622
|
+
*/
|
|
623
|
+
function suggestedSiteUrl(configuredUrl, location) {
|
|
624
|
+
try {
|
|
625
|
+
const configured = new URL(configuredUrl);
|
|
626
|
+
const target = new URL(location, configured);
|
|
627
|
+
return `${target.origin}${configured.pathname.replace(/\/+$/, '')}`;
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const REDIRECT_REFUSAL_CODES = new Set(['respira_site_url_redirects', 'respira_write_redirect_downgrade']);
|
|
634
|
+
/**
|
|
635
|
+
* The guardAgainstMethodDowngrade() error behind an axios error, if that is
|
|
636
|
+
* what ended the request. follow-redirects wraps it (RedirectionError, code
|
|
637
|
+
* ERR_FR_REDIRECTION_FAILURE) and axios wraps that again, each keeping the
|
|
638
|
+
* previous one as `.cause`.
|
|
639
|
+
*/
|
|
640
|
+
function findRedirectRefusal(error) {
|
|
641
|
+
let current = error;
|
|
642
|
+
for (let depth = 0; current && depth < 6; depth += 1) {
|
|
643
|
+
if (REDIRECT_REFUSAL_CODES.has(String(current.code || '')))
|
|
644
|
+
return current;
|
|
645
|
+
current = current.cause;
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
/** Verbs WordPress lets a POST stand in for via `_method` / X-HTTP-Method-Override. */
|
|
650
|
+
const METHOD_OVERRIDE_VERBS = new Set(['PUT', 'PATCH', 'DELETE']);
|
|
651
|
+
function headerValue(headers, name) {
|
|
652
|
+
if (!headers)
|
|
653
|
+
return '';
|
|
654
|
+
if (typeof headers.get === 'function') {
|
|
655
|
+
const value = headers.get(name);
|
|
656
|
+
if (value !== undefined && value !== null && value !== false)
|
|
657
|
+
return String(value);
|
|
658
|
+
}
|
|
659
|
+
const lower = name.toLowerCase();
|
|
660
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
661
|
+
if (key.toLowerCase() === lower && value !== undefined && value !== null)
|
|
662
|
+
return String(value);
|
|
663
|
+
}
|
|
664
|
+
return '';
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Did WordPress answer this write with a READ handler?
|
|
668
|
+
*
|
|
669
|
+
* Ticket 953dde4e. Some hosts rewrite PUT/PATCH/DELETE into a request WordPress
|
|
670
|
+
* dispatches as GET. On a route that also has a GET handler (pages, posts,
|
|
671
|
+
* media...) the read handler answers 200 and the write silently did nothing.
|
|
672
|
+
*
|
|
673
|
+
* The plugin's reliability layer (class-respira-mcp-reliability.php, since
|
|
674
|
+
* plugin 8.0) says which case this is. On every /respira/ response it sends
|
|
675
|
+
* `X-Respira-Idempotency-Supported: 1`. On a request it treats as a write
|
|
676
|
+
* (a write verb as WordPress saw it) that carries a valid Idempotency-Key it
|
|
677
|
+
* takes the lock before routing and answers with `X-Respira-Call-Id`, on
|
|
678
|
+
* success, on error, on rest_no_route and on replay alike. Both headers are
|
|
679
|
+
* set in the same function, on the same response object, since the same
|
|
680
|
+
* commit. So a 2xx that has the first header and not the second, for a write
|
|
681
|
+
* that sent a valid key, was dispatched by WordPress as GET/HEAD/OPTIONS: only
|
|
682
|
+
* a read handler ran, and re-sending it as a write cannot write twice.
|
|
683
|
+
* Verified live on withmaroua.com (PUT /respira/v1/ping: 200, Supported: 1, no
|
|
684
|
+
* Call-Id, Allow: GET) and on a local 8.8.34 site (a real PUT gets a Call-Id,
|
|
685
|
+
* a GET does not). Plugins older than 8.0 send neither header and are never
|
|
686
|
+
* second-guessed.
|
|
687
|
+
*/
|
|
688
|
+
function writeWasAnsweredAsRead(response, sentMethod) {
|
|
689
|
+
if (!METHOD_OVERRIDE_VERBS.has(String(sentMethod || '').toUpperCase()))
|
|
690
|
+
return false;
|
|
691
|
+
const status = Number(response?.status);
|
|
692
|
+
if (!(status >= 200 && status < 300))
|
|
693
|
+
return false;
|
|
694
|
+
if (headerValue(response?.headers, 'x-respira-idempotency-supported') !== '1')
|
|
695
|
+
return false;
|
|
696
|
+
if (headerValue(response?.headers, 'x-respira-call-id'))
|
|
697
|
+
return false;
|
|
698
|
+
return /^[A-Za-z0-9._:-]{8,128}$/.test(headerValue(response?.config?.headers, 'Idempotency-Key'));
|
|
699
|
+
}
|
|
560
700
|
export class WordPressClient {
|
|
561
701
|
client;
|
|
562
702
|
rootClient;
|
|
@@ -784,6 +924,19 @@ export class WordPressClient {
|
|
|
784
924
|
return instance.request(config);
|
|
785
925
|
}
|
|
786
926
|
const handledError = await this.handleError(error);
|
|
927
|
+
// Ticket 953dde4e: a PUT/PATCH/DELETE WordPress answered with
|
|
928
|
+
// rest_no_route (the host rewrote the verb before WordPress saw it), or
|
|
929
|
+
// a PATCH/DELETE rejected or dropped in front of WordPress. Re-send it
|
|
930
|
+
// once as a POST that names the original verb. PUT's non_wordpress
|
|
931
|
+
// shapes stay with putWithPostFallback, unchanged.
|
|
932
|
+
if (this.methodOverrideFallbackApplies(config, handledError)) {
|
|
933
|
+
try {
|
|
934
|
+
return await this.retryWithMethodOverride(config);
|
|
935
|
+
}
|
|
936
|
+
catch (retryError) {
|
|
937
|
+
throw this.noteMethodOverrideFailure(handledError, method, retryError);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
787
940
|
throw handledError;
|
|
788
941
|
};
|
|
789
942
|
// Success interceptor: detect HTML masquerading as JSON on Respira REST routes.
|
|
@@ -845,8 +998,34 @@ export class WordPressClient {
|
|
|
845
998
|
}
|
|
846
999
|
throw this.buildHtmlInsteadOfJsonError(observation, requestConfig);
|
|
847
1000
|
};
|
|
848
|
-
|
|
849
|
-
|
|
1001
|
+
// Ticket 953dde4e: the same verb-rewriting host, on a route that also has
|
|
1002
|
+
// a GET handler, answers the write 200 from the READ handler. See
|
|
1003
|
+
// writeWasAnsweredAsRead(). Only a read ran, so re-sending the write once
|
|
1004
|
+
// with the method override cannot write twice.
|
|
1005
|
+
const rescueWriteAnsweredAsRead = async (response) => {
|
|
1006
|
+
const config = response?.config || {};
|
|
1007
|
+
const method = String(config.method || '').toUpperCase();
|
|
1008
|
+
if (config._respiraMethodOverride || !writeWasAnsweredAsRead(response, method))
|
|
1009
|
+
return response;
|
|
1010
|
+
if (!isReplayableBody(config.data)) {
|
|
1011
|
+
throw this.codedError(`${method} ${config.url || ''} was answered by a read handler: this site rewrote the ${method} into a GET before WordPress saw it, so nothing was written. ` +
|
|
1012
|
+
'The request body is a stream and cannot be sent twice, so it was not retried.', 'respira_write_answered_as_read');
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
return await this.retryWithMethodOverride(config);
|
|
1016
|
+
}
|
|
1017
|
+
catch (retryError) {
|
|
1018
|
+
if (retryError instanceof Error && retryError.name !== 'respira_write_answered_as_read') {
|
|
1019
|
+
retryError.message +=
|
|
1020
|
+
`\n\n(This site rewrote the ${method} into a GET before WordPress saw it, so the first attempt was answered by a read handler and wrote nothing. ` +
|
|
1021
|
+
`Respira re-sent it once as POST with X-HTTP-Method-Override: ${method}; the error above is WordPress's answer to that retry.)`;
|
|
1022
|
+
}
|
|
1023
|
+
throw retryError;
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
const successInterceptor = async (response) => rescueWriteAnsweredAsRead(await htmlGuard(response));
|
|
1027
|
+
this.client.interceptors.response.use(successInterceptor, errorInterceptor);
|
|
1028
|
+
this.rootClient.interceptors.response.use(successInterceptor, errorInterceptor);
|
|
850
1029
|
}
|
|
851
1030
|
/**
|
|
852
1031
|
* Inspect a response for the "HTML instead of JSON" failure mode on Respira
|
|
@@ -1087,6 +1266,10 @@ export class WordPressClient {
|
|
|
1087
1266
|
},
|
|
1088
1267
|
validateStatus: (s) => s >= 200 && s < 300,
|
|
1089
1268
|
maxRedirects: 5,
|
|
1269
|
+
// This goes out through the global axios, so it does not get the guard
|
|
1270
|
+
// the two instances install per request. Without it a write retried
|
|
1271
|
+
// here would follow a redirect to another host (tickets dfe2d236 et al.).
|
|
1272
|
+
beforeRedirect: guardAgainstMethodDowngrade(String(originalConfig.method || 'get')),
|
|
1090
1273
|
_restRouteFallback: true,
|
|
1091
1274
|
};
|
|
1092
1275
|
return axios.request(retryConfig);
|
|
@@ -1411,12 +1594,15 @@ export class WordPressClient {
|
|
|
1411
1594
|
*
|
|
1412
1595
|
* This is safe for the whole set because the plugin registers all of them
|
|
1413
1596
|
* with `WP_REST_Server::EDITABLE`, which is literally `'POST, PUT, PATCH'`:
|
|
1414
|
-
* the fallback POST reaches the identical handler.
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1597
|
+
* the fallback POST reaches the identical handler. A bare POST cannot stand
|
|
1598
|
+
* in for DELETE the same way: those routes are registered `DELETABLE`, so it
|
|
1599
|
+
* would hit `rest_no_route`, or the route's POST handler where there is one.
|
|
1600
|
+
* DELETE and PATCH are covered instead by the method-override retry in the
|
|
1601
|
+
* response interceptor (retryWithMethodOverride, ticket 953dde4e): a POST
|
|
1602
|
+
* carrying `_method` and `X-HTTP-Method-Override`, which WordPress core
|
|
1603
|
+
* (WP_REST_Server::serve_request) dispatches to the original verb's
|
|
1604
|
+
* handler. This function is unchanged by that and still sends a plain POST,
|
|
1605
|
+
* so the hosts it already rescues see exactly the request they did before.
|
|
1420
1606
|
*/
|
|
1421
1607
|
async putWithPostFallback(client, url, data, config = {}) {
|
|
1422
1608
|
const callId = randomUUID();
|
|
@@ -1440,6 +1626,103 @@ export class WordPressClient {
|
|
|
1440
1626
|
return client.post(url, data, fallbackAttempt);
|
|
1441
1627
|
}
|
|
1442
1628
|
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Should this failed write be re-sent as a POST with the method override?
|
|
1631
|
+
*
|
|
1632
|
+
* - PUT, PATCH or DELETE, sent once, with a body that can be sent again.
|
|
1633
|
+
* - Either WordPress answered `rest_no_route`, or (PATCH/DELETE only) the
|
|
1634
|
+
* failure is one of the shapes isRetryableAsPost() already accepts for
|
|
1635
|
+
* PUT: a 403/404/405 from something in front of WordPress, or a dropped
|
|
1636
|
+
* connection. PUT's version of those stays in putWithPostFallback.
|
|
1637
|
+
*
|
|
1638
|
+
* A 403/404/405 raised BY WordPress for any other reason is still a decision
|
|
1639
|
+
* and still not retried; only `rest_no_route` is singled out, because it is
|
|
1640
|
+
* what WordPress says when the verb it received has no handler, which is
|
|
1641
|
+
* exactly what a rewritten verb produces.
|
|
1642
|
+
*/
|
|
1643
|
+
methodOverrideFallbackApplies(config, handled) {
|
|
1644
|
+
const method = String(config?.method || '').toUpperCase();
|
|
1645
|
+
if (!METHOD_OVERRIDE_VERBS.has(method))
|
|
1646
|
+
return false;
|
|
1647
|
+
if (!config || config._respiraMethodOverride || config.url === undefined || !isReplayableBody(config.data)) {
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
if (handled?.name === 'rest_no_route')
|
|
1651
|
+
return true;
|
|
1652
|
+
return method !== 'PUT' && isRetryableAsPost(handled);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Re-send a PUT, PATCH or DELETE once as a POST that tells WordPress which
|
|
1656
|
+
* verb it stands for. Ticket 953dde4e.
|
|
1657
|
+
*
|
|
1658
|
+
* WordPress core supports this for exactly this situation.
|
|
1659
|
+
* WP_REST_Server::serve_request() builds the request with the wire method,
|
|
1660
|
+
* then replaces it with `$_GET['_method']` if present, else with the
|
|
1661
|
+
* `X-HTTP-Method-Override` header, before dispatch; match_request_to_handler()
|
|
1662
|
+
* then picks the handler registered for THAT method (verified in core 7.0.2,
|
|
1663
|
+
* class-wp-rest-server.php serve_request / match_request_to_handler, and live:
|
|
1664
|
+
* a POST to a DELETABLE route with either form reaches the DELETE handler).
|
|
1665
|
+
* Both forms are sent. `_method` wins in core and survives a host that strips
|
|
1666
|
+
* unknown headers, which matters for DELETE: a bare POST on a route that is
|
|
1667
|
+
* DELETABLE and EDITABLE would land on the update handler.
|
|
1668
|
+
*
|
|
1669
|
+
* Why this is not a double write:
|
|
1670
|
+
* - After `rest_no_route`: core returns that error from
|
|
1671
|
+
* match_request_to_handler() and dispatch() hands it back without calling
|
|
1672
|
+
* respond_to_request(), so no permission_callback and no route callback
|
|
1673
|
+
* ran. (rest_pre_dispatch filters do run before routing. Respira's own
|
|
1674
|
+
* idempotency lock is one: when WordPress saw a GET it does nothing, and
|
|
1675
|
+
* when it saw the real verb it cached the 404, which the retry, sent with
|
|
1676
|
+
* the same Idempotency-Key and ORIGINAL_METHOD_HEADER, gets replayed.)
|
|
1677
|
+
* - After a write answered by a read handler: see writeWasAnsweredAsRead().
|
|
1678
|
+
* - After an edge rejection or a dropped connection: the same Idempotency-Key
|
|
1679
|
+
* and ORIGINAL_METHOD_HEADER that make PUT's fallback safe.
|
|
1680
|
+
*
|
|
1681
|
+
* Exactly one attempt. The retry is marked so neither interceptor retries it
|
|
1682
|
+
* again.
|
|
1683
|
+
*/
|
|
1684
|
+
async retryWithMethodOverride(config) {
|
|
1685
|
+
const method = String(config?.method || '').toUpperCase();
|
|
1686
|
+
const raw = config?.headers;
|
|
1687
|
+
const headers = raw && typeof raw.toJSON === 'function' ? { ...raw.toJSON() } : { ...(raw || {}) };
|
|
1688
|
+
for (const key of Object.keys(headers)) {
|
|
1689
|
+
const lower = key.toLowerCase();
|
|
1690
|
+
if (lower === 'content-length' || lower === 'x-http-method-override' || lower === ORIGINAL_METHOD_HEADER.toLowerCase()) {
|
|
1691
|
+
delete headers[key];
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
headers['X-HTTP-Method-Override'] = method;
|
|
1695
|
+
headers[ORIGINAL_METHOD_HEADER] = method;
|
|
1696
|
+
const retryConfig = {
|
|
1697
|
+
...config,
|
|
1698
|
+
method: 'post',
|
|
1699
|
+
headers,
|
|
1700
|
+
// Folded into the URL by computeRestRouteFallbackUrl in ?rest_route= mode.
|
|
1701
|
+
params: { ...(config?.params || {}), _method: method },
|
|
1702
|
+
_respiraMethodOverride: true,
|
|
1703
|
+
};
|
|
1704
|
+
const instance = typeof config?.baseURL === 'string' && config.baseURL.includes('/wp-json/respira/v1') ? this.client : this.rootClient;
|
|
1705
|
+
const response = await instance.request(retryConfig);
|
|
1706
|
+
if (writeWasAnsweredAsRead(response, method)) {
|
|
1707
|
+
throw this.codedError(`${method} ${config?.url || ''} was answered by a read handler twice. This site rewrites ${method} into a GET before WordPress sees it, ` +
|
|
1708
|
+
`and the retry as POST with X-HTTP-Method-Override: ${method} was not honoured either. Nothing was written. ` +
|
|
1709
|
+
`Ask the host to allow ${method} for /wp-json/respira/*.`, 'respira_write_answered_as_read');
|
|
1710
|
+
}
|
|
1711
|
+
return response;
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* The method-override retry did not help: surface the ORIGINAL error, which
|
|
1715
|
+
* is WordPress's answer to what was actually asked, and say the retry ran.
|
|
1716
|
+
*/
|
|
1717
|
+
noteMethodOverrideFailure(original, method, retryError) {
|
|
1718
|
+
const retryCode = String(retryError?.name || retryError?.code || 'error');
|
|
1719
|
+
const retryLine = String(retryError?.message || retryError || '').split('\n')[0].slice(0, 300);
|
|
1720
|
+
original.message +=
|
|
1721
|
+
`\n\nRespira also retried this once as POST with X-HTTP-Method-Override: ${method}, because some hosts rewrite or block ${method} before WordPress sees it. ` +
|
|
1722
|
+
`That retry failed too (${retryCode}: ${retryLine}), so the error above is the answer to the original ${method}.`;
|
|
1723
|
+
original.methodOverrideRetry = { attempted: true, retry_error_code: retryCode };
|
|
1724
|
+
return original;
|
|
1725
|
+
}
|
|
1443
1726
|
/**
|
|
1444
1727
|
* Generic v1 REST caller for endpoints under /wp-json/respira/v1/.
|
|
1445
1728
|
* Mirror of callRestV2 but for the existing v1 surface — useful for new tools
|
|
@@ -1832,6 +2115,17 @@ export class WordPressClient {
|
|
|
1832
2115
|
return apiError;
|
|
1833
2116
|
}
|
|
1834
2117
|
else if (error.request) {
|
|
2118
|
+
// A write refused by guardAgainstMethodDowngrade() lands here too: the
|
|
2119
|
+
// site DID answer, with a redirect, but follow-redirects reports the
|
|
2120
|
+
// refusal as a request error and axios gives it no `response`. Left to
|
|
2121
|
+
// the branch below it was described as "no response received" and an
|
|
2122
|
+
// unknown write outcome, in a few hundred milliseconds, while the real
|
|
2123
|
+
// cause (the configured URL redirects to another host) was in the
|
|
2124
|
+
// error's `.cause` the whole time. Tickets dfe2d236, 6e94282d, addc289e,
|
|
2125
|
+
// 77aae005, c2dd9d87.
|
|
2126
|
+
const redirectRefusal = findRedirectRefusal(error);
|
|
2127
|
+
if (redirectRefusal)
|
|
2128
|
+
return this.redirectRefusalError(redirectRefusal);
|
|
1835
2129
|
// No response received at all — DNS failure, connection refused, timeout, SSL error, etc.
|
|
1836
2130
|
const code = error.code;
|
|
1837
2131
|
let hint = '';
|
|
@@ -1907,6 +2201,47 @@ export class WordPressClient {
|
|
|
1907
2201
|
}
|
|
1908
2202
|
return this.codedError(`Unknown error: ${error.message}`, 'unknown_error');
|
|
1909
2203
|
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Describe a write that guardAgainstMethodDowngrade() refused.
|
|
2206
|
+
*
|
|
2207
|
+
* The outcome is known, which is the point: the site answered with a
|
|
2208
|
+
* redirect instead of running the request (Respira's handlers never answer
|
|
2209
|
+
* a write with a redirect), and the redirect was not followed, so nothing
|
|
2210
|
+
* was written anywhere.
|
|
2211
|
+
*/
|
|
2212
|
+
redirectRefusalError(refusal) {
|
|
2213
|
+
const method = String(refusal?.redirectMethod || 'write').toUpperCase();
|
|
2214
|
+
const status = refusal?.redirectStatus;
|
|
2215
|
+
const location = String(refusal?.redirectLocation || '');
|
|
2216
|
+
const configured = String(this.siteConfig.url || '').replace(/\/+$/, '');
|
|
2217
|
+
if (refusal?.code !== 'respira_site_url_redirects') {
|
|
2218
|
+
return this.codedError(String(refusal?.message || 'Refused a redirect on a write.'), 'respira_write_redirect_downgrade', {
|
|
2219
|
+
code: 'respira_write_redirect_downgrade',
|
|
2220
|
+
data: { redirect_status: status, redirect_location: location, configured_url: configured },
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
let targetOrigin = location;
|
|
2224
|
+
try {
|
|
2225
|
+
targetOrigin = new URL(location).origin;
|
|
2226
|
+
}
|
|
2227
|
+
catch {
|
|
2228
|
+
// Keep the raw Location.
|
|
2229
|
+
}
|
|
2230
|
+
const suggested = suggestedSiteUrl(configured, location) || targetOrigin;
|
|
2231
|
+
const where = this.siteConfig.id === 'env-site' ? 'the WORDPRESS_URL setting' : CONFIG_FILE;
|
|
2232
|
+
return this.codedError(`Your site URL redirects to ${targetOrigin}. ${configured} answered this ${method} with a ${status} redirect to ${location}. ` +
|
|
2233
|
+
`Update the URL for this site in your Respira config to ${suggested} (${where}). It should match the Site Address in WordPress under Settings > General.\n\n` +
|
|
2234
|
+
`Nothing was written. The site answered with a redirect instead of running the ${method}, and Respira does not re-send a write to an address other than the configured one, so it was not sent to ${targetOrigin} either. ` +
|
|
2235
|
+
'It is safe to retry once the URL is updated. Reads keep working because they follow the redirect, which is why only writes fail on this site.', 'respira_site_url_redirects', {
|
|
2236
|
+
code: 'respira_site_url_redirects',
|
|
2237
|
+
data: {
|
|
2238
|
+
redirect_status: status,
|
|
2239
|
+
redirect_location: location,
|
|
2240
|
+
configured_url: configured,
|
|
2241
|
+
suggested_url: suggested,
|
|
2242
|
+
},
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
1910
2245
|
/**
|
|
1911
2246
|
* Create a telemetry-safe Error while preserving the structured WP_Error
|
|
1912
2247
|
* envelope. Every HTTP/proxy/upstream branch must use this helper so useful
|
|
@@ -3500,6 +3835,23 @@ export class WordPressClient {
|
|
|
3500
3835
|
// Helper: capture only the response shape we care about.
|
|
3501
3836
|
const probe = async (label, method, url, extraHeaders = {}) => {
|
|
3502
3837
|
const headers = { ...this.defaultHeaders, ...extraHeaders };
|
|
3838
|
+
// Tickets dfe2d236 et al.: the probes follow redirects, so a site whose
|
|
3839
|
+
// configured URL 301s to another host came back all green while every
|
|
3840
|
+
// write failed. Record each hop so the redirect itself is reported.
|
|
3841
|
+
const redirects = [];
|
|
3842
|
+
const recordRedirect = (_options, responseDetails, requestDetails) => {
|
|
3843
|
+
const from = requestDetails?.url ? String(requestDetails.url) : null;
|
|
3844
|
+
const raw = String(responseDetails?.headers?.location || '');
|
|
3845
|
+
let location = raw;
|
|
3846
|
+
try {
|
|
3847
|
+
if (raw && from)
|
|
3848
|
+
location = new URL(raw, from).href;
|
|
3849
|
+
}
|
|
3850
|
+
catch {
|
|
3851
|
+
// Keep the raw Location.
|
|
3852
|
+
}
|
|
3853
|
+
redirects.push({ status: Number(responseDetails?.statusCode), from, location });
|
|
3854
|
+
};
|
|
3503
3855
|
try {
|
|
3504
3856
|
const resp = await axios.request({
|
|
3505
3857
|
method,
|
|
@@ -3511,6 +3863,7 @@ export class WordPressClient {
|
|
|
3511
3863
|
// Pass through raw body so we can inspect content-type vs payload.
|
|
3512
3864
|
transformResponse: (raw) => raw,
|
|
3513
3865
|
maxRedirects: 5,
|
|
3866
|
+
beforeRedirect: recordRedirect,
|
|
3514
3867
|
});
|
|
3515
3868
|
const respHeaders = {};
|
|
3516
3869
|
Object.entries(resp.headers || {}).forEach(([k, v]) => {
|
|
@@ -3549,6 +3902,7 @@ export class WordPressClient {
|
|
|
3549
3902
|
method,
|
|
3550
3903
|
url,
|
|
3551
3904
|
status: resp.status,
|
|
3905
|
+
...(redirects.length ? { redirects } : {}),
|
|
3552
3906
|
content_type: ct || null,
|
|
3553
3907
|
looks_like_html: looksHtml,
|
|
3554
3908
|
title_text: titleText,
|
|
@@ -3575,6 +3929,7 @@ export class WordPressClient {
|
|
|
3575
3929
|
url,
|
|
3576
3930
|
error: err?.message || String(err),
|
|
3577
3931
|
code: err?.code ?? null,
|
|
3932
|
+
...(redirects.length ? { redirects } : {}),
|
|
3578
3933
|
});
|
|
3579
3934
|
}
|
|
3580
3935
|
};
|
|
@@ -3696,6 +4051,39 @@ export class WordPressClient {
|
|
|
3696
4051
|
// An HTML 4xx/5xx is something in front of WordPress answering instead.
|
|
3697
4052
|
const putReachedWordPress = !!putProbe && typeof putProbe.status === 'number' && putProbe.looks_like_html === false;
|
|
3698
4053
|
const putBlockedAtEdge = !!putProbe && typeof putProbe.status === 'number' && putProbe.status >= 400 && !putReachedWordPress;
|
|
4054
|
+
// Ticket 953dde4e. /respira/v1/ping registers a GET handler and nothing
|
|
4055
|
+
// else (class-respira-api.php), so the healthy answer to the PUT probe is
|
|
4056
|
+
// a JSON 404 rest_no_route. A 2xx JSON answer means WordPress dispatched
|
|
4057
|
+
// the PUT as a GET: the host rewrote the verb. It used to count as
|
|
4058
|
+
// `put_reached_wordpress` and grade the site `not_blocked`.
|
|
4059
|
+
const putDispatchedAsRead = !!putProbe &&
|
|
4060
|
+
typeof putProbe.status === 'number' &&
|
|
4061
|
+
putProbe.status >= 200 &&
|
|
4062
|
+
putProbe.status < 300 &&
|
|
4063
|
+
putProbe.looks_like_html === false;
|
|
4064
|
+
// Tickets dfe2d236, 6e94282d, addc289e, 77aae005, c2dd9d87. The first
|
|
4065
|
+
// redirect hop of any probe that leaves the configured origin. Reads follow
|
|
4066
|
+
// it and work; every write is refused (guardAgainstMethodDowngrade), so a
|
|
4067
|
+
// site like this is not healthy however green the GET probes look.
|
|
4068
|
+
const siteUrlRedirect = (() => {
|
|
4069
|
+
for (const p of probes) {
|
|
4070
|
+
const first = Array.isArray(p.redirects) ? p.redirects[0] : null;
|
|
4071
|
+
if (!first)
|
|
4072
|
+
continue;
|
|
4073
|
+
const change = describeOriginChange(first.from || `${baseUrl}/`, first.location);
|
|
4074
|
+
if (!change)
|
|
4075
|
+
continue;
|
|
4076
|
+
return {
|
|
4077
|
+
status: first.status,
|
|
4078
|
+
from: first.from,
|
|
4079
|
+
location: first.location,
|
|
4080
|
+
target_origin: change.to_origin,
|
|
4081
|
+
suggested_url: suggestedSiteUrl(baseUrl, first.location),
|
|
4082
|
+
seen_on_probe: p.label,
|
|
4083
|
+
};
|
|
4084
|
+
}
|
|
4085
|
+
return null;
|
|
4086
|
+
})();
|
|
3699
4087
|
// Ticket 44fcf6b5. A 401/407 is not a method block, it is the server
|
|
3700
4088
|
// asking for a credential, and the two need opposite fixes. Grading an
|
|
3701
4089
|
// authentication challenge as "the edge is blocking non-GET methods"
|
|
@@ -3711,6 +4099,10 @@ export class WordPressClient {
|
|
|
3711
4099
|
const optionsAuthChallenged = authChallenged(optionsProbe);
|
|
3712
4100
|
const httpAuthConfigured = !!(this.siteConfig.httpAuth?.username && this.siteConfig.httpAuth?.password);
|
|
3713
4101
|
const writeMethodVerdict = (() => {
|
|
4102
|
+
// First, and regardless of the GET probes: they followed the redirect,
|
|
4103
|
+
// which is exactly why they look healthy.
|
|
4104
|
+
if (siteUrlRedirect)
|
|
4105
|
+
return 'site_url_redirects';
|
|
3714
4106
|
// If plain GET is not healthy, nothing can be concluded about writes
|
|
3715
4107
|
// specifically — the whole connection is the problem.
|
|
3716
4108
|
if (!getOk)
|
|
@@ -3723,6 +4115,8 @@ export class WordPressClient {
|
|
|
3723
4115
|
return 'auth_challenge';
|
|
3724
4116
|
if (optionsBlocked || putBlockedAtEdge)
|
|
3725
4117
|
return 'blocked';
|
|
4118
|
+
if (putDispatchedAsRead)
|
|
4119
|
+
return 'verb_rewritten';
|
|
3726
4120
|
if (putDropped || optionsDropped)
|
|
3727
4121
|
return 'unknown';
|
|
3728
4122
|
if (putReachedWordPress)
|
|
@@ -3733,7 +4127,17 @@ export class WordPressClient {
|
|
|
3733
4127
|
// check still means "proven blocked" and never accidentally fires on the
|
|
3734
4128
|
// unknown case. null = could not tell — which is the honest answer for an
|
|
3735
4129
|
// auth challenge as well as for a dropped probe.
|
|
3736
|
-
|
|
4130
|
+
// true also covers the two states where the write verb provably does not
|
|
4131
|
+
// arrive as sent: the site URL redirects (every write is refused) and the
|
|
4132
|
+
// host rewrites the verb (the connector works around it, see
|
|
4133
|
+
// retryWithMethodOverride, but the verb itself is not getting through).
|
|
4134
|
+
const writeMethodBlocked = writeMethodVerdict === 'blocked' ||
|
|
4135
|
+
writeMethodVerdict === 'site_url_redirects' ||
|
|
4136
|
+
writeMethodVerdict === 'verb_rewritten'
|
|
4137
|
+
? true
|
|
4138
|
+
: writeMethodVerdict === 'not_blocked'
|
|
4139
|
+
? false
|
|
4140
|
+
: null;
|
|
3737
4141
|
const forceWriteMethodHint = ' Unblock this connection immediately without touching the host: add ' +
|
|
3738
4142
|
`"forceWriteMethod": "post" to the site object in ${CONFIG_FILE}. The Respira plugin ` +
|
|
3739
4143
|
'registers the same handler on PUT and POST for every updatable resource, so POST does ' +
|
|
@@ -3802,6 +4206,21 @@ export class WordPressClient {
|
|
|
3802
4206
|
forceWriteMethodHint);
|
|
3803
4207
|
}
|
|
3804
4208
|
}
|
|
4209
|
+
if (writeMethodVerdict === 'verb_rewritten') {
|
|
4210
|
+
recommendations.push(`PUT /wp-json/respira/v1/ping returned ${putProbe?.status} with the ping response, but /ping only registers a GET handler: ` +
|
|
4211
|
+
'something in front of WordPress rewrote the PUT into a GET. Every PUT, PATCH and DELETE on this site reaches WordPress as a read. ' +
|
|
4212
|
+
'On a route with no GET handler WordPress answers rest_no_route; on a route that has one (pages, posts, media) the read handler answers and nothing is written. ' +
|
|
4213
|
+
'This connector detects both and re-sends the write once as POST with X-HTTP-Method-Override and _method set to the original verb, which WordPress core honours, ' +
|
|
4214
|
+
'so writes work without a config change. The host is still worth asking to stop rewriting PUT/PATCH/DELETE for /wp-json/respira/*.');
|
|
4215
|
+
}
|
|
4216
|
+
if (siteUrlRedirect) {
|
|
4217
|
+
const where = this.siteConfig.id === 'env-site' ? 'the WORDPRESS_URL setting' : CONFIG_FILE;
|
|
4218
|
+
recommendations.unshift(`Your site URL redirects: ${baseUrl} answered with a ${siteUrlRedirect.status} to ${siteUrlRedirect.target_origin} ` +
|
|
4219
|
+
`(${siteUrlRedirect.seen_on_probe}: ${siteUrlRedirect.location}). Reads follow the redirect and work, but Respira does not re-send a write ` +
|
|
4220
|
+
'to a different address than the one configured, so every write (create, update, delete) fails on this site. ' +
|
|
4221
|
+
`Update the URL for this site in your Respira config to ${siteUrlRedirect.suggested_url || siteUrlRedirect.target_origin} (${where}). ` +
|
|
4222
|
+
'It should match the Site Address in WordPress under Settings > General.');
|
|
4223
|
+
}
|
|
3805
4224
|
if (restRouteFallbackWorked) {
|
|
3806
4225
|
this.lastRestRouteFallbackWorked = true;
|
|
3807
4226
|
recommendations.push('Pretty `/wp-json/respira/...` path returned HTML but `?rest_route=/respira/...` returned JSON. ' +
|
|
@@ -3834,6 +4253,9 @@ export class WordPressClient {
|
|
|
3834
4253
|
// `write_method_verdict` for the named state; never treat null as false.
|
|
3835
4254
|
write_method_blocked: writeMethodBlocked,
|
|
3836
4255
|
write_method_verdict: writeMethodVerdict,
|
|
4256
|
+
// The configured URL redirects to another origin: { status, from,
|
|
4257
|
+
// location, target_origin, suggested_url, seen_on_probe }, or null.
|
|
4258
|
+
site_url_redirect: siteUrlRedirect,
|
|
3837
4259
|
// Ticket 44fcf6b5: whether a credential was configured and sent is part
|
|
3838
4260
|
// of the evidence, not background knowledge the reader is assumed to
|
|
3839
4261
|
// have. Without it, "401 on PUT" and "401 on PUT with the password
|
|
@@ -3847,6 +4269,7 @@ export class WordPressClient {
|
|
|
3847
4269
|
put_dropped: putDropped,
|
|
3848
4270
|
put_auth_challenged: putAuthChallenged,
|
|
3849
4271
|
put_reached_wordpress: putReachedWordPress,
|
|
4272
|
+
put_dispatched_as_read: putDispatchedAsRead,
|
|
3850
4273
|
put_error: putProbe?.error ?? null,
|
|
3851
4274
|
put_error_code: putProbe?.code ?? null,
|
|
3852
4275
|
put_www_authenticate: putProbe?.headers?.['www-authenticate'] ?? null,
|