Skip to content

Address requests by path before choosing settings - #1376

Open
jwrosewell wants to merge 44 commits into
feature/34-asset-cachefrom
feature/35-request-addressing
Open

jwrosewell wants to merge 44 commits into
feature/34-asset-cachefrom
feature/35-request-addressing

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 5 files, 759 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 3 files, 710 lines added, 31 removed
docs/superpowers 1 file, 129 lines added, 0 removed
docs/guide 1 file, 37 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/tests 1 file, 31 lines added, 1 removed
crates/trusted-server-adapter-spin/tests 1 file, 31 lines added, 1 removed
crates/trusted-server-adapter-axum/tests 1 file, 30 lines added, 0 removed
crates/trusted-server-adapter-axum/src 1 file, 24 lines added, 0 removed
crates/trusted-server-adapter-cloudflare/src 1 file, 24 lines added, 0 removed
crates/trusted-server-adapter-spin/src 1 file, 24 lines added, 0 removed
CHANGELOG.md 1 file, 2 lines added, 0 removed

Closes

Closes #1328

Spec

Spec: request-addressing

Test plan

CI on this pull request at c61a4f4 passed all 29 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 c61a4f4

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

On top of the publisher's assets held through the loading cache (#1375). Issue: #1328.

What this adds

What changes.

One host may serve several publishers, each under a path of its own and from its own app-config blob. Core offers one extension point, RequestAddressing, which the adapter asks about each request before it chooses the blob and before it routes. The hook reads the request's host and path and answers either that the request is addressed by its host, as every request is today, or three things and nothing else: the selector values that take the place of {host} in the __KEY selector, the path the request continues with, and the prefix it removed.

pub trait RequestAddressing: Send + Sync {
    fn address(&self, host: Option<&str>, path: &str) -> Addressing;
}

A deployment hands its hook to trusted_server_adapter_fastly::run_with_addressing beside its integration builders, or to TrustedServerApp::routes_with_addressing on Axum, Cloudflare and Spin. run_with and routes_with_registrations are unchanged, so no existing caller changes, and an adapter given no hook uses ByHostAddressing, which serves every request as before. On a Fastly service whose __KEY selector is {host}, a hook that takes the first path segment as the publisher gives these results.

A request for Reads the blob at Is routed as Carries the prefix
https://front.example/publisher-a.example/news?p=2 publisher-a.example /news?p=2 /publisher-a.example
https://front.example/publisher-b.example/ publisher-b.example / /publisher-b.example
https://front.example/unknown.example/news None 421 Misdirected Request, not to be stored None
https://front.example/not_a_host/news None, no lookup 421 Misdirected Request None
https://front.example/health on Fastly None 200, before the hook is asked None

How it works.

  • The first value whose blob exists is read. Each selector value takes the place of {host} in turn and is keyed as a host is, lowercased with a port and a trailing dot removed. A value that is not a host name is passed over without a lookup, so a value cannot name the logical store ID or a part of a stored blob. A miss on every value is answered 421, as a host with no blob is, and never from a key the hook did not name, which includes the blob of the host the request arrived on. A lookup that fails refuses the request 500 and no later value is tried.
  • The request continues at the given path, with its query kept, so the routes and every module see that path. The removed prefix travels on the request as a RemovedPathPrefix extension. Core never writes it into a response, and a deployment whose pages must carry it puts it back with a serve phase middleware of its own.
  • Axum, Cloudflare and Spin read one blob, so the values are ignored and core's addressed_router wraps the router they build, applying the path and the prefix for every path and method the inner router registers.
  • The Fastly adapter answers /health first, then asks the hook, then serves the debug probes, so the probes read the blob and the path the request is addressed to, which a test drives through the hook.
  • Two publishers on one host do not share a stored page, because the stored template's key carries the origin and a digest of the blob's settings as well as the host and the path. They share the host's cookies unless the deployment's prefix module names them apart.
  • Logs never print the path or the values. A request refused for want of a blob logs how many values the hook named and the selector, and the config store lookup error no longer carries the key.

Why

A Fastly service reads the app-config blob its __KEY selector names, and a selector that carries {host} names a blob for each host, so every publisher needs a host of its own. A middleware cannot change that, because a middleware is named by settings, so it runs after a blob has been chosen and after the request has been routed. Only a step the adapter takes before both can choose the blob and change the path the router sees, so the extension point is in core, and the policy that uses it, such as which part of a path names a publisher, is the deployment's.

What breaks

Nothing. A deployment that supplies no hook is served exactly as before, and the existing entry points are unchanged.

The commits

Commit What it does
8bdaa4f The extension point in core, each adapter taking the hook, and the guide
8ad8284 The design page says what keeps publishers on one host apart
779e691 The merge of #1375, after the merges of #1374 as its head moved
a68f811 The guide says a purge by address reaches every publisher on the host, and that they share cookies
e4a5bc2 The adapters' tests share core's addressing hooks
16dc3c7 The debug probes are found by one function a test drives through the hook
f0da74d The request-addressing design names its pull request and issue
d58b3f4 The request addressing design states the cost of putting the prefix back and its limits
b68ba83 The request addressing design takes its round 2 corrections
5e28d48 The request addressing design names the specs pull request that holds it
fb61723 Hand a request addressing hook no host where Host is not UTF-8
e401521 Pass over an addressed value whose key is too long

The other commits merge the branch below into this one, and through it Tech Lab main at 6ef07ae.

What is not in this pull request

  • The deployment's policy, being which part of a path names a publisher and how a value is spelled.
  • Writing the prefix into pages, which is a middleware outside core.
  • Choosing a blob for each request on Axum, Cloudflare and Spin, which read one blob.
  • Purging one page by its address. A page stored for an addressed request is keyed by the front host and the continuing path, so ts cache purge --page with that address also purges the page at the same path of every other publisher on the host.
  • /health under a prefix on Fastly, which is addressed like any other path. The probe at the root is answered before the hook is asked.

The design page is docs/superpowers/specs/2026-10-13-request-addressing-design.md.

jwrosewell and others added 13 commits October 13, 2026 22:04
…on is chosen

Core offers RequestAddressing, asked about each request before the adapter
chooses its app-config blob and before routing. It answers either that the
request is addressed by its host, as before, or three things: the selector
values that take the place of {host} in the __KEY selector, the path the
request continues with, and the path prefix it removed. ByHostAddressing is
the default, so an adapter given no hook serves every request as before.

The Fastly adapter takes the hook through run_with_addressing beside the
integration builders. It reads the first selector value whose blob exists,
keyed and checked as a host is, and answers 421 when none has one, never
from a key the hook did not name. The request continues at the given path
with its query kept and carries the prefix as a RemovedPathPrefix
extension. A selector without {host} ignores the values.

The Axum, Cloudflare and Spin adapters take the hook through
TrustedServerApp::routes_with_addressing, which wraps the router they build
with core's addressed_router. They read one blob, so the values are ignored
and the path and prefix apply.

Logs never print the path or the values, and the config store lookup error
no longer carries the key. Putting the prefix back into responses is the
deployment's job, through middleware. The design is in
docs/superpowers/specs/2026-10-13-request-addressing-design.md.
…apart

A refusal logs how many values the hook named, not how many were tried.
Publishers on one host are kept from sharing a stored page by the origin
and the settings digest in the stored template's key, and share the host's
cookies unless the deployment's prefix module names them apart. The hook is
asked about /health wherever an adapter routes it.
# Conflicts:
#	crates/trusted-server-core/src/lib.rs
…to it

Purging an addressed page also purges the page at the same continuing path of every other publisher on the host, and publishers on one host share its cookies unless the prefix module names them apart. The design page's rules list gave way to a pointer to the guide and the rustdoc that state them.
FirstSegment, NamesSelectorValues and status_and_type live in the test_support addressing module behind test-utils, and the tests use ByHostAddressing in place of a hook of their own that answers by host.
…gives

