Skip to content

fix(ios): route editor REST requests through a native relay under Lockdown Mode - #611

Draft
dcalhoun wants to merge 70 commits into
trunkfrom
task/stabilize-rest-request-relay
Draft

fix(ios): route editor REST requests through a native relay under Lockdown Mode#611
dcalhoun wants to merge 70 commits into
trunkfrom
task/stabilize-rest-request-relay

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Sep 1, 2026

Copy link
Copy Markdown
Member

What?

Under iOS Lockdown Mode the editor's REST traffic fails — every request comes back as "Could not get a valid response from the server." This routes that traffic through a native loopback relay so it works.

Supersedes #544 and #610: the same work as a single branch against trunk, plus the fixes from an adversarial review of the combined diff. Both are left open for now.

Why?

With Lockdown Mode on, nothing in the editor that talks to the site works — media uploads, link search, embeds, and saving all fail with api-fetch's generic error. Where the request stops depends on its shape: every api-fetch request carries Authorization, so it is preflighted and the browser abandons it after the OPTIONS response fails the origin check — it never reaches the site. A request needing no preflight does reach the site and is processed, and only its response is discarded.

The editor is a file:// page whose cross-origin requests normally bypass CORS through allowUniversalAccessFromFileURLs. Lockdown Mode stops honoring that exemption while the page still sends Origin: file://, and WordPress sanitizes that value through a protocol allowlist that excludes file — so it answers with an empty Access-Control-Allow-Origin and WebKit rejects the response. Ref CMM-2014.

How?

The web view fetches http://127.0.0.1:<port>/proxy/<path> on the media upload server from #357, and native code performs the real request with the configured credential, answering with CORS headers we control.

The caller supplies a path, not a URL. Everything after /proxy/ resolves natively against the configured site API root, so the relay cannot be pointed at another host by construction. Dot segments are refused, including the percent-encoded separators a server may decode before it normalizes. Redirects out of the root are refused rather than followed, except one that differs only by an httphttps upgrade. Any client-supplied Authorization is discarded in favor of the natively held one. Which route within the site is reached remains the caller's: the query is forwarded as-is, and WordPress prefers a rest_route parameter in it over the path — no wider than what the editor may request through the relay directly.

The relay is the transport, not an api-fetch middleware. apiFetch.use() can never run innermost, so a middleware would short-circuit past api-fetch's own request building and response parsing. Wrapping fetch puts the relay below all of it, changing only where the request goes.

Only the configured site is relayed. A target matches when its host is the configured host under a www. or loopback spelling and its port is identical. Only the scheme may differ, so a site whose siteurl is http behind a TLS-terminating proxy still relays. Anything else keeps the direct path it had before a relay existed — as does any no-cors request, whose response is opaque, so there is no CORS rejection to solve and a browser would strip the relay's bearer token from it anyway.

Preflights are told apart from deliberate OPTIONS by Access-Control-Request-Method, in the HTTP server and in the wp-env CORS shim, which had the same bug: canUser reads Allow off an OPTIONS response, and answering it with a blanket 204 reported that the user could do nothing, with no error surfaced.

Three gaps are deliberate:

  • jQuery.ajax / wp.ajax (XHR) still go direct and still fail under Lockdown Mode. Nothing in the editor's own REST path uses them today.
  • A site reached under an alias the host comparison cannot recognize — a LAN IP whose home_url() says localhost, a mapped domain — takes the direct path, so paginated Link targets fail. Growing the list of spellings cannot close that class; the fix belongs at the relay, which knows where a response came from, and is deferred to the all-origins work.
  • Whether to relay is decided once, when the editor loads. Re-entering Lockdown Mode with the editor already open — excluding the app and then removing that exclusion — reloads the web view under restrictions the relay is not running for, and REST requests fail until the editor is closed and reopened. Deciding it later means starting the server and reloading the page for it to take effect, since GBKit.networkProxy is read at load; the reverse transition needs nothing, as the relay keeps working once it is up.

Testing Instructions

make test-js
make test-swift-package

Then against a live site, which relays real requests — a write is read back to confirm the record actually changed, Allow is asserted end to end, and ten concurrent requests are fired:

make wp-env-start
WP_ENV_CREDENTIALS_PATH="$PWD/.wp-env.credentials.json" swift test --filter RestRelayIntegrationTests

