Skip to content

fix(editor): stop persisting a WP.com proxy root as the site's REST API root - #23301

Open
dcalhoun wants to merge 11 commits into
trunkfrom
fix/cmm-2383-wpcom-proxy-rest-root
Open

dcalhoun wants to merge 11 commits into
trunkfrom
fix/cmm-2383-wpcom-proxy-rest-root

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Sep 4, 2026

Copy link
Copy Markdown
Member

TL;DR

Fix CMM-2383. The editor failed to load with rest_no_route on some Atomic sites because their stored REST API root was a WP.com proxy URL that already contained the wp/v2/sites/<id> namespace, so every request carried the namespace twice. While the erroneous URL surfaced as an editor failure, the impact is far wider, albeit latent.

Description

Symptom

Every editor dependency 404s with rest_no_route, and the editor never loads:

https://public-api.wordpress.com/wp/v2/sites/256930037/wp/v2/types?context=view
https://public-api.wordpress.com/wp/v2/sites/256930037/wp/v2/themes?context=edit&status=active
https://public-api.wordpress.com/wp/v2/sites/256930037/wp-block-editor/v1/settings

Those decompose exactly as siteApiRoot + path, with siteApiNamespace empty. The root already embeds wp/v2/sites/<id>, so GutenbergKit appends an already-namespaced path to an already-namespaced root.

Root cause

SiteModel.getWpApiRestUrl() returned a synthesized WP.com proxy root whenever the site read as WP.com Simple, so callers would "get the correct endpoint without needing site-type checks". But that value isn't the same kind of URL as a stored one, and every reader treats the property as a bare direct-host root. Introduced in #22765.

The value also didn't stay derived. The generated WellSql mapper reads the getter — cv.put("WP_API_REST_URL", item.getWpApiRestUrl()) — and insertOrUpdateSite writes the full row on insert, while its update path excludes the column. So a site added to the app while it was still Simple had the proxy root written into WP_API_REST_URL at insert and kept it permanently; no later sync could correct it.

Once such a site transferred to Atomic and acquired an application password, GutenbergKitSettingsBuilder took the direct-host branch, used the stored proxy root, and set an empty namespace.

Why it looked user-specific

The row is poisoned at insert, so only on the device that added the site while it was Simple — in practice the device that created it. A device that first sees the site after the Atomic transfer inserts it with a null column and works fine. That's why the reported site failed for its creator and worked for an administrator added later, and why pull-to-refresh never helped.

The application password isn't a cause; it's the switch that exposes the already-poisoned column by flipping shouldUseWPComRestApi to false.

Scope beyond the editor

Everything that reads wpApiRestUrl as a direct-host root was affected: WpApiClientProvider.getApplicationPasswordClient / getDirectHostApiUrlResolver, WpServiceProvider (the wordpress-rs Posts list), ApplicationPasswordsNetwork, and ApplicationPasswordValidator. That accounts for the Posts-list failures and the "Unable to connect to your site" banner seen alongside the editor error.

The change

  • SiteModel.getWpApiRestUrl() returns the stored value only. No caller needed the synthesis: WP.com-routed traffic goes through WpComUrlResolver (WpApiClientProvider.getWpApiClient / getApiUrlResolver) or wpComRestClient (ReactNativeStore dispatches on isUsingWpComRestApi). Every reader of the property wants a direct-host root, and each pairs it with a ${url}/wp-json fallback — though note that five fluxc readers use a bare ?:, so the fallback doesn't fire for an empty string (see follow-ups).
  • SiteSqlUtils.updateWpApiRestUrl refuses WP.com proxy URLs. It is the sole writer of the column, so rejecting there covers every reader at once rather than each having to recognise the shape. The predicate lives in a new WPComApiProxy and is shared with the migration, so a value the writer rejects can't survive in an older row.
  • The stored root is normalized to a trailing slash. api-fetch's createRootURLMiddleware strips a path's leading slash before concatenating onto the root, and buildEditorAssetsEndpoint concatenates with no separator, so a slash-less root yields …/wp-jsonwp/v2/types — the same failure class. WpApiClientProvider builds exactly such roots and they reach the column via app-password login. Roots carrying a query string are the plain-permalink ?rest_route= form and are left alone.
  • DB migration 213 nulls WP_API_REST_URL values matching https://public-api.wordpress.com/wp/v2/sites/%. Required: existing installs are already poisoned and nothing else rewrites that column. The prefix match is safe — only the removed synthesizer produced that shape.
  • GutenbergKitSettingsBuilder rejects a proxy root in the direct-host branch and falls back to the site's own host, logging when it does. A backstop, but this is the layer that knows the root and namespace have to agree.
  • resolveApiTransport now derives root, namespace and auth header together, since they're only valid as a set.
  • Diagnostics (first commit): the editor logs how it resolved its REST root plus the site classification, tagged by ConfigSource (preloader vs editor), and SiteSqlUtils logs the value the mapper is about to persist on insert. These were how the origin was found and are worth keeping as a canary.