The probe a request asks for is read by debug_probe, which tests drive through the addressing. The two blob choices log a refusal through one helper, and the rustdoc of run_with_addressing says only what a caller needs.
…ack and its limits

Review round 1 found that putting the prefix back with a module's request
state leaves the page unstored and sent private, no-store, and that
publishers on one host share the consent cookies, the sharedId cookie and
the forwarded Cookie header. The design now says both, gives the host each
adapter hands the hook, the answers on failure, the rollout order and the
two paths with no test, and records the review round.
Review round 2 found four small points, none blocking. The cost of
putting the removed path prefix back no longer says every such page is
fetched from the origin for each reader, because the platform's cache
can still answer the origin request unless origin readthrough is on or
the ad stack runs. The rollout order, which rests on the 421 for a
missing blob, is limited to a Fastly service whose selector carries
{host}. The round 1 entry of the review log says the copy of the design
on split/6-integration-seam is brought up to date in the specs pull
request. The removed path prefix is named in full wherever the text
means it, and the prefix module is left as it was. The review log and
the revision record carry round 2.
The specs pull request #1084 was closed on
15 October 2026 and its review moved to #1342,
which has the same head commit. The Related specs line and the round 1
review log entry now name the pull request that holds the design
documents.
address_request handed the hook the request's Host read with
get_header_str, which panics on a value whose bytes are not valid UTF-8,
and a client chooses those bytes. It now reads Host through request_host,
as the selection by host and both module requests do, so the hook is
handed None for such a value, and every read of Host in the adapter goes
through the one function that cannot panic.

Tests: a_hook_is_handed_no_host_for_a_value_that_is_not_utf8 records the
host a hook is handed for a Host of the bytes b"\xffa.example" and checks it
is None and that the request, addressed by host, is refused 421.
a_request_addressed_by_its_path_with_a_host_that_is_not_utf8_is_served_without_a_panic
addresses a request for /publisher.example/news/today with such a Host by
the selector value current-kid, which the local jwks_store holds, and checks
the request is served from that key and that the module request and the
kept request read no host.
A selector value whose key would be longer than the 255 characters a
Fastly config store entry takes is passed over without a lookup, as a
value that is not a host name is, and the search goes on to the next
value. The rule came with the merge of the config by host change, which
never looks up a host key that long, into the search over selector values.

Test: an_addressed_value_whose_key_is_too_long_is_passed_over names the
longest host name, whose key under {host}_v2 is 256 characters, and then
a.example, and checks a.example_v2 is read and is the only key the store
is asked about. The guide says the selector where it said the __KEY
selector, and states the rule.
`RequestInfo::from_request` read the host from `Forwarded`, then
`X-Forwarded-Host`, then `Host`, and gave an empty host when none of them
was sent. An HTTP/2 client may send its `:authority` and no `Host`
header, and core runs no middleware for a request that names no host, so
such a request's page went back unmodified, with no URL rewriting,
middleware, injected script or auction, and only a warning said so.

The host now comes last from the request URI's authority, read as
`address_request` and the attestation page's address already read it,
so its port is kept as a `Host` header would carry it and each header
still takes precedence.

The tests read the host from the authority, with its port, when no
header names one, find no host when nothing names one, show each of
`Host`, `X-Forwarded-Host` and `Forwarded` winning over the authority,
and send a request with no `Host` header through the publisher path to
find the entry's middleware run for the host the URI names. The
changelog and the request addressing design record the change.

The fault was reported in #1118.
@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 closed this Oct 19, 2026
@jwrosewell jwrosewell reopened this Oct 19, 2026
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell jwrosewell changed the title A deployment may address a request by its path before its configuration is chosen Address requests by path before choosing settings Oct 19, 2026
@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 marked this pull request as ready for review October 19, 2026 20:23

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.

A deployment may address a request by its path before its configuration is chosen

2 participants