Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f1b6fef
Core can remove the headers that describe a deployment
jwrosewell Oct 9, 2026
506727f
Every adapter strips the diagnostic headers before it finalizes
jwrosewell Oct 9, 2026
5b632c4
The guide says which headers leave a response
jwrosewell Oct 9, 2026
87b3683
Merge branch 'review/14-robots-txt' into review/15-diagnostic-headers
Jamesr51d Oct 12, 2026
450dc2d
The origin's answer beneath /_ts loses the deployment headers
Jamesr51d Oct 12, 2026
06ba471
Merge branch 'review/14-robots-txt' into review/15-diagnostic-headers
Jamesr51d Oct 12, 2026
d4d3149
Merge 14-robots-txt into 15-diagnostic-headers
Jamesr51d Oct 12, 2026
eacc120
The Fastly adapter no longer writes X-TS-Version on every response
jwrosewell Oct 13, 2026
5cc8fe7
A serve middleware may write a response header, and is told the platform
jwrosewell Oct 13, 2026
0991047
Core supplies a middleware that writes the service version where named
jwrosewell Oct 13, 2026
50c10da
Merge 14-robots-txt into 15-diagnostic-headers
jwrosewell Oct 13, 2026
49940a8
Merge split/14-robots-txt into split/15-diagnostic-headers
Jamesr51d Oct 13, 2026
e4de006
Merge split/14-robots-txt into split/15-diagnostic-headers
Jamesr51d Oct 13, 2026
a43a05a
No adapter writes a version on a response
jwrosewell Oct 13, 2026
0963d16
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 16, 2026
59fee0a
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 16, 2026
9d8ddd1
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 16, 2026
1900b7d
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 16, 2026
bbac4e6
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 16, 2026
c06ec61
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 18, 2026
6bcfb75
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 18, 2026
b234c5d
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 18, 2026
de69ddb
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 18, 2026
52fcb1c
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 19, 2026
c16c51e
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 19, 2026
64b7334
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 19, 2026
21b0bae
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 19, 2026
cf6432d
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
b8f0004
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
7cdbf89
Keep the platform identity in the platform module
jwrosewell Oct 28, 2026
54b394d
The comments and guides name no attestation
jwrosewell Oct 28, 2026
e33e123
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
516dc42
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
ee9d04c
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
69087e0
The changelog says which middleware writes the versions
jwrosewell Oct 28, 2026
1828748
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
146489e
Merge split/14-robots-txt into split/15-diagnostic-headers
jwrosewell Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking:** Settings must name the publisher's permission policy in `[permissions] policy`, and its file is supplied with `ts config push --permissions`. A domain without a valid policy no longer starts. No adapter build carries a policy anymore, and the policy a domain loads decides the permissions of each request it serves. The push writes the SHA-256 of the file's exact bytes into the blob and, on Fastly, Cloudflare and Spin, the file itself, and Axum reads `<policy>.yaml` from the folder `TRUSTED_SERVER_PERMISSIONS_DIR` names. A domain whose settings name no policy, or whose file is missing, is not the file that was pushed or does not parse, answers `500`. `ts config validate` checks the policy when given `--permissions`, and `ts config diff` needs the file whenever the settings name a policy, because the stored settings carry its SHA-256.
- **Breaking:** The Cloudflare adapter reads its settings from its config store, the Workers KV namespace `ts config push` writes, bound as `trusted_server_config` by default, and no longer reads the `TRUSTED_SERVER_CONFIG` Worker variable, which holds at most 5 KB. Bind the namespace in `wrangler.toml` and push into it.
- A Spin service whose settings fail to load answers with the status of the startup error's kind, `500` for a configuration or settings error and `503` for a key-value store error, as the other adapters do, where it answered `503` for every kind.
- **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header.
- **Breaking:** No adapter writes `x-ts-version` on a response, and the Fastly adapter no longer writes the service version. The build compiles the deployed git version in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, being the release tag, else the branch, else the first 6 characters of the commit, and core's `service_version` middleware writes it as `x-ts-version`, with the platform's service version as `x-ts-service-version`, on the pages a `[[serve]]` entry names it for. Update dashboards, monitors and scripts that read either from a page, or name the middleware.
- **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias.
- The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave.
- **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.<id>]` and `[auction.bidders.<id>]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.<id>]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. Provider response names now use the configured provider ID, such as `pbs-main`, instead of the legacy literal `prebid`; audit consumers that match `AuctionResponse.provider`. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary.
Expand All @@ -67,6 +67,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- 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`. The three name the node that answered and say how a cache treated the request, and arrive from the publisher's origin or a cache on the way to it. Every adapter removes them before it finalizes a response, so one set in `[response_headers]` is still sent.
- No adapter writes a version on a response. A deployment that wants the versions on its pages names core's `service_version` middleware in a `[[serve]]` entry, which writes `X-TS-Version` from the git version the build compiled in and `X-TS-Service-Version` from the version the adapter reports.
- A module's table that cannot be read or fails its validation is refused naming the setting at fault and never its value, because at startup the table holds the module's secrets resolved from the secret store and each adapter logs a refusal whole.
- `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect.
- The `/_ts/admin` prefix and the `/admin/keys` aliases are closed at the publisher-fallback boundary on every adapter. A request to either, including trailing, descendant and encoded-separator forms, is answered `404` by the server itself, so a caller's `Authorization` header and body are never forwarded to the publisher origin. The path is evaluated as sent and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again.
Expand Down
5 changes: 3 additions & 2 deletions crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store_with,
};