Manually, in the Simulator with GUTENBERG_FORCE_LOCKDOWN_MODE=1 or on a device with system Lockdown Mode enabled:

  1. Open a post, insert an image, and confirm the upload completes and the attachment exists on the site.
  2. Insert a link from the toolbar and search for an existing post — results come back.
  3. Save, reopen the post, and confirm the content persisted rather than reporting success over an unchanged record.
  4. Turn Lockdown Mode off with no media upload delegate configured: no local server starts, and uploads behave as on trunk.

jkmassel and others added 30 commits August 14, 2026 11:57
…down Mode

# Conflicts:
#	ios/Sources/GutenbergKit/Sources/EditorViewController.swift
#	ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
#	src/utils/api-fetch.js
Two defects in how the local server decides what reaches its handler.

A relayed `OPTIONS` and a browser preflight arrive at the same target, so
the permissive CORS policy answered both with 204 and the handler never
ran. `canUser` issues `OPTIONS /wp/v2/{resource}` and reads the `Allow`
response header, so the editor silently reported that the user could not
create pages, update settings, upload media, or edit global styles — with
no error surfaced, because the request "succeeded". Discriminate on
`Access-Control-Request-Method`: a preflight always carries it, a
deliberate `OPTIONS` never does. The authentication exemption narrows to
the same condition, so a deliberate `OPTIONS` is authenticated like any
other request rather than riding in on the preflight exemption.

The server also gains an opt-in requirement for `Origin` or
`Sec-Fetch-Site`, headers WebKit sets on every editor `fetch()` and a raw
socket opened by another process on the device does not. The bearer token
remains the control; this is defense in depth, and cheap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The relay has never served a request. `upstreamURL(from:)` assigned
`parsed.query` to `percentEncodedQuery`, but `query` includes the leading
`?`, so the first query item was named `?url`, the lookup for `url`
returned nil, and every request 400d.

Rather than fix the parse, drop the caller-supplied URL. The upstream path
now rides in the request path — `/proxy/wp/v2/posts?_locale=user` — and
resolves natively against the configured site API root, so there is no URL
to contain in the first place. The old `hasPrefix` guard ran on an
absolute URL without normalizing `..` segments; those are now refused
outright, literal or percent-encoded, and the resolved URL is re-checked
against the root. Each request also identifies itself in a network log
instead of every row reading `/proxy`.

Resolution appends to the root rather than resolving relative to it,
mirroring `createRootURLMiddleware`: a site on plain permalinks has
`https://example.com/?rest_route=/`, where relative resolution would
discard the query and the path has to merge into it.

Three further defects, all in what comes back:

- `Allow` was not exposed, so `canUser` read null even once its `OPTIONS`
  reached the handler.
- Error bodies were `text/plain`, reaching JavaScript as an unparseable
  `invalid_json` with the real reason lost. They are now WordPress-shaped
  `{code, message}`, as `MediaUploadServer.errorResponse` already was.
- `URLSession` followed 3xx responses with no task delegate, so the
  containment check only ever applied to the first hop and a redirect
  carried the site credential to another host. Cross-root redirects are
  now refused and the 3xx handed back instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`MediaUploadServer.start` omitted `maxConnections`, so the server ran on
the library default of 5. That suits a server receiving one upload at a
time, but this one also carries every editor REST request under Lockdown
Mode, and editor boot fans out well past five. Each connection serves
exactly one request, and one past the limit is closed immediately —
surfacing in JavaScript as an unretried `fetch_error`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The relay was a middleware that short-circuited past `next()`, but
`apiFetch.use()` unshifts and api-fetch applies its middleware with
`reduceRight`, so a registered middleware runs *outside* the four built-in
ones and `defaultFetchHandler`, not inside them. Every relayed request
therefore lost what those do: `options.data` never became a body (so every
save sent `Content-Length: 0`, which WordPress accepts as a no-op — silent
data loss), the `Accept` header WordPress uses to recognize a REST request
was dropped, the HTTP v1 method override was lost, and `signal` never
reached `fetch`, so cancellation did not propagate.

Installing the relay as the fetch handler puts it where the old comment
said it already was. Everything above is fixed at once, and `_locale=user`
and the `per_page=-1` expansion now hold because their middleware runs,
rather than by the accident of a failed direct attempt leaving its
mutations behind.

Which transport to use is now read from configuration. The relay is only
advertised when the host knows direct requests cannot work, so a direct
attempt first is a guaranteed-doomed round trip per request; the previous
module-global flag inferred the answer from an observed success, which one
misleading response could latch on for the rest of the session.

The upstream path travels in the request path, so `Access-Control-Allow-
Headers` no longer needs a relay header, and `PATCH` joins the allowed
methods — it was blocked outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`LocalWordPressCredentials.load()` fell back to a compiled-in application
password and a hardcoded LAN IP when `WP_ENV_CREDENTIALS_PATH` was unset.
Throwaway local dev credentials rather than production ones, but they
should not ship, and the silent fallback turned a misconfigured
environment into a confusing failure against someone else's machine.
`SitePreparationView` already explains what to run when `load()` returns
nil.

Keeping the screen awake is now opt-in behind `GUTENBERG_DISABLE_IDLE_
TIMER` rather than unconditional; it exists for debugging workflows that
break on auto-lock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The relay's defects were invisible to unit tests: each one needed a real
request to cross the loopback server, be forwarded natively, and come back.
These tests do that against the `make wp-env-start` environment, and are
skipped unless `WP_ENV_CREDENTIALS_PATH` is set, so a normal run and CI
never need a site.

The write test reads the record back rather than trusting the create
response, because the defect it covers sent an empty body — which
WordPress accepts as a no-op while still answering 2xx.

`canUser` is not verifiable here: the Playground runtime's web server
answers every `OPTIONS` itself with a bodiless 204 before WordPress is
reached, so there is no `Allow` header to relay. The test asserts the
relay forwards the `OPTIONS` upstream instead, which is the half of that
chain this code owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`getGBKit()` falls back to the copy of the config in `localStorage`, which
iOS writes on every load and never clears. A relay's port and per-session
token belong to the server that issued them, so a persisted copy points at
a listener that has been stopped — or at a port something else now owns.

That was survivable while the relay was a fallback for requests that had
already failed. It is not now that it is the transport: every REST request
in the session would go to the stale port. Read the relay's details from
the injected global only, and fall back to no relay, which is what
requests did before one existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
WordPress builds `Link` headers and `_links` hrefs from `home_url()`, which
need not be the host the app was configured with — `www.` versus bare, a
mapped or reverse-proxied domain, an `http` `siteurl` behind `https`.
wp-env is the everyday case: its credentials report `localhost` while
WordPress reports `127.0.0.1`.

Matching the target against the configured root as a plain string left
those unrecognized, so `fetchAllMiddleware` — which follows the absolute
URL from the `Link` header — had page 2 of every `per_page=-1` collection
refused with a bare `fetch_error`. The existing pagination test could not
catch it: it mocks the `Link` header with the configured root verbatim.

Move the target onto the root's origin before comparing, and parse both
sides so they normalize identically. Path differences stay unmatched
deliberately — in a subdirectory multisite two roots that differ only by
path are separate sites, and matching across them would route one site's
request into the other's API root.

The root is also parsed slash-terminated, so a sibling can no longer match
it as a prefix: `https://site/wp-json` matched `https://site/wp-jsonx/…`,
and a host-supplied root without a trailing slash is not hypothetical.

Found by the session working the scheme-handler branch, which hit it first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`setFetchHandler` was the only api-fetch hook that runs after every
middleware, so it was the only way to intercept without short-circuiting
past `_locale=user`, the `per_page=-1` expansion, and the HTTP v1 method
override. But it replaces the response half too, so `data` serialization,
the default `Accept` header, `parse: false`, the 204 case, parse-and-throw
on non-2xx, and `offline_error`/`fetch_error` normalization all had to be
reimplemented here and kept in step with a package we do not control.

Wrapping `fetch` sits below all of it. api-fetch builds the request, hands
it over, and parses whatever comes back; this layer only changes where the
request goes. That deletes the reimplementation — about 130 lines — and
`configureApiFetch` goes back to nothing but middleware registration.

The predicate is not a new skip list. It is the same "is this a site API
request?" test, minus its relative-path branch, which was needed only
because `setFetchHandler` receives options where `path` may be set without
`url`. `blob:`, `data:`, `gbk-media-file:` and relative URLs all fail an
absolute-URL-under-the-API-root test on their own. The one addition is a
guard for the relay's own origin, which is load-bearing because matching
deliberately ignores the origin to tolerate host aliases — the upload route
shares that server, and a site configured with a bare root would otherwise
match it on path.

Ordering is explicit in the bootstrap: the relay installs before the
network log, so the log records the request the editor made rather than the
loopback rewrite of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Two wrappers now sit on the global `fetch` — the network log and the
Lockdown Mode relay — and their order is behavior, not preference: a
wrapper that rewrites the request changes what every wrapper inside it
observes, so the log has to sit outside the relay to record the request the
editor made rather than the loopback rewrite of it. Encoding that in the
order two modules happen to patch `window.fetch` leaves nothing to read and
nothing to test.

`installFetchWrappers` takes the chain outermost-first and composes it, so
the ordering and its rationale live in one place. Each wrapper is a
`( next ) => fetch` transform — the same shape as an `apiFetch` middleware,
one layer down — which also makes each testable against a stub `next`
rather than against globals. A wrapper reports itself inapplicable by
returning `null`, so a disabled feature installs no pass-through layer.

`fetch-interceptor` is renamed to `fetch-logging`: it was "the" interceptor
when it was the only one, and is now one wrapper among several. Its
behavior is unchanged and its tests carry over as they were, against a
one-line helper standing in for the chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
The CORS shim short-circuited every `OPTIONS` request site-wide with a
bodiless 204, without checking whether it was a CORS preflight. A preflight
always carries `Access-Control-Request-Method`; an `OPTIONS` a client sent
on its own behalf never does. `canUser` issues the latter and reads `Allow`
to decide whether the user may create a page, update settings, upload
media, or edit global styles — so against the local environment every one
of those read as false, and read as *success*, so nothing surfaced.

Discriminating on that header lets core answer the deliberate ones, where
`rest_handle_options_request` builds the response and
`rest_send_allow_header` fills in `Allow`. The site-wide hook stays: the
editor also sends authenticated requests to `admin-ajax.php`, which core
does not answer preflights for.

`Allow` is also now added to the exposed CORS headers, through core's
`rest_exposed_cors_headers` filter rather than by sending the header
directly — core sends its own `Access-Control-Expose-Headers`, so a second
`header()` call would replace that value instead of extending it. Without
this the header is on the wire and invisible to JavaScript cross-origin,
which is the same failure by a different route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`canUser` issues a deliberate `OPTIONS` and reads `Allow` to decide what
the user may do. Asserting the header arrives covers the whole chain in one
check: the request reaches WordPress rather than being answered locally as
a preflight, WordPress computes the header from the matched route's
permission callbacks, and the relay passes it through and exposes it.

Replaces a weaker assertion that the response merely carried the site's
server headers.

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

The alias divergence is a documented property of this environment, not a
one-off: `e2e/wp-env-fixtures.js` already works around the Playground
runtime resolving `localhost` to `127.0.0.1` in `WP_SITEURL` by matching
uploads on path rather than hostname. Citing it justifies the tolerance
better than a single observation could.

The relay's redirect guard stays origin-exact, and now says why. The two
comparisons answer different questions: recognizing an alias decides where
a request is sent, while the redirect guard decides whether the site
credential follows a redirect somewhere we did not choose. `isSameOrigin`
in `ajax.js` already draws that line for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Refusing the redirect left `URLSession` holding the 3xx, and the relay
passed it straight through — `Location` header included. `fetch` follows
redirects by default, so the web view then chased the redirect to the very
host the guard had just declined, and the request failed there as an opaque
CORS error. The refusal was undone by the layer above it, and the reason
was nowhere in the result.

The relay now answers with a WordPress-shaped 502 naming the target it
declined, so whoever hits this can see that the site redirected out of its
own API root rather than going hunting in the relay.

Also records the trade the prefix check makes, which is not obvious from
the code: matching the whole URL rather than the host refuses a redirect to
another path on the same site, and refuses a scheme downgrade without a
rule of its own — but it also refuses a legitimate permalink-structure
redirect. Refusing is the right default, because it cannot hand the site
credential somewhere unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Scratch tooling from the Lockdown Mode investigation, committed alongside
it. It builds bare web views to compare page-origin variants, which is not
something the relay depends on, and seven of its eleven measurements point
at a CORS-instrumented echo server that only ever ran on one developer's
machine — as its own comment says. It cannot run for anyone, and making it
run would mean building that server for a question this work does not ask.

