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