Summary
WordPressLoginClient cannot complete the self-hosted login / site-details fetch when the target site uses plain permalinks (permalink_structure empty). Discovery succeeds and an Application Password is issued, but every subsequent REST request resolves to the API index (or 404s) instead of the requested endpoint, so site details never load. In the WordPress iOS app this surfaces as "Cannot load the WordPress site details" (wordpress-mobile/WordPress-iOS, SelfHostedSiteAuthenticator.swift).
Root cause
On a plain-permalink site, WordPress core advertises the REST API root — in the Link; rel="https://api.w.org/" header and in every _links href — as the query-parameter form, not a path:
Link: https://example.com/index.php?rest_route=/; rel="https://api.w.org/"
This is core-default behavior (get_rest_url(), the non-pretty branch deliberately builds …/index.php?rest_route=) and the documented universal form.
WordPressLoginClient takes that href as the API root (wp_api/src/login/url_discovery.rs, API_ROOT_LINK_HEADER = "https://api.w.org/") and then builds endpoint URLs by path-extending it:
- wp_api/src/request/endpoint.rs → WpOrgSiteApiUrlResolver::resolve calls
- wp_api/src/parsed_url.rs → by_extending_and_splitting_by_forward_slash, which is url.extend(segments) — itappends to the path and leaves the ?rest_route=/ query untouched.
So a request for /wp/v2/users/me becomes:
https://example.com/index.php/wp/v2/users/me?rest_route=/
WordPress ignores the path segments and honors rest_route=/, returning the API index for every endpoint (or, on servers without a front-controller catch-all, a hard 404). The client parses that root document as the site-details response and fails. The hard-coded /wp-json fallback never fires because discovery itself "succeeded." A repo-wide grep for rest_route finds no handling in the self-hosted discovery/login path (only the WP.com endpoint).
Reproduction
Any self-hosted site with Settings → Permalinks = Plain:
# 1. Advertised API root is the query form:
curl -sI https://SITE/ | grep -i '^link:'
# link: <https://SITE/index.php?rest_route=/>; rel="https://api.w.org/"
# 2. The exact URL the client builds collapses to the API index:
curl -s "https://SITE/index.php/wp/v2/users/me?rest_route=/" | head -c 120
# {"name":"…","description":"…","url":"…", …} ← API ROOT, not the user resource
(wp_rs_cli also can't express this root: --api-root is required to end with /wp-json.)
Expected
The client should support the ?rest_route= API-root form. Either:
- When the discovered root carries a rest_route query param, build endpoints by appending to that query value (…/index.php?rest_route=/wp/v2/users/me) rather than the path; or
- On discovering the query-form root, probe /wp-json/ and prefer it only if it routes — otherwise use the rest_route query form consistently.
Environment
Workaround (server-side — and a clean controlled confirmation)
Making WordPress advertise the path form fixes it without touching the client, which isolates the client-side path-extension as the defect:
- .htaccess: RewriteRule ^wp-json/(.*)$ index.php?rest_route=/$1 [QSA,L]
- mu-plugin: add_filter('rest_url', fn($u) => str_replace('/index.php?rest_route=/', '/wp-json/', $u));
- (or enable pretty permalinks)
General Note
I am not a PHP developer or familiar with the low level WordPress code. The above was determined by debugging my system with Claude Code after updating to WordPress 7.0 for my WordPress instance.
Summary
WordPressLoginClient cannot complete the self-hosted login / site-details fetch when the target site uses plain permalinks (permalink_structure empty). Discovery succeeds and an Application Password is issued, but every subsequent REST request resolves to the API index (or 404s) instead of the requested endpoint, so site details never load. In the WordPress iOS app this surfaces as "Cannot load the WordPress site details" (wordpress-mobile/WordPress-iOS, SelfHostedSiteAuthenticator.swift).
Root cause
On a plain-permalink site, WordPress core advertises the REST API root — in the Link; rel="https://api.w.org/" header and in every _links href — as the query-parameter form, not a path:
Link: https://example.com/index.php?rest_route=/; rel="https://api.w.org/"
This is core-default behavior (get_rest_url(), the non-pretty branch deliberately builds …/index.php?rest_route=) and the documented universal form.
WordPressLoginClient takes that href as the API root (wp_api/src/login/url_discovery.rs, API_ROOT_LINK_HEADER = "https://api.w.org/") and then builds endpoint URLs by path-extending it:
So a request for /wp/v2/users/me becomes:
https://example.com/index.php/wp/v2/users/me?rest_route=/
WordPress ignores the path segments and honors rest_route=/, returning the API index for every endpoint (or, on servers without a front-controller catch-all, a hard 404). The client parses that root document as the site-details response and fails. The hard-coded /wp-json fallback never fires because discovery itself "succeeded." A repo-wide grep for rest_route finds no handling in the self-hosted discovery/login path (only the WP.com endpoint).
Reproduction
Any self-hosted site with Settings → Permalinks = Plain:
(wp_rs_cli also can't express this root: --api-root is required to end with /wp-json.)
Expected
The client should support the ?rest_route= API-root form. Either:
Environment
Workaround (server-side — and a clean controlled confirmation)
Making WordPress advertise the path form fixes it without touching the client, which isolates the client-side path-extension as the defect:
General Note
I am not a PHP developer or familiar with the low level WordPress code. The above was determined by debugging my system with Claude Code after updating to WordPress 7.0 for my WordPress instance.