Takes the last hardcoded LAN address in the demo app with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
Two of its measurements went to a CORS-instrumented echo server that only
ever ran on one developer's machine, so they reported connection failures
for everyone else. What they covered — a cross-origin GET and a FormData
POST — the `site_get_direct` and `site_post_media_direct` cases already
cover against a real WordPress.

The probe now needs nothing but a reachable site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A21xYJuymCU7My8yyS8Bv
`containsDotSegment` split on literal `/` only, so a traversal spelled
`%2e%2e%2f%2e%2e%2fwp-admin` arrived as one segment and passed the guard.
A server that decodes the separator before normalizing then resolves it
outside the API root, with the site credential attached.

Decode the separators alongside the dots so the guard holds the boundary
its documentation promises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`relayUpstreamPath` replaced the target's whole origin before comparing,
so any host whose path started with the API root's path was captured and
rewritten onto the configured site — a third party's request sent to the
user's own site with the site credential attached.

Compare the hosts first, tolerating only the spellings that name the same
host: a `www.` prefix and the loopback addresses. Anything else keeps the
direct path it had before a relay existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The passthrough guard compared the target's origin against the relay's
`127.0.0.1` spelling, but the native upload request it exists to exempt is
issued to `localhost` (the name Android hosts permit cleartext to). That
request fell through to the site match, where only the path stood between
a multipart upload and being relayed to the REST API.

Match the local server by port instead, accepting any loopback spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`buildEditorConfiguration` decided on the delegate alone while
`startUploadServer` also required a site credential, so a host with a
delegate but no auth header — with the relay starting the server anyway —
advertised a port whose `/upload` route had no uploader. Every media
upload then failed with a 500 instead of falling back to the WebView path.

Record the decision once, where it is made, and read it when the
configuration is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
`GBKit` carries the site credential and the local server's port and
tokens, all valid only for the load that injected them, and iOS mirrored
it into `localStorage` where it outlived the session. Anything reading
through the `getGBKit` fallback — the media upload port and token — could
pick up a previous session's values.

Remove the key as the configuration is injected, matching Android, which
clears the WebView's web storage before each load. With no stale copy to
guard against, the relay details read through `getGBKit` like every other
field and `getNetworkProxy` goes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The chain replaced the interceptor's idempotency guard with nothing, so a
retried boot or a re-injected bundle wrapped the already-wrapped `fetch`:
every request logged to the native host twice and relayed through two
layers.

Mark the wrapped `fetch` and skip a second install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
Two changes to the relay's upstream request:

A `URLSession` was built per relay and never invalidated, so one
accumulated per editor load under Lockdown Mode. Nothing about it is
per-relay, so share one for the process.

`RequestBody.count` is a file-size lookup that reports zero when it fails,
and the streamed branch sent that as the `Content-Length` — uploading
nothing, which WordPress accepts as a no-op and answers 2xx. The missing
file that is the likeliest cause already throws in `makeInputStream()`,
which the existing catch turns into a 500, so this closes the remaining
window rather than a live bug.

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

The exemption rests on the library answering a preflight with its own 204
before the handler runs, which only happens under `CORSPolicy.permissive`.
Under any other policy an unauthenticated `OPTIONS` carrying
`Access-Control-Request-Method` reached the handler — an unauthenticated
way into a server that requires authentication.

Exempt a preflight only when the permissive policy will answer it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
The struct is public and names a parameter of a public initializer, but
its memberwise initializer is internal, so the only value a host could
pass was nil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
A stray fragment above `loadEditorWithoutDependencies` merged into that
method's documentation, and the symbol link to `HTTPServer.start(…)` was
not updated for the `requiresBrowserOrigin` parameter, so it no longer
resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
api-fetch turns every PUT/PATCH/DELETE into a POST carrying
`X-HTTP-Method-Override`. The header is not CORS-safelisted, so the
browser announces it in the preflight and wp-env's allow-list — which
replaces core's — rejected every such request from the dev server. The
iOS policy gained the same header in this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQvFHoZpEeD6obF2YCoor
dcalhoun and others added 28 commits September 1, 2026 10:53
The deletion was the test's last statement, so a failing `#require` — the
regression this test exists to catch — skipped it and left the draft behind.
The suite is serialized and re-run against the same site, so each failure
added another `Relay integration <uuid>` to the `wp/v2/posts` listing the read
tests depend on, turning one failure into unrelated ones.

