Skip to content

Serve asset routes on every adapter - #1412

Open
jwrosewell wants to merge 27 commits into
feature/53-edge-cookiefrom
feature/54-asset-routes
Open

jwrosewell wants to merge 27 commits into
feature/53-edge-cookiefrom
feature/54-asset-routes

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-adapter-spin/src 6 files, 606 lines added, 9 removed
crates/trusted-server-adapter-cloudflare/src 6 files, 490 lines added, 15 removed
crates/trusted-server-adapter-axum/src 4 files, 380 lines added, 8 removed
docs/superpowers 3 files, 136 lines added, 13 removed
docs/guide 6 files, 84 lines added, 11 removed
crates/trusted-server-adapter-spin/tests 1 file, 53 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/tests 1 file, 52 lines added, 0 removed
CHANGELOG.md 1 file, 3 lines added, 0 removed

Closes

Part of #777

Spec

Spec: asset-routes-on-every-adapter

Test plan

CI on this pull request at 1dde36e passed all 27 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at 1dde36e

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on the Edge Cookie on every adapter (#1411).

What this pull request does

The design is asset-routes-on-every-adapter. [[proxy.asset_routes]] were dispatched on the Fastly adapter alone, so on the Axum, Cloudflare and Spin adapters a request under a route went to the publisher's origin without the route's signing. Those adapters now dispatch asset routes as Fastly does, with the route's source module, its signer, its held answers and its cache policy, the request filters' effects kept and no Edge Cookie state.

  • Breaking, on Spin: a request to a source whose host_header names another host answers 502 before its origin is asked, the publisher's [source.origin] as well as an asset route's source, because Spin can only send the host in the address. Before, the request reached its origin with that host in place of the one host_header named. The changelog gives the check to make before upgrading.
  • The Cloudflare and Spin adapters now install a logger of their own, because EdgeZero's does nothing on those hosts, so their warnings and errors reach wrangler tail and Spin's console and .spin/logs, where before every line was lost.
  • As it builds its state, each of the three adapters warns about a route that enables the Image Optimizer, which none of their clients can run, and the design says why that is a warning and not a refusal.
  • None of the three adapters' HTTP clients streams, so an asset body is held whole.
  • The Axum client now refuses Image Optimizer metadata on send and send_async, as the Cloudflare and Spin clients already did, where it sent the request unchanged.

How it was verified

Locally on Windows at f429b64: cargo test-cloudflare (79) and cargo test-spin (116) passed, with clippy refusing warnings on both adapters natively and for WebAssembly, and cargo fmt --all -- --check. At 76986f0 cargo test-axum (1,371) and the parity tests (17) passed. A request under a route reaches the route's origin with an AWS4-HMAC-SHA256 signature from the stock s3_sigv4 signer, one outside it reaches the publisher's origin unsigned, and on each adapter the route's cache rule, the 502 that no cache keeps and the missing Edge Cookie are tested. Each logger is installed once at Info and above, and each new test fails with its change reverted. With every row below merged in, this row passed cargo fmt --all -- --check, clippy on the four adapters natively and for WebAssembly, core's 2,989 tests, cargo test-axum, cargo test-cloudflare (90), cargo test-spin (127), the parity tests (17), the Fastly adapter tests under Viceroy (300) and the CLI's library suite (706) at 19d15b2.

Each adapter's dispatch_fallback now checks a GET or HEAD against
[[proxy.asset_routes]] where the Fastly adapter does, once robots.txt, the
Edge Cookie context, the request filters, tsjs and the module routes have
had their turn and before the publisher's origin. dispatch_asset_fallback
follows the Fastly function of that name: the registry's source for the
route, handle_asset_proxy_request, which locates the asset, signs the
request with the route's signer and answers from a held copy before it
asks the origin, the response's cache policy in its extensions and the
request filters' effects, and no Edge Cookie state.
FinalizeResponseMiddleware applies the cache policy after the finalize
headers with the adapter's own shared cache header, before the Edge Cookie
and the filters' effects.

None of the three HTTP clients streams, so the asset body is held whole.
The Cloudflare and Spin clients already refuse a request carrying Image
Optimizer metadata, which only an asset route sets, and the Axum client now
refuses it too where it sent the request unchanged. The Cloudflare
client's note no longer says asset routes are not routed to it.

src/app/request_cycle_tests.rs in each adapter checks, with a stub origin
and an asset route whose source signs with the stock s3_sigv4 signer, that
a request under the route reaches the route's origin with an
AWS4-HMAC-SHA256 signature and that one outside it reaches the publisher's
origin unsigned. The Axum platform tests check that a request with Image
Optimizer metadata is refused while its origin would have answered.

cargo test-axum (1,365 passed), cargo test-cloudflare (68) and cargo
test-spin (102) pass, and so do the cross-adapter parity tests (17), the
Cloudflare wasm32 check, the Spin native and wasm32-wasip1 checks and its
release build, clippy with warnings denied on the three adapters and
cargo fmt.
docs/superpowers/specs/2026-10-17-asset-routes-on-every-adapter-design.md
sets out how the Fastly adapter serves [[proxy.asset_routes]], the
dispatch and finalize steps the Axum, Cloudflare and Spin adapters now
run, where each holds the route's answers, that their bodies are held
whole, and that a route enabling the Image Optimizer answers with an
error there.
…pter

The changelog gains a line under Changed. The route table of the API
reference lists the asset routes as served on every adapter, the asset
routes guide says what the Axum, Cloudflare and Spin adapters do with the
body and the Image Optimizer, and their own guides say the routes are
served there.
@jwrosewell
jwrosewell added this pull request to stack #1408 October 18, 2026 01:31
Spin sends the address's own host as the Host of every request, because
WASI HTTP takes the authority from the address and forbids a host header,
so the Spin HTTP client dropped the Host that core writes for a source's
host_header. A route whose source names another host therefore reached its
origin with the wrong Host, and a signed one with a signature made for the
Host it named. The publisher's own source has the same fault.

NoopBackend::ensure now refuses a backend asked for a Host other than the
address's own host, with its port where that is not the scheme's default,
so core answers the request with 502 before anything is sent, the signing
HEAD of a route that uses the Image Optimizer included. The backend is the
one the adapter's own services use, and it is pub(crate) so the request
cycle tests can send requests through it.

The platform tests cover another name, another port and the same host
written with its default port or in other letter case. Through that
backend, a request under a route whose source names another host answers
502 with no-store, private while a route without one reaches its origin,
and a page request to a publisher source that names another host answers
502 with its origin never asked.

With the refusal disabled, a_backend_asked_to_send_another_host_is_refused
fails at its expect_err and both request cycle tests fail, because their
requests reach the origins and answer 200, while
a_backend_asked_to_send_the_address_s_own_host_is_named still passes.
The PlatformHttpRequest contract asks a client that cannot honor Image
Optimizer metadata to refuse the request rather than send it untransformed.
The Axum client refused it on send, which asset routes use, but send_async
built and sent its own request with no check. Both paths now call
refuse_image_optimizer_metadata first.

a_request_with_image_optimizer_metadata_is_refused sends through send and
send_async to an origin that would answer.

With the send_async check taken out the test fails at its send_async
assertion, while send still refuses.
…Image Optimizer

The HTTP clients of those adapters refuse Image Optimizer metadata, so a
route that enables the Image Optimizer answers each request it would
optimize with 502, and nothing at startup said so. Each adapter's state
builder now logs one warning naming the prefix of every such route, once
for each instance, as the identity graph warning does.

The route is still served rather than refused, because its SVG paths, its
debug requests and, where it signs, its origin's own errors are answered,
and a refusal would stop every route and page of a deployment that shares
its settings with a Fastly service whose routes optimize images.

startup_warns_when_an_asset_route_enables_the_image_optimizer in each
adapter's request cycle tests builds the router with such a route and
checks that the warning was logged.

With the call taken out of the state builder the test fails on each
adapter.
…ength

The comment copied from the Fastly adapter said that a HEAD answer keeps
the origin's Content-Length. The Cloudflare client holds every response
whole and writes the length of the body it read, which for a HEAD is 0,
and the comment now says so.
…dge Cookie

Nothing proved, on the Axum, Cloudflare or Spin adapter, that
FinalizeResponseMiddleware applies an asset response's cache policy, that
a failed asset request is marked no-store, private, or that an asset
response carries no Edge Cookie. Each adapter's request cycle tests now
check, with a stub origin, that a [[cache.asset_rules]] rule is written
after the finalize headers in the adapter's own shared cache header,
s-maxage on Axum and Spin and cloudflare-cdn-cache-control on Cloudflare,
that an origin that fails answers 502 with no-store, private, and that a
browser's page navigation under the route gets no Edge Cookie where its
permissions would grant one on a page.

Taking the cache policy step out of FinalizeResponseMiddleware fails the
cache rule and error tests on each adapter, the rule then giving public,
max-age=60 with no shared lifetime and the error no Cache-Control, and the
Spin route refusal test too, which also checks no-store, private. Taking
NoStorePrivate out of the error arm fails the error test on each adapter,
and giving the asset response the publisher path's Edge Cookie state
writes a ts-ec cookie and fails the Edge Cookie test on each adapter.

With the fixes of this round, cargo test-axum (1,371 passed, 1 ignored),
cargo test-cloudflare (74 passed, 1 ignored), cargo test-spin (112
passed, 1 ignored) and the cross-adapter parity tests (17) pass, and so
do clippy-axum, clippy-cloudflare, clippy-cloudflare-wasm,
clippy-spin-native and clippy-spin-wasm with warnings denied, and cargo
fmt --all -- --check.
…oudflare and Spin

The asset routes guide gives the body limits on Cloudflare and Spin, the
502 with no-store, private that a route enabling the Image Optimizer
answers there, the startup warning, the HEAD a signed route sends first,
the SVG and debug requests that pass, and what Spin and Cloudflare do with
a source's host_header. The Axum, Cloudflare and Spin guides say the same
for their adapter, and the Cloudflare guide gives the reason as the
adapter not translating the metadata into Cloudflare's own image options,
where it said Workers has none.

The API reference no longer calls the route Fastly-only and names the 502
answers, and the configuration guide says Spin cannot send host_header and
no longer calls an asset route Fastly's. The changelog entry says all of
this, that an asset response neither writes nor expires the Edge Cookie,
and that on Spin a request to a source whose host_header names another
host, a route's or the publisher's, now answers 502.
The origin signing design said asset routes and their preflight run on the
Fastly adapter alone, and now says every adapter runs them, with Spin
refusing a source whose host_header names another host before it is
signed. The asset cache design said an origin fetch has 15-second
timeouts, and now says core asks for them and only the Fastly adapter
applies them, the Axum client applying its own limits and the Cloudflare
and Spin adapters none of their own.
The design names its pull request and states Spin's refusal of a source's
host_header and what Cloudflare does with one, the startup warning for a
route that enables the Image Optimizer and why it is a warning, the HEAD a
signed route sends and the SVG and debug requests that pass, the Axum
client's refusal on send_async, the startup refusal of a source that
cannot be built, the per-request failures with their statuses, the body
limits and timeouts, where answers are held, and that an asset response
neither writes nor expires the Edge Cookie. It lists the new tests and
how each was shown to fail, leaves the Cloudflare host_header and HEAD
length as open questions, and ends with a review log entry for round 1.
EdgeZero's run_app installs no logger on Workers or Spin, because its
init_logger does nothing on either at v0.0.9, and neither adapter
installed one, so the log crate dropped every line the two adapters
wrote: the warnings they log as they build their state, a startup error
and the report behind each error answer, such as Spin's refusal of a
source whose host_header names another host.

Each entry point now calls init_logger before run_app. The Worker's
logger writes each line through the worker crate's console macros by
level, console.error, console.warn, console.log for info and
console.debug below that, and the Spin component's writes each line to
standard error. A line gives the level and the module before the
message, both write info and above, and a second call in an instance
finds the logger installed and changes nothing, because the entry point
runs for every request. Each adapter's Hooks::owns_logging now answers
true, so run_app does not try to install a logger of its own.

The unit tests cover the console method for each level, the line and
the level filter, and tests/logging.rs in each adapter, a binary of its
own because a logger is global to a process, calls init_logger twice
and checks that one logger is installed at info and above.
…y where Cloudflare and Spin log

On Spin a request to a source whose host_header names another host
answers 502 before its origin is asked, the publisher's source as well as
an asset route's, so a Spin deployment whose publisher source sets one
answers every page with 502. The changelog said so at the end of the
asset routes entry, and now gives it a Breaking entry of its own with the
check to make before upgrading. A Fixed entry says the Cloudflare and
Spin adapters now install a logger and where each writes its lines.

The Cloudflare and Spin guides gain a Logging section. The Worker's lines
go to the console by level, which wrangler dev prints and wrangler tail,
the dashboard's live logs and, where the Wrangler file enables
observability, Workers Logs show. The component's go to standard error,
which spin up prints and keeps in .spin/logs beside a local manifest.
Both guides say the Image Optimizer warning is logged once for each
instance, and the Spin guide that Spin can serve several requests from
one instance, to a degree it does not guarantee, so there the warning
appears each time Spin starts an instance.

The asset routes and configuration guides say Spin refuses a host_header
that names another host, where they said any host_header, because the
backend accepts the address's own host, and the API reference's
publisher fallback contract gives the Spin 502.
The contributing guide asks a spec to end with its review log, and both
designs had it before the revision record. The two sections change
places and their text is unchanged.
The summary, the problem and the first rollout item now say that the
Spin refusal of a host_header naming another host reaches the publisher
path as well as the asset routes, so a Spin deployment whose publisher
source sets one answers every page with 502, a breaking change. A
Logging paragraph says the Cloudflare and Spin adapters install a logger
of their own and where each writes its lines, and Configuration says how
often each adapter logs the Image Optimizer warning: once for the Axum
process, once for each Workers instance, and once for each instance of
the Spin component, which Spin can reuse for several requests to a
degree it does not guarantee. It also says why such a route gets a
warning where Cloudflare and Spin refuse an auction of more than one
provider at startup, which is that such an auction could run nothing
there.

The design also states the reader headers a route's origin receives, the
rise in key-value traffic after the upgrade, the signed source whose
host_header names the address's own host in other letter case, the
error lines each adapter now writes, what the warning test reads, which
Spin client code the native tests reach, the logger tests and how each
was shown to fail, and what is not in this change. The round 1 entry of
the review log names the public branch and the round 1 revision by its
commit, and a round 2 entry follows.
# Conflicts:
#	CHANGELOG.md
#	crates/trusted-server-adapter-axum/src/app.rs
#	crates/trusted-server-adapter-axum/src/app/request_cycle_tests.rs
#	crates/trusted-server-adapter-cloudflare/src/app.rs
#	crates/trusted-server-adapter-cloudflare/src/app/request_cycle_tests.rs
#	crates/trusted-server-adapter-spin/src/app.rs
#	crates/trusted-server-adapter-spin/src/app/request_cycle_tests.rs
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell jwrosewell closed this Oct 18, 2026
@jwrosewell jwrosewell reopened this Oct 18, 2026
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell jwrosewell changed the title Asset routes are served on every adapter Serve asset routes on every adapter Oct 19, 2026
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:24

This branch has not been deployed

No deployments
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.

1 participant