@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/__tests__/303ad1de-fse-operation-schema.test.d.ts +2 -0
  3. package/dist/__tests__/303ad1de-fse-operation-schema.test.d.ts.map +1 -0
  4. package/dist/__tests__/303ad1de-fse-operation-schema.test.js +98 -0
  5. package/dist/__tests__/303ad1de-fse-operation-schema.test.js.map +1 -0
  6. package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.d.ts +29 -0
  7. package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.d.ts.map +1 -0
  8. package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.js +305 -0
  9. package/dist/__tests__/953dde4e-verb-rewriting-host-method-override.test.js.map +1 -0
  10. package/dist/__tests__/9dee06c4-patch-identifier-vocabulary.test.d.ts +2 -0
  11. package/dist/__tests__/9dee06c4-patch-identifier-vocabulary.test.d.ts.map +1 -0
  12. package/dist/__tests__/9dee06c4-patch-identifier-vocabulary.test.js +130 -0
  13. package/dist/__tests__/9dee06c4-patch-identifier-vocabulary.test.js.map +1 -0
  14. package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.d.ts +31 -0
  15. package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.d.ts.map +1 -0
  16. package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.js +191 -0
  17. package/dist/__tests__/dfe2d236-site-url-redirects-on-write.test.js.map +1 -0
  18. package/dist/fse-operation-schema.d.ts +20 -0
  19. package/dist/fse-operation-schema.d.ts.map +1 -0
  20. package/dist/fse-operation-schema.js +75 -0
  21. package/dist/fse-operation-schema.js.map +1 -0
  22. package/dist/patch-identifier.d.ts +5 -0
  23. package/dist/patch-identifier.d.ts.map +1 -0
  24. package/dist/patch-identifier.js +87 -0
  25. package/dist/patch-identifier.js.map +1 -0
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +12 -8
  28. package/dist/server.js.map +1 -1
  29. package/dist/wordpress-client.d.ts +90 -9
  30. package/dist/wordpress-client.d.ts.map +1 -1
  31. package/dist/wordpress-client.js +447 -20
  32. package/dist/wordpress-client.js.map +1 -1
  33. package/package.json +1 -1
@@ -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
- * Only the method-rewriting statuses are refused, and only for writes.
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 isWrite = !['GET', 'HEAD', 'OPTIONS'].includes((originalMethod || 'GET').toUpperCase());
546
- return (options, responseDetails) => {
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
- if (status !== 301 && status !== 302 && status !== 303)
551
- return;
552
- const target = responseDetails?.headers?.location ||
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
- 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';
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
- this.client.interceptors.response.use(htmlGuard, errorInterceptor);
849
- this.rootClient.interceptors.response.use(htmlGuard, errorInterceptor);
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. 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.
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
- const payload = { operations };
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
- const writeMethodBlocked = writeMethodVerdict === 'blocked' ? true : writeMethodVerdict === 'not_blocked' ? false : null;
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,