use trusted_server_core::platform::{PolicyOrigin, RuntimeServices};
use trusted_server_core::platform::{PlatformIdentity, PolicyOrigin, RuntimeServices};

use crate::middleware::{FinalizeResponseMiddleware, SanitizeRequestMiddleware};
use crate::platform::{
Expand Down Expand Up @@ -183,7 +183,8 @@ fn build_state_with_registrations_and_services(
let plan = Arc::new(compile_auction_plan_with(&settings, integrations)?);
plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?;
let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan))?;
let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)?;
let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)?
.with_platform(PlatformIdentity::named("axum"));

// Composition root: reject a module selection this adapter can never
// supply, once, before any request is served. The registry is built first
Expand Down
70 changes: 56 additions & 14 deletions crates/trusted-server-adapter-axum/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use edgezero_core::error::EdgeError;
use edgezero_core::http::{HeaderValue, Response};
use edgezero_core::middleware::{Middleware, Next};
use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE;
use trusted_server_core::http_util::sanitize_trusted_client_ip_headers;
use trusted_server_core::http_util::{
ROUTE_NAMESPACE, sanitize_trusted_client_ip_headers, strip_deployment_headers,
};
use trusted_server_core::settings::Settings;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -52,9 +54,8 @@ impl Middleware for SanitizeRequestMiddleware {
/// Response-finalization middleware: injects all standard TS response headers.
///
/// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false`
/// is always emitted. `X-TS-Version` carries the compiled-in git version. The
/// Fastly-specific headers (`X-TS-Fastly-Version`, `X-TS-ENV`) are skipped
/// because the corresponding env vars are not set in a local dev context.
/// is always emitted. The Fastly adapter's `X-TS-ENV` is skipped because its
/// env var is not set in a local dev context.
///
/// Registered directly inside [`SanitizeRequestMiddleware`], so that every
/// outgoing response carries a consistent set of headers.
Expand All @@ -73,7 +74,10 @@ impl FinalizeResponseMiddleware {
#[async_trait(?Send)]
impl Middleware for FinalizeResponseMiddleware {
async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result<Response, EdgeError> {
let path = ctx.request().uri().path().to_owned();
let mut response = next.run(ctx).await?;
// Before finalizing, so a header finalizing writes is kept.
strip_deployment_headers(&path, ROUTE_NAMESPACE, &mut response);
apply_finalize_headers(&self.settings, &mut response);
Ok(response)
}
Expand All @@ -86,8 +90,7 @@ impl Middleware for FinalizeResponseMiddleware {
/// Applies standard Trusted Server response headers to the given response.
///
/// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false`
/// is unconditionally emitted, followed by the compiled-in `X-TS-Version`.
/// Fastly-specific headers are omitted.
/// is unconditionally emitted. Fastly-specific headers are omitted.
/// Operator-configured `settings.response_headers` are applied last (with the
/// shared cookie cache-privacy hardening) and can override any managed header.
pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) {
Expand All @@ -96,8 +99,6 @@ pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Respons
HeaderValue::from_static("false"),
);

trusted_server_core::version_header::apply_git_version_header(response);

// Cookie-bearing responses stay private to shared caches and operator
// headers cannot re-enable caching for uncacheable per-user payloads.
trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy(
Expand Down Expand Up @@ -257,17 +258,58 @@ mod tests {
);
}

fn ctx_for(path: &str) -> RequestContext {
let req = request_builder()
.method(Method::GET)
.uri(path)
.body(Body::empty())
.expect("should build test request");
RequestContext::new(req, PathParams::new(HashMap::new()))
}

/// A response as an origin behind a cache sends it, naming the node that
/// answered and how the cache treated the request.
fn response_naming_its_node() -> Response {
response_builder()
.header("x-served-by", "cache-node-1")
.header("x-cache", "HIT")
.header("x-cache-hits", "3")
.body(Body::empty())
.expect("should build test response")
}

/// What the finalize middleware makes of that response for a request to
/// `path`.
fn finalized(path: &str, settings: Settings) -> Response {
let middleware = FinalizeResponseMiddleware::new(Arc::new(settings));
let handler = Arc::new(|_ctx: RequestContext| async move {
Ok::<Response, EdgeError>(response_naming_its_node())
});

block_on(middleware.handle(ctx_for(path), Next::new(&[], &*handler)))
.expect("should run middleware")
}

#[test]
fn emits_git_version_header() {
let mut response = empty_response();
apply_finalize_headers(&settings_with_response_headers(vec![]), &mut response);
fn finalize_middleware_strips_deployment_headers_before_it_writes_the_operators() {
let response = finalized(
"/articles/an-article",
settings_with_response_headers(vec![("X-Cache", "configured")]),
);

for name in ["x-served-by", "x-cache-hits"] {
assert!(
!response.headers().contains_key(name),
"should strip {name} from a publisher's page"
);
}
assert_eq!(
response
.headers()
.get("x-ts-version")
.get("x-cache")
.and_then(|v| v.to_str().ok()),
trusted_server_core::constants::TS_GIT_VERSION,
"should report the compiled-in git version as x-ts-version"
Some("configured"),
"should carry the operator's header and not the origin's"
);
}
}
12 changes: 4 additions & 8 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() {
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn health_reports_git_version() {
async fn health_carries_no_version() {
let mut service = make_service();
let request = Request::builder()
.method("GET")
Expand All @@ -827,12 +827,8 @@ async fn health_reports_git_version() {
200,
"should return 200 on /health"
);
assert_eq!(
response
.headers()
.get("x-ts-version")
.and_then(|v| v.to_str().ok()),
trusted_server_core::constants::TS_GIT_VERSION,
"should report the compiled-in git version on /health"
assert!(
response.headers().get("x-ts-version").is_none(),
"should write no version on /health, which the service_version middleware writes on a page"
);
}
5 changes: 3 additions & 2 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ use trusted_server_core::inspect::permissions::{PERMISSIONS_PATHS, handle_permis
use trusted_server_core::integrations::{
IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput,
};
use trusted_server_core::platform::RuntimeServices;
#[cfg(target_arch = "wasm32")]
use trusted_server_core::platform::StoreName;
use trusted_server_core::platform::{PlatformIdentity, RuntimeServices};
use trusted_server_core::proxy::{
handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild,
handle_first_party_proxy_sign,
Expand Down Expand Up @@ -231,7 +231,8 @@ fn build_state_with_registrations_and_services(
let plan = Arc::new(compile_auction_plan_with(&settings, integrations)?);
plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?;
let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan))?;
let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)?;
let registry = IntegrationRegistry::with_plan_and_registrations(&settings, plan, integrations)?
.with_platform(PlatformIdentity::named("cloudflare"));

// Composition root: resolve the module selection once, before any request
// is served, so a selection this adapter can never supply fails here rather
Expand Down
64 changes: 52 additions & 12 deletions crates/trusted-server-adapter-cloudflare/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use edgezero_core::error::EdgeError;
use edgezero_core::http::{HeaderValue, Response};
use edgezero_core::middleware::{Middleware, Next};
use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE;
use trusted_server_core::http_util::sanitize_trusted_client_ip_headers;
use trusted_server_core::http_util::{
ROUTE_NAMESPACE, sanitize_trusted_client_ip_headers, strip_deployment_headers,
};
use trusted_server_core::settings::Settings;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -80,7 +82,10 @@ impl Middleware for FinalizeResponseMiddleware {
.filter(|s| !s.is_empty() && *s != "XX")
.is_some();

let path = ctx.request().uri().path().to_owned();
let mut response = next.run(ctx).await?;
// Before finalizing, so a header finalizing writes is kept.
strip_deployment_headers(&path, ROUTE_NAMESPACE, &mut response);
apply_finalize_headers(&self.settings, geo_available, &mut response);
Ok(response)
}
Expand All @@ -106,8 +111,6 @@ pub(crate) fn apply_finalize_headers(
HeaderValue::from_static(if geo_available { "true" } else { "false" }),
);

trusted_server_core::version_header::apply_git_version_header(response);

// Cloudflare is a real shared cache: cookie-bearing responses must stay
// private and operator headers must not re-enable caching for uncacheable
// per-user payloads.
Expand Down Expand Up @@ -287,21 +290,58 @@ mod tests {
);
}

fn ctx_for(path: &str) -> RequestContext {
let req = request_builder()
.method(Method::GET)
.uri(path)
.body(Body::empty())
.expect("should build test request");
RequestContext::new(req, PathParams::new(HashMap::new()))
}

/// A response as an origin behind a cache sends it, naming the node that
/// answered and how the cache treated the request.
fn response_naming_its_node() -> Response {
response_builder()
.header("x-served-by", "cache-node-1")
.header("x-cache", "HIT")
.header("x-cache-hits", "3")
.body(Body::empty())
.expect("should build test response")
}

/// What the finalize middleware makes of that response for a request to
/// `path`.
fn finalized(path: &str, settings: Settings) -> Response {
let middleware = FinalizeResponseMiddleware::new(Arc::new(settings));
let handler = Arc::new(|_ctx: RequestContext| async move {
Ok::<Response, EdgeError>(response_naming_its_node())
});

block_on(middleware.handle(ctx_for(path), Next::new(&[], &*handler)))
.expect("should run middleware")
}

#[test]
fn emits_git_version_header() {
let mut response = empty_response();
apply_finalize_headers(
&settings_with_response_headers(vec![]),
false,
&mut response,
fn finalize_middleware_strips_deployment_headers_before_it_writes_the_operators() {
let response = finalized(
"/articles/an-article",
settings_with_response_headers(vec![("X-Cache", "configured")]),
);

for name in ["x-served-by", "x-cache-hits"] {
assert!(
!response.headers().contains_key(name),
"should strip {name} from a publisher's page"
);
}
assert_eq!(
response
.headers()
.get("x-ts-version")
.get("x-cache")
.and_then(|v| v.to_str().ok()),
trusted_server_core::constants::TS_GIT_VERSION,
"should report the compiled-in git version as x-ts-version"
Some("configured"),
"should carry the operator's header and not the origin's"
);
}
}
Loading
Loading