@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.
@@ -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
- * Only the method-rewriting statuses are refused, and only for writes.
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 isWrite = !['GET', 'HEAD', 'OPTIONS'].includes((originalMethod || 'GET').toUpperCase());
546
- return (options, responseDetails) => {
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
- if (status !== 301 && status !== 302 && status !== 303)
551
- return;
552
- const target = responseDetails?.headers?.location ||
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
- const err = new Error(`Refusing to follow a ${status} redirect on a ${originalMethod.toUpperCase()} request: it would be re-sent as a GET with the body discarded, so the write would silently become a read. ` +
555
- `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.`);
556
- err.code = 'respira_write_redirect_downgrade';
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
- this.client.interceptors.response.use(htmlGuard, errorInterceptor);
849
- this.rootClient.interceptors.response.use(htmlGuard, errorInterceptor);
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. 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.
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
- const writeMethodBlocked = writeMethodVerdict === 'blocked' ? true : writeMethodVerdict === 'not_blocked' ? false : null;
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,