Skip to content

Fix self-hosted login on plain-permalink sites (#1366)#1369

Open
jkmassel wants to merge 7 commits into
trunkfrom
jkmassel/issue-1366
Open

Fix self-hosted login on plain-permalink sites (#1366)#1369
jkmassel wants to merge 7 commits into
trunkfrom
jkmassel/issue-1366

Conversation

@jkmassel

@jkmassel jkmassel commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #1366. Self-hosted login fails on sites with plain permalinks: every REST request resolves to the API index instead of the requested endpoint, so site details never load and the WordPress iOS app surfaces "Cannot load the WordPress site details."

Root cause

On plain-permalink sites, WordPress advertises the REST API root in the Link: rel="https://api.w.org/" header as the query-parameter form:

Link: <https://example.com/index.php?rest_route=/>; rel="https://api.w.org/"

WpLoginClient accepts that URL as the API root, and WpOrgSiteApiUrlResolver::resolve then builds endpoint URLs by path-extending it:

https://example.com/index.php?rest_route=/  +  /wp/v2/users/me
        ↓ (path extension)
https://example.com/index.php/wp/v2/users/me?rest_route=/

WordPress ignores the path segments and honors rest_route=/, returning the API root JSON for every endpoint. The deserializer tries to parse it as the requested resource and fails.

Changes

  1. The fix (wp_api/src/parsed_url.rs, wp_api/src/request/endpoint.rs): new ParsedUrl::by_extending_rest_api_path helper that detects a rest_route query parameter on the API root and appends into its value rather than the path. WpOrgSiteApiUrlResolver::resolve calls it. No constructors or callers change — the URL itself encodes which form to use.

  2. wp_rs_cli (wp_rs_cli/...): the --api-root flag never actually enforced a /wp-json suffix in code; only the help text and error message claimed it did. Updates the help text and README to acknowledge both forms, and adds a worked example for plain-permalink sites.

  3. Dedicated integration test (docker-compose.plain-permalinks.yml, scripts/setup-plain-permalinks-test-site.sh, wp_api_integration_tests/tests/test_login_plain_permalinks.rs, Makefile targets): a separate WordPress Docker container with plain permalinks, on port 8081, running an end-to-end login + users/me test. Test self-skips when the dedicated server isn't up, so the default suite is unaffected.

  4. CI (.buildkite/pipeline.yml): new step in the e2e group that brings up the dedicated server and runs the integration test.

Test plan

  • cargo test --lib — 1736 tests pass
  • Unit tests for by_extending_rest_api_path cover pretty permalinks, ?rest_route=/, ?rest_route= (no trailing slash), and rest_route mixed with other query params
  • Resolver-level test confirms both URL shapes produce the right endpoint URL
  • Integration test passes against the dedicated plain-permalinks WordPress instance
  • Integration test fails on trunk without the fix (response parse error: missing field 'id' at line 1 column 186231 — the deserializer hitting the API root JSON instead of users/me)
  • wp_rs_cli fetch-post --api-root 'http://localhost:8081/index.php?rest_route=/' --post-id 1 --username … --password … --pretty returns the expected post JSON
  • cargo fmt --all -- --check, clippy clean on touched files (one pre-existing iter-kv-map lint in url_discovery.rs:121 is unrelated and present on trunk)
  • CI Buildkite e2e job for plain permalinks passes

Related issues

Changelog

  • I've added an entry to CHANGELOG.md under ## [Unreleased], using the Keep a Changelog categories (Added, Changed, Deprecated, Removed, Fixed, Security). Prefix breaking changes with **BREAKING:**.

jkmassel added 4 commits June 1, 2026 11:25
On plain-permalink sites, WordPress advertises the REST API root as
`…/index.php?rest_route=/` rather than `…/wp-json`. Discovery accepted
that URL, but `WpOrgSiteApiUrlResolver` then path-extended it to build
endpoint URLs — producing `…/index.php/wp/v2/users/me?rest_route=/`,
which WordPress routes to the API index. Every endpoint silently
collapsed, breaking self-hosted login (#1366).

Detect the query-parameter form on the discovered `api_root_url` and
append into the `rest_route` value instead of the path. The URL itself
carries the form information, so no callers or constructors change.

Includes a dedicated Docker setup (`docker-compose.plain-permalinks.yml`
+ `make start-plain-permalinks-test-server`) and a regression test that
runs against a real plain-permalinks WordPress instance.

Fixes #1366
The CLI's `--api-root` flag never actually enforced a `/wp-json` suffix
in code — only the help text and error message claimed it did. Now that
the resolver handles plain-permalink sites, update the help text and
README example to document both forms and drop the misleading error
message.
Adds a step in the e2e group that brings up the dedicated WordPress
instance and runs the regression test from #1366. Rust is installed on
the agent (matching the pattern used by the Kotlin step), then cargo
test runs on the host against the docker-mapped port 8081.
Documents the plain-permalink REST API root fix under [Unreleased] →
Fixed, per the project's changelog convention.
@wpmobilebot

wpmobilebot commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

XCFramework Build

This PR's XCFramework is available for testing. Add to your Package.swift:

.package(url: "https://github.com/automattic/wordpress-rs", branch: "pr-build/1369")

Built from 964623d

jkmassel added 2 commits June 1, 2026 11:56
The first CI attempt ran `cargo test` directly on the Buildkite agent,
which has no OpenSSL dev libraries — `openssl-sys` failed to build. It
only passed locally because the dev Mac has openssl.

Run cargo *inside* the dedicated WordPress container instead, matching
the main `test-rust-integration` flow. The container image already
provides the Rust toolchain and `libssl-dev`. Inside the container the
site is reachable at `http://localhost` (port 80), so `wp core install`
now configures that URL rather than the host-mapped `:8081` (which the
compose file still exposes for local debugging).
@jkmassel jkmassel requested review from crazytonyli and oguzkocer June 2, 2026 01:33
--skip-email

# Default permalink_structure is empty, but be explicit so the intent is clear.
wp rewrite structure ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this command the only difference between this new local site and the existing local site? If that is the case, is it possible to simplify the test setup by making the new test a "mut" test where it calls the same wp command to modify the site?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible, but I'd worry about downstream breakage.

WDYT @oguzkocer – I know you built this to start with?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd work. The whole setup is built in a way that restores the site to a known state. So, if I am not mistaken, all you need is to add a new endpoint to wp_api_integration_tests_backend/src/main.rs to run the command wp rewrite structure '' and then you can follow the existing mut tests setup with #[serial] annotation and call RestoreServer::db().await; at the end.

@oguzkocer oguzkocer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix looks good. I haven't reviewed the setup changes because as mentioned by @crazytonyli we can probably make this a regular mut test instead. I've also left one suggestion.

--skip-email

# Default permalink_structure is empty, but be explicit so the intent is clear.
wp rewrite structure ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd work. The whole setup is built in a way that restores the site to a known state. So, if I am not mistaken, all you need is to add a new endpoint to wp_api_integration_tests_backend/src/main.rs to run the command wp rewrite structure '' and then you can follow the existing mut tests setup with #[serial] annotation and call RestoreServer::db().await; at the end.

Comment thread wp_api/src/parsed_url.rs
Comment on lines +51 to +63
let appended_path = segments
.into_iter()
.flat_map(|s| {
s.as_ref()
.split('/')
.filter_map(|x| match x.trim() {
"" => None,
y => Some(y.to_string()),
})
.collect::<Vec<String>>()
})
.collect::<Vec<_>>()
.join("/");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the no-rest_route case can delegate directly to by_extending_and_splitting_by_forward_slash. If we handle that path first and early-return, we can drop the pre-join here (lines 51-63) and the second split → filter_map pass it triggers at line 92 — only the rest_route branch actually needs the joined string.

…#1411)

Replace the dedicated plain-permalinks WordPress instance with a serial
test that flips the shared test server to plain permalinks via wp-cli,
runs API discovery, and restores the original permalink structure as its
last step. This removes the extra docker-compose stack, setup script,
Makefile targets, and Buildkite step in favor of the existing mutable
test harness.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-hosted login fails on plain-permalink sites: the ?rest_route= API root is path-extended into non-routing URLs

4 participants