Testing instructions

The reproduction needs the site to be created in the app — that's the step that inserts the poisoned row.

Reproduce on trunk first:

  1. In the app, create a new WordPress.com site (free/Simple is fine).
  2. Purchase a plan
  3. Transfer the site to Atomic (the web's Hosting Features UI works).
    1. https://wordpress.com/hosting-features/<site_origin_or_id>
    2. Click the site name in the sidebar to reveal the activation flow
    3. Proceed to Activate hosting features
  4. Back in the app, do not open the editor. Instead, switch to a different site and perform a pull-to-refresh gesture. Switching back to the created site and pull-to-refresh again.
  5. Visit the Posts list to trigger the headless mint of an application password, and a "Unable to connect to your site" banner often appears.
  6. Open the editor.
  • Verify it fails with "Failed to load editor — rest_no_route", and that requests go to https://public-api.wordpress.com/wp/v2/sites/<id>/wp/v2/...

Then install this branch over it (do not clear app data — the migration is the point):

  1. Launch the app.
  • Verify the log shows Upgrading database from version 212 to 213
  • Verify the site's WP_API_REST_URL is now null, and that legitimate …/wp-json/ roots on other sites are untouched:
    adb exec-out run-as com.jetpack.android.prealpha cat databases/wp-fluxc > /tmp/wp-fluxc.db
    sqlite3 /tmp/wp-fluxc.db "SELECT SITE_ID, URL, IS_WPCOM, IS_WPCOM_ATOMIC, WP_API_REST_URL FROM SiteModel;"
    
  1. Open the editor on the transferred site.
  • Verify it loads, with theme styles applied
  • Verify the log shows root=https://<site>/wp-json/, and no rest_no_route

Regression checks:

  1. Open the editor on a WordPress.com Simple site.
  • Verify wpcomRest=true root=https://public-api.wordpress.com/ and a sites/<id>/ namespace — unchanged behaviour
  1. Open the editor on a self-hosted site and on a Jetpack site.
  • Verify both still load

Verified on device

Both previously-broken Atomic sites now load. The migration cleared 8 poisoned rows and left 3 legitimate roots untouched. A Simple site and a private Atomic site that already worked were unchanged.

Known gaps, deliberately left out

Both are pre-existing, neither is made worse here, and each wants its own tests and blast-radius review.

  • clearWpApiRestUrl writes "", not NULL. Five fluxc readers use a bare ?:, so an empty string reads as "present" and resolves to a host-less base URL. Its one caller is removeApplicationPassword, so the trigger is narrow — but it is sticky, because ReactNativeStore treats non-null as "already discovered" and therefore stops re-running discovery that would otherwise heal the row.
  • The editor resolves its transport from the SiteModel serialized into its intent, while the preloader deliberately re-reads from the store (GutenbergEditorPreloader does getSiteByLocalId(...) ?: site precisely because provisioning mints credentials mid-flight). A snapshot taken before provisioning yields the WP.com bearer path where the preloader used the direct host. Both transports work, so the realistic cost is a preload cache miss rather than a failure — the ConfigSource tagging on the routing log exists to measure whether it happens in the field before changing behaviour.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4

dcalhoun and others added 6 commits September 4, 2026 14:59
The experimental editor fails with `rest_no_route` on some Atomic sites
because `siteApiRoot` is a WP.com proxy root that already embeds
`wp/v2/sites/<id>` while `siteApiNamespace` is empty, so GutenbergKit
appends each already-namespaced path unchanged and every request 404s.

Add the instrumentation that identifies which of the two inputs is
wrong and where the bad value entered the system:

- `GutenbergKitSettingsBuilder` logs the resolved root alongside the
  site classification it was derived from, and raises an error-level
  canary when the direct-host branch is handed a proxy root. A
  `ConfigSource` parameter distinguishes the preloader (which re-reads
  the site from the store) from the editor (which uses the copy
  serialized into its intent), so lines that disagree point at a stale
  in-memory model rather than a stale row.
- `SiteSqlUtils.insertOrUpdateSite` logs the `wpApiRestUrl` the WellSql
  mapper is about to persist on insert. The mapper calls the getter,
  which synthesizes a proxy root while the site reads as WP.com Simple,
  and the update path excludes the column — so the insert decides the
  value for good.
- `SiteStore.persistAppPasswordColumns` and the auto-auth mint log the
  root they persist, covering the paths that rewrite the column later.

Diagnostics only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
`getWpApiRestUrl()` returned "https://public-api.wordpress.com/wp/v2/sites/<id>"
whenever the site read as WP.com Simple, so callers would "get the correct
endpoint without needing site-type checks". That value is not the same kind of
URL as a stored one: it already embeds the `wp/v2/sites/<id>` namespace, while
every reader treats the property as a bare direct-host root and appends an
already-namespaced path to it.

Worse, the value did not stay derived. The generated WellSql mapper reads this
getter (`SiteModelMapper.put("WP_API_REST_URL", item.getWpApiRestUrl())`) and
`insertOrUpdateSite` writes the full row on insert, while its update path
excludes the column. So a site added to the app while still Simple had the proxy
root written into WP_API_REST_URL at insert and kept it for good — no later sync
could correct it. Once such a site transferred to Atomic and acquired an
application password, `GutenbergKitSettingsBuilder` used the stored proxy root as
a direct-host root with an empty namespace, and every editor request carried
`wp/v2/sites/<id>` twice and 404'd with `rest_no_route`.

That also explains why the same site worked on a second device: a device that
first saw the site after the transfer inserted the row with a null column.

Return only the stored value. No caller needs the synthesis — WP.com-routed
requests go through `WpComUrlResolver` (`WpApiClientProvider.getWpApiClient` /
`getApiUrlResolver`) or `wpComRestClient` (`ReactNativeStore`), and every reader
of this property pairs it with a `${url}/wp-json` fallback because it wants a
direct host.

Existing rows are already poisoned; the migration follows separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
Sites added to the app while they were WP.com Simple had a synthesized
proxy root written into WP_API_REST_URL at insert. The column is excluded
from the generic full-row update, so nothing rewrites it — existing
installs stay broken after the getter change alone.

Null the derived values so REST discovery can repopulate a real root.
The prefix match is safe: only the removed synthesizer produced URLs of
the form https://public-api.wordpress.com/wp/v2/sites/<id>, and a genuine
direct-host root never has that shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
The direct-host branch pairs `siteApiRoot` with an empty `siteApiNamespace`,
so it needs a bare root. A root under the WP.com proxy already embeds
`wp/v2/sites/<id>`; using one there makes GutenbergKit append an
already-namespaced path to an already-namespaced root, and every editor
request 404s with `rest_no_route`.

The getter no longer produces such a value and the migration clears the
stored ones, so this is a backstop — but it's the layer that knows the two
inputs have to agree, and it turns a silent storm of 404s into a logged
fallback to the site's own host.

Adds the matrix cell the suite was missing: a WPCom-flagged site holding an
application password. The existing coverage only exercised a Jetpack site
there, which is why the combination that breaks went unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
`persistAppPasswordColumns` runs in tests that don't stub `android.util.Log`,
so the diagnostic added while tracing CMM-2383 failed three SiteStoreTest
cases with "Method e in android.util.Log not mocked".

It has served its purpose: it established that the proxy root arrived here
already set rather than being synthesized at this point, which is what moved
the search to the insert. The insert-time log in `SiteSqlUtils` covers the
origin and the editor routing log covers the symptom, so drop this one rather
than loosen the module's unit-test config for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
Detekt flagged two thresholds the previous commit crossed: LongMethod on
`buildPostConfiguration` (61 of a maximum 60) and LargeClass on
`GutenbergKitSettingsBuilderTest`.

Extract the root, namespace and auth header into `resolveApiTransport`.
They are derived from one another and only valid together — a WP.com root
needs the namespace to carry `sites/<id>/`, a direct-host root needs it
empty, and the auth header has to match the host being addressed. Holding
them in one `ApiTransport` names that invariant, which is precisely what
went wrong in CMM-2383, and leaves `buildPostConfiguration` assembling the
configuration rather than deriving it.

Move the API-routing tests into `GutenbergKitSettingsBuilderApiRoutingTest`
for the same reason: the routing matrix is a coherent unit, and separating
it returns the original class to its previous size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
@wpmobilebot

wpmobilebot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in WordPress Android by scanning the QR code below to install the corresponding build.

App NameWordPress Android
Build TypeDebug
Versionpr23301-b338e94
Build Number1498
Application IDorg.wordpress.android.prealpha
Commitb338e94
Installation URL1jpi7tfvd1r8g
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot

wpmobilebot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

App Icon📲 You can test the changes from this Pull Request in Jetpack Android by scanning the QR code below to install the corresponding build.

App NameJetpack Android
Build TypeDebug
Versionpr23301-b338e94
Build Number1498
Application IDcom.jetpack.android.prealpha
Commitb338e94
Installation URL4e4u86inr9dbo
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@dcalhoun dcalhoun added [Type] Bug Gutenberg Editing and display of Gutenberg blocks. labels Sep 4, 2026
@dcalhoun dcalhoun added this to the 27.2 milestone Sep 4, 2026
@wpmobilebot

Copy link
Copy Markdown
Contributor

🤖 Build Failure Analysis

This build has failures. Claude has analyzed them - check the build annotations for details.

`GutenbergEditorPreloaderTest` stubs `buildPostConfiguration` with argument
matchers, so adding the `source` parameter left it one matcher short and all
15 cases failed with "6 matchers expected, 5 recorded". A Kotlin default
doesn't help here — the mock still records the call with every argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.82353% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.06%. Comparing base (4e4662a) to head (b338e94).

Files with missing lines Patch % Lines
...word/ApplicationPasswordAutoAuthDialogViewModel.kt 0.00% 6 Missing ⚠️
...ss/android/ui/posts/GutenbergKitSettingsBuilder.kt 93.33% 0 Missing and 3 partials ⚠️
...org/wordpress/android/fluxc/model/WPComApiProxy.kt 0.00% 0 Missing and 1 partial ⚠️
...rdpress/android/fluxc/persistence/WellSqlConfig.kt 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk   #23301      +/-   ##
==========================================
+ Coverage   38.04%   38.06%   +0.01%     
==========================================
  Files        2353     2354       +1     
  Lines      128959   129005      +46     
  Branches    17976    17981       +5     
==========================================
+ Hits        49068    49103      +35     
- Misses      75871    75878       +7     
- Partials     4020     4024       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

dcalhoun and others added 4 commits September 4, 2026 16:38
Findings from review of #23301.

The proxy check lived in one consumer while the PR description named six, and
its prefix disagreed with the migration's. WP.com Simple sites advertise
`https://public-api.wordpress.com/wp-json/?rest_route=/sites/<domain>` during
discovery, and `ApplicationPasswordLoginHelper` persists whatever discovery
returns — so a second proxy shape reaches the column, survives a migration
matching only `/wp/v2/sites/%`, and leaves the editor logging a rejection every
launch while `WpServiceProvider` and `getApplicationPasswordClient` keep using
the bad root with no error of their own.

Move the predicate to `WPComApiProxy` and apply it where the value is written:
`SiteSqlUtils.updateWpApiRestUrl` is the sole writer of the column, so refusing
there covers every reader at once. The migration now shares the same prefix, and
the editor keeps a backstop for rows predating both.

Also normalize the trailing slash. `api-fetch`'s `createRootURLMiddleware`
strips a path's leading slash before concatenating onto the root, and
`buildEditorAssetsEndpoint` concatenates with no separator, so a slash-less root
yields `…/wp-jsonwp/v2/types` — the same failure this PR fixes.
`WpApiClientProvider` builds exactly such roots and they reach the column
through app-password login. Roots carrying a query string are the plain
permalink `?rest_route=` form and are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
Findings from review of #23301.

- `persistAppPasswordColumns` claimed the credential gate is what stops a
  credential-less sync from blanking a discovered root. It isn't: the
  isNotEmpty() check below does that, and the credential check gates the whole
  write. Someone trusting the old wording could drop the takeIf and reintroduce
  the clobber.
- `logResolvedApiRoot` described `getWpApiRestUrl()` as synthesizing a WP.com
  proxy root and read `isSimple=true` as meaning the persisted root is about to
  be wrong. Both were true when the log was added to trace this bug and are
  false as merged — the synthesis is gone.
- `SiteModelTest` still assigned a siteId that no assertion reads now that the
  proxy-URL expectation became assertNull.
- `ConfigSource` was a generic name at package level for a type only this
  builder uses; nest it so ownership is obvious. `internal` would have been the
  narrower fix but cascades onto the public function that takes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
Findings from review of #23301.

The migration is the whole fix for existing installs and was verified only by
hand on one device. A wrong LIKE pattern ships as a silent no-op — the upgrade
succeeds and the rows stay broken — so assert the statement directly: both proxy
shapes cleared, a direct-host root untouched, a lookalike domain
(public-api.wordpress.com.example.net) untouched, and null rows left alone.

Confirmed the test has teeth by narrowing the pattern back to the wp/v2 form:
exactly the rest_route case fails, which is the shape the first revision missed.

Rows are seeded with raw SQL because updateWpApiRestUrl now refuses the values
the migration exists to clean up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
updateWpApiRestUrl now also returns 0 when it refuses a WP.com proxy URL, so
"no site with localId=..." would name the wrong cause — the kind of confidently
wrong log that sent this investigation down a blind alley in the first place.
Report both possibilities; the writer logs which one it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aCE7dERu43yYPzoxrqeq4
@dcalhoun
dcalhoun marked this pull request as ready for review September 6, 2026 02:06
@dcalhoun
dcalhoun requested a review from jkmassel September 6, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Gutenberg Editing and display of Gutenberg blocks. [Type] Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants