Skip to content

Keep headers that describe the deployment off a publisher's pages - #1356

Open
jwrosewell wants to merge 27 commits into
feature/14-robots-txtfrom
feature/15-diagnostic-headers
Open

jwrosewell wants to merge 27 commits into
feature/14-robots-txtfrom
feature/15-diagnostic-headers

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-core/src 12 files, 989 lines added, 273 removed
crates/trusted-server-adapter-fastly/src 3 files, 68 lines added, 91 removed
crates/trusted-server-adapter-axum/src 2 files, 59 lines added, 16 removed
crates/trusted-server-adapter-spin/src 2 files, 58 lines added, 20 removed
crates/trusted-server-adapter-cloudflare/src 2 files, 55 lines added, 14 removed
docs/guide 3 files, 30 lines added, 10 removed
crates/trusted-server-modules/src 1 file, 8 lines added, 2 removed
trusted-server.example.toml 1 file, 6 lines added, 5 removed
crates/trusted-server-adapter-axum/tests 1 file, 4 lines added, 8 removed
CHANGELOG.md 1 file, 3 lines added, 1 removed

Closes

Closes #1273

Spec

Spec: diagnostic-headers

Test plan

CI on this pull request at 21b0bae passed all 32 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 21b0bae

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 robots.txt from modules (#1355).

What changes

A response no longer carries X-Served-By, X-Cache or X-Cache-Hits, unless this server answered a request for a path beneath /_ts.

Header What it tells whoever reads it
X-Served-By Which node answered
X-Cache Whether a cache answered
X-Cache-Hits How many times that cache has answered with the object

No adapter writes a version on a response. What the build was made from is in the attestation document (#1353), under build, and the platform's version number of the service under platform.serviceVersion, which is where a deployment is asked about itself. A deployment that wants the versions on its pages names core's service_version middleware in a [[serve]] entry. The middleware writes X-TS-Version, the git version the build compiled in from TRUSTED_SERVER__GIT_VERSION or local git, when the build knows one, and X-TS-Service-Version, the version the adapter reports for the service, when it reports one, on every page the entry covers and on nothing else.

[[serve]]
media_type = "text/html"
middleware = ["service_version"]

How it works

pub const ROUTE_NAMESPACE: &str = "/_ts";

pub fn strip_deployment_headers<T>(path: &str, namespace: &str, response: &mut Response<T>)
pub fn remove_deployment_headers<T>(response: &mut Response<T>)
  • Who answered decides beneath /_ts. The publisher fallback removes the three from whatever the origin sends, on every path. The finalize middleware of each of the four adapters then removes them from every response outside the namespace. So a request for an unrouted /_ts/anything, which the fallback sends to the origin, does not get the origin's headers back.
  • A path is in the namespace only at a boundary. /_ts and /_ts/permissions are. /_tsx and /_ts-archive are a publisher's pages, and are stripped like any other.
  • The removal comes before the finalize step. A header that finalizing writes is kept, an operator's [response_headers] among them. An operator who wants X-Cache on every page can still set it there.
pub type ResponseHeader = (HeaderName, HeaderValue);

pub struct MiddlewareAction {
    pub response_headers: Vec<ResponseHeader>,
    // ...
}

pub struct MiddlewareContext<'a> {
    pub platform: &'a PlatformIdentity,
    // ...
}
  • A serve middleware may write a response header. Core writes the headers a serve chain returns on the reader's response at each place a serve pass runs, being the assembled copy of a stored page, the streamed copy and the inline copy of a page that is not stored. A fetch middleware that writes one is refused when the chain is planned, because what a fetch middleware leaves is stored for every reader.
  • Every middleware is told the platform, as the adapter reports it for the attestation evidence, through the registry, which each adapter tells once at startup.
  • service_version is core's own middleware. No section selects it, an entry names it, and it writes X-TS-Version, the git version the build compiled in, and X-TS-Service-Version, the platform's version number of the service, each only when its version is known.

Why

A response to a reader carries what the page needs and nothing about the deployment, so that the response stays as clean and free of clutter as it can be. The headers above describe how the site is built, to anyone who asks for any page, without that being a decision anyone made. Three arrive from the publisher's origin or a cache on the way to it, and the version was written by the adapter itself.

A question about a deployment has addresses of its own. The inspection pages (#1350) answer beneath /_ts, and so does attestation (#1353) unless its endpoint is moved, and what each shows is the publisher's decision. The rule here is the same one. What describes the deployment is answered where a deployment is asked about itself, and is left off everything else. A deployment that has a reason to put the service version on its pages says so in its settings, with the middleware, and the decision is then its own.

What breaks

  • An operator who reads X-Cache or X-Served-By from a page fetched through this server, to see whether the origin's cache answered, no longer sees them. The origin still sends them, and they can be read at the origin.
  • A check that read X-TS-Version from a page no longer finds it, whichever adapter served the page and whichever version it carried. It reads the attestation document, where build says what the build was made from and platform.serviceVersion is the platform's number, or the deployment names service_version in a [[serve]] entry.

How it was verified

Not exercised: a response the Fastly entry point sends outside the middleware, being the router's own 405 and the two debug endpoints, is not stripped. It carries nothing from an origin.

References #1273.

jwrosewell and others added 14 commits October 9, 2026 18:07
`x-served-by` names the node that answered, and `x-cache` and `x-cache-hits`
say how a cache treated the request. They arrive on a response from the
publisher's origin or from a cache on the way to it.

`strip_diagnostic_headers` removes the three from a response unless the
request was for a path in the namespace it is given. `ROUTE_NAMESPACE` names
`/_ts`, the prefix of every path this server answers itself. A path that
only starts with the namespace's text, `/_tsx` for one, is not in it.
The finalize middleware of each adapter takes the request's path before the
handler runs, and removes the three headers from the response unless the
path is beneath `/_ts`. The removal comes before the finalize step, so a
header of one of those names that an operator sets in `[response_headers]`
is still sent.

Each adapter has three tests of its own middleware. One sends a response
naming its node for a publisher's page and finds the headers gone, one does
the same for a path beneath `/_ts` and finds them kept, and one finds an
operator's `X-Cache` on a publisher's page.
The response headers section of the configuration guide names the three
headers and says where they are kept, and the changelog records the change.
The publisher fallback removes X-Served-By, X-Cache and X-Cache-Hits from whatever the origin sends, so an unrouted path beneath /_ts no longer hands them back. The code says deployment as the title does, ROUTE_NAMESPACE says what answers beneath it, and each adapter keeps one test, that the strip runs before finalize.
The header named the platform's version number of the service to anyone
who asked for any page. The version is in the attestation document, under
platform.serviceVersion, where a deployment is asked about itself, and the
Axum, Cloudflare and Spin adapters never wrote the header.
The middleware action carries headers for the reader's response, which core
writes on the response at each place a serve pass runs: the assembled copy
of a stored page, the streamed copy and the inline copy of a page that is
not stored. A fetch middleware that writes one is refused when the chain is
planned, because what it leaves is stored for every reader and a header
goes on one reader's response.

The middleware context names the platform the deployment runs on, as the
adapter reports it for the attestation evidence. The registry carries that
report, each adapter tells it once at startup, and the attestation route
reads the same report.
A deployment that wants the platform's service version on its pages names
`service_version` in a `[[serve]]` entry, and X-TS-Version is written on
every page the entry covers, with the version the adapter reports, and on
nothing else. No section selects it, it runs in the serve phase alone, and
a platform that reports no version gets no header. The stock list, the
example and the configuration guide carry it, and the publisher's tests
prove the header reaches a buffered and a streamed page.
Core's version_header module is removed, with its call from the finalize
step of the Axum, Cloudflare and Spin adapters and from the Fastly adapter's
health probe and its settings fallback, so no response carries x-ts-version
unless a [[serve]] entry names the service_version middleware. That
middleware now writes the git version the build compiled in from
TRUSTED_SERVER__GIT_VERSION or local git as X-TS-Version, when the build
knows one, and the version the adapter reports for the service as
X-TS-Service-Version, when it reports one. HEADER_X_TS_FASTLY_VERSION, which
nothing wrote, goes with the module.

The health tests of the three adapters prove the probe carries no version,
the middleware's own tests prove each header is written when its version is
known and nothing when neither is, and the publisher tests prove both
headers reach a buffered page and neither reaches a page no entry names the
middleware for. The configuration guide, the getting started guide, the
EdgeZero guide and the changelog say the same.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@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 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.

Every page tells anyone which node answered it and whether a cache did

2 participants