`defer` cannot `await`, so the deletion is threaded through the throwing path
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wg4dxFwhstXCtukTVYvMUU
The filter could not do what its name claimed. It read only
`request.query`, which is empty for a plain-permalink site — the JS relay
folds that root's query into the path, so `/proxy/wp/v2/posts&rest_route=…`
arrives with no `?` at all. And it compared the decoded name byte-for-byte,
while PHP rewrites `.`, space and `+` to `_` when populating `$_GET`, so
`rest.route` and `rest+route` passed through and still landed as
`rest_route`.

Completing it means reproducing PHP's `$_GET` name mangling in Swift and
keeping the two in step. That is not worth its cost here: every route a
caller reaches this way is one the editor may request through the relay
directly, so there is no boundary being crossed — only the docblock's claim
that the relay sends the route it validated, which is now stated accurately
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
Enumerating the headers the editor reads recreates the bug the list was
extended to fix. A missing name does not fail loudly — `headers.get()`
returns `null` and the feature behind it reads as absent, which is how
`Allow` went unnoticed while every `canUser` capability reported false.

Lead with `*` so the next header a plugin or a core update reads is covered
without another round of this. It is valid because relayed requests are sent
`credentials: 'omit'`, and it withholds nothing that was not already the
editor's. The four known names stay listed behind it: WebKit's support for
the wildcard here is unverified, and these are the ones whose absence is
known to break a feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
The connectivity probe fetches the site API root with `mode: 'no-cors'`.
Relayed, it lost the bearer token — a browser attaches only CORS-safelisted
headers to a no-cors request — and the loopback server answered 407. An
opaque response resolves regardless of status, so the probe reported the
site reachable whenever the relay's own server was up, which is always:
under Lockdown Mode it measured loopback instead of the internet, and the
offline indicator it guards would clear on a network that could not reach
the site.

A no-cors response is opaque by definition, so there is no CORS rejection
for the relay to solve and nothing gained by relaying one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`nonPreflightOptionsWithoutTokenReturns407` ran under the default
`cors: .none`, where `isExemptPreflight` is false unconditionally — so it
asserted a 407 that arrives whatever `isPreflight` returns. Dropping the
`Access-Control-Request-Method` test entirely left it green.

Under `.permissive` the exemption is live, and the assertion now fails when
that test goes, which is what the test's name has always claimed to cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
The guard defended against `RequestBody.count` reporting zero from a failed
file-size lookup, which is the behavior of `Storage.file(url)`. The parser
never produces that case: `extractBody` yields `.data` below the in-memory
threshold and `.fileSlice` above it, and a slice's `count` returns the length
the parser recorded, with no file-system call and no failure mode. The
streaming branch only sees `.fileSlice`, so the guard could not fire and its
500 response was unreachable.

The comment now describes what `count` does on this path rather than a case
that cannot arrive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`new Headers()` throws on a malformed header name or value, and the wrapper
built one outside any try/catch. api-fetch's middleware chain calls its
`next` synchronously, so that throw escaped `apiFetch()` itself instead of
arriving as a rejection — a caller with only a `.catch()` saw an uncaught
exception and the editor's error boundary rather than a failed request.

Making the wrapper `async` gives it the contract the `fetch` it stands in for
already has, and covers anything added to it later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
AGENTS.md orders helpers by when the main function first calls them.
`installEditorFetchWrappers` sat first in the file but runs fifth, ahead of
`setBodyClasses` and `setLogLevelFromGBKit`, which are defined below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`upstreamURL(for:)` re-spelled the route-prefix test that `handles(_:)`
already publishes. `MediaUploadServer.handleRequest` dispatches on `handles`,
so the two have to agree: a request the router accepts and the resolver
rejects is a 403 on a route the server claims to serve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`MediaUploadServer.start` documented every parameter but `restRelay` — the
one that decides whether this server also becomes the Lockdown Mode REST
transport, and so what `GBKit.networkProxy` means to the web view.

`CORSPolicy` said the editor's `file://` page sends `Origin: null`. Captured
from the device, WebKit sends `Origin: file://`. The conclusion the comment
draws from it is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`handle()` had no coverage in the suite CI runs. `RestRelayTests` stops at
`upstreamURL`, `handles`, and `RedirectGuard`, and the integration tests that
reach the rest are gated on `WP_ENV_CREDENTIALS_PATH` and skipped in CI — so
a regression in the parts the code calls load-bearing shipped green.

Both directions of the hop now have tests, against a stubbed `URLSession`:
the site credential replacing the caller's, the web view's own hop headers
being dropped, the upstream CORS strip (WordPress answers a rejected origin
with an empty `Access-Control-Allow-Origin`, which WebKit honors over the
policy's `*`), the `Content-Encoding` strip that keeps WebKit from decoding
an already-decoded body, and the status, body, and error paths.

Each was checked by reverting the behavior it covers and confirming the test
fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`Access-Control-Expose-Headers: *` is honored: captured from the device
under Lockdown Mode, a relayed response reads back `date`, `server`,
`x-robots-tag` and `server-timing`, none of them safelisted or listed.

So the four names are not there against a WebKit that might ignore `*`.
They are there because `*` is ignored for a credentialed request — treated
as a literal header name — and they are what would keep capabilities,
pagination and list counts working if the relay's `credentials: 'omit'`
ever changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
An upstream `Set-Cookie` was passed through to the web view, which stores it
against `127.0.0.1:<port>` — a different origin from the site that issued it,
and one whose port belongs to another process once this server stops. Nothing
sends those cookies back today because `createRelayFetch` forces
`credentials: 'omit'`, but that is a JavaScript-side invariant the relay does
not enforce.

A proxy consumes the upstream's cookies. The relay carries the site
credential natively and never needs them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`makeSession` registered a stub keyed by a per-session token and nothing
removed it, so every stubbed exchange held its `Stub` — response body
included — and its `Recorder` for the life of the test process. The doc
comment said invalidating the session released it, which it did not.

`makeSession` now returns a `Stubbed` whose `finish()` does both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
A request header the caller announces but the policy does not allow fails
inside the browser, which reports an opaque CORS error and never reaches the
handler — so the server, which is the one party that knows which header it
was, said nothing. Under the relay that covers every REST request the editor
makes, leaving it undiagnosable.

The allow list stays enumerated rather than echoing the announced headers:
unlike the expose list, it governs what a caller may send, and the relay
forwards most request headers upstream with the site credential attached.
`Access-Control-Allow-Headers` is now built from that list so the advertised
value and the diagnostic cannot disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`@wordpress/api-fetch` reads every response as JSON, so the `text/plain`
bodies the HTTP server sent for its own errors — 407, 403, 408, 411, 400 —
reached the editor as `invalid_json`, "The response is not a valid JSON
response.", with the real reason lost. Under the relay this server answers
every REST request the editor makes, so a stale token or a read timeout
surfaced that way rather than as itself.

`HTTPServerDelegate` gains `errorBody(for:)`, which supplies the payload only:
the server keeps the status and the headers the protocol requires, so a 407
cannot lose its `Proxy-Authenticate` challenge. `MediaUploadServer` answers
with the same `{code, message}` object it already uses for its own errors and
for relayed WordPress ones, so the JS middleware needs no special case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`defaultUploader` and `restRelay` are each gated on the flag that enables
them; `uploadDelegate` was not. With Lockdown Mode on and no auth header the
server starts for the relay, and the delegate rode along — standing up an
upload route with no uploader behind it, which could only run the delegate's
file processing and then fail. Trunk could not reach that state: it returned
before starting a server at all when the auth header was missing.

The port and token are withheld from `GBKit` in that state, so nothing calls
the route today. This restores the invariant rather than fixing a live
failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`GUTENBERG_FORCE_LOCKDOWN_MODE` reproduces Lockdown Mode's restrictions in
the Simulator, where the system setting is unavailable. It lives in the
shipped library rather than the demo app — unlike every sibling knob — so any
host app's process environment could turn on those restrictions and reroute
the editor's REST traffic through the relay.

`#if DEBUG` keeps it working wherever it is useful, including a debug build
of another host, and compiles it out of release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`errorResponse` was passed a literal reason phrase per call site, each of
them byte-identical to what `HTTPResponse.defaultStatusText(for:)` already
returns for that status. Deriving it keeps the status line, the plain-text
fallback body, and the rest of the library from disagreeing about what a
status is called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`RestRelay` and `MediaUploadServer` each serialized `{code, message}` and
each carried its own hardcoded fallback literal for the impossible
serialization failure. The two had already diverged: one interpolated the
caller's `code` into a raw JSON string with no escaping, which produces
invalid JSON for any code that is not a bare identifier.

`HTTPErrorBody.wordPressError(code:message:)` serves both. It lives in
GutenbergKit rather than on the type's own module: `GutenbergKitHTTP` serves
whatever body its consumer hands it and knows nothing about WordPress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`startUploadServer()` runs once, and assigns `uploadServer` only on the
success path. Every read of these two flags in `buildEditorConfiguration`
goes through `uploadServer`, which is still nil when the start throws — so
clearing them advertised nothing that was not already withheld.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`responseHeaders` is evaluated for every response the server sends, and
building the value from `allowedRequestHeaders` moved a four-element join and
a string allocation onto that path. Hoisting it keeps the single source of
truth without paying for it per request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`merge(_:adding:)` took a parameter with one possible argument, a private
constant on the same type, restated at its single call site.

The JS relay rebuilt the `Relay-Authorization` value and re-canonicalized the
API root's hostname on every request, beside three values the same closure
already hoists at construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`RestRelay` hand-rolled "parsed request → URLRequest, minus hop-by-hop
headers", which `ParsedHTTPRequest.urlRequest(relativeTo:)` already did —
leaving the library's well-tested version with no production caller and the
relay's copy untested on the path every REST request takes. The copy was also
weaker: a static set cannot drop the headers a request's own `Connection`
header names, so `Connection: X-Hop-Only` forwarded `X-Hop-Only` to the site.

`urlRequest(url:stripping:)` takes an already-resolved URL — the relay appends
to a string root, since a `?rest_route=/` root has no path to resolve against
— and further names the caller owns. `urlRequest(relativeTo:)` now delegates
to it. The body stays with the caller: the relay buffers small ones, streams
large ones with an explicit `Content-Length`, and surfaces a read failure as a
500, none of which the general case wants.

`requestHeadersToStrip` drops from 19 names to 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
`RestRelay.route` was spelled in Swift and the URL rebuilt from it in
JavaScript, so `/proxy` appeared on both sides of the bridge with nothing
keeping them in step — renaming the route meant remembering two languages.

`NetworkProxy` now carries the slash-terminated base URL, built from
`RestRelay.route`, and the web view appends an upstream path to it verbatim.
The address stays literal `127.0.0.1`: the server binds the IPv4 loopback
only, and a name that may resolve to `::1` first would have to fall back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
The probe drove ~65 lines of inline JavaScript from the SwiftUI coordinator —
a base64 PNG, a hand-rolled promise race, four try/catch blocks — plus a
30-second sleeping task on every editor creation, all to answer questions
`RestRelayIntegrationTests` now answers repeatably: a relayed GET, a relayed
media POST, and what `canUser` reads off `Allow`. It had no syntax checking,
no tests, and no way to run it but launching the app with an environment
variable.

Removing it takes `configuration` off the coordinator, which nothing else
used, and returns `makeCoordinator()` to its one-argument form.

`GUTENBERG_AUTO_START_LOCAL_WP` and `GUTENBERG_DISABLE_IDLE_TIMER` stay:
neither belongs to the probe, and both serve on-device debugging generally —
the idle-timer one is what keeps a device awake for a Web Inspector session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
One restated on the consuming side a rationale the producing side already
carries; the other justified the absence of code by describing what used to
be there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
This reverts commit 53aaa9d.

The reset was removed on the grounds that nothing observes the difference,
which holds only while every read of these flags goes through `uploadServer`
— an invariant spanning two methods that the type does not enforce. Starting
the relay on a Lockdown Mode transition would call `startUploadServer()` more
than once, and a later failure would then leave the flags describing an
intent rather than what is running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wmFZoU6yWeEUD71jY9jhH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants