Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **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:** `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 Down
24 changes: 21 additions & 3 deletions crates/trusted-server-adapter-axum/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ 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. Fastly-specific headers (`X-TS-Version`, `X-TS-ENV`) are
/// skipped because the corresponding env vars are not set in a local dev context.
/// 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.
///
/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of
/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected
Expand Down Expand Up @@ -125,7 +126,8 @@ impl Middleware for AuthMiddleware {
/// 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. Fastly-specific headers are omitted.
/// is unconditionally emitted, followed by the compiled-in `X-TS-Version`.
/// 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 @@ -134,6 +136,8 @@ 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 @@ -287,4 +291,18 @@ mod tests {
"should remove both configured trust headers before the handler"
);
}

#[test]
fn emits_git_version_header() {
let mut response = empty_response();
apply_finalize_headers(&settings_with_response_headers(vec![]), &mut response);
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 as x-ts-version"
);
}
}
32 changes: 32 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -938,3 +938,35 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() {
"should not leak generated placeholders: {html}"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn health_reports_git_version() {
let mut service = make_service();
let request = Request::builder()
.method("GET")
.uri("/health")
.body(AxumBody::empty())
.expect("should build health request");

let response = service
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should serve health");

assert_eq!(
response.status().as_u16(),
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"
);
}
20 changes: 20 additions & 0 deletions crates/trusted-server-adapter-cloudflare/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ 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 @@ -320,4 +322,22 @@ mod tests {
"should remove both configured trust headers before the handler"
);
}

#[test]
fn emits_git_version_header() {
let mut response = empty_response();
apply_finalize_headers(
&settings_with_response_headers(vec![]),
false,
&mut response,
);
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 as x-ts-version"
);
}
}
29 changes: 28 additions & 1 deletion crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use fastly::http::Method as FastlyMethod;
use fastly::{Request as FastlyRequest, Response as FastlyResponse};

use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive};
use trusted_server_core::constants::HEADER_X_TS_VERSION;
use trusted_server_core::ec::device::DeviceSignals;
use trusted_server_core::ec::finalize::ec_finalize_response;
use trusted_server_core::ec::kv::KvIdentityGraph;
Expand All @@ -27,6 +28,7 @@ use trusted_server_core::platform::PlatformGeo as _;
use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body};
use trusted_server_core::response_privacy::TerminalPrivateResponse;
use trusted_server_core::settings::Settings;
use trusted_server_core::version_header::{apply_git_version_header, git_version_header_value};

mod app;
mod backend;
Expand Down Expand Up @@ -70,7 +72,12 @@ fn open_trusted_server_config_store(store_name: &str) -> Result<ConfigStoreHandl

fn health_response(req: &FastlyRequest) -> Option<FastlyResponse> {
if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" {
return Some(FastlyResponse::from_status(200).with_body_text_plain("ok"));
let mut response = FastlyResponse::from_status(200).with_body_text_plain("ok");
// Compiled-in constant: keeps the probe free of settings and app construction.
if let Some(version) = git_version_header_value() {
response.set_header(HEADER_X_TS_VERSION, version);
Comment thread
dhruv8sh marked this conversation as resolved.
}
return Some(response);
}

None
Expand Down Expand Up @@ -489,6 +496,9 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re
}
Err(e) => {
log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}");
// The compiled-in version needs no settings, so a deploy that cannot
// load its config still reports which build is serving.
apply_git_version_header(&mut response);
}
}
}
Expand Down Expand Up @@ -905,6 +915,23 @@ mod tests {
);
}

#[test]
fn health_response_reports_git_version_only() {
let req = FastlyRequest::get("https://example.com/health");

let response = health_response(&req).expect("should build health response");

assert_eq!(
response.get_header_str("x-ts-version"),
trusted_server_core::constants::TS_GIT_VERSION,
"should report the compiled-in git version on /health"
);
assert!(
response.get_header("x-ts-fastly-version").is_none(),
"should keep x-ts-fastly-version off the /health fast path"
);
}

#[test]
fn health_response_ignores_non_health_paths() {
let req = FastlyRequest::get("https://example.com/auction");
Expand Down
58 changes: 50 additions & 8 deletions crates/trusted-server-adapter-fastly/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ use std::net::IpAddr;
use trusted_server_core::auth::enforce_basic_auth;
use trusted_server_core::constants::{
ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE,
HEADER_X_TS_ENV, HEADER_X_TS_VERSION,
HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION,
};
use trusted_server_core::geo::GeoInfo;
use trusted_server_core::platform::{ClientInfo, PlatformGeo};
use trusted_server_core::settings::Settings;
use trusted_server_core::version_header::apply_git_version_header;

pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized";

Expand All @@ -49,7 +50,9 @@ pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized";
///
/// Headers are written in this order (last write wins):
/// 1. Geo headers (or `X-Geo-Info-Available: false` when geo is unavailable)
/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var
/// 2. `X-TS-Version` from the compiled-in git version
/// (`TRUSTED_SERVER__GIT_VERSION`), and `X-TS-Fastly-Version` from the
/// `FASTLY_SERVICE_VERSION` env var
/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"`
/// 4. Operator-configured `settings.response_headers` (can override any managed header)
pub struct FinalizeResponseMiddleware {
Expand Down Expand Up @@ -187,7 +190,9 @@ where
///
/// Header write order (last write wins):
/// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent
/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var
/// 2. `X-TS-Version` from the compiled-in git version
/// (`TRUSTED_SERVER__GIT_VERSION`), and `X-TS-Fastly-Version` from the
/// `FASTLY_SERVICE_VERSION` env var
/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"`
/// 4. Set-Cookie cache privacy — strip surrogate cache headers and downgrade
/// `Cache-Control` to `private, max-age=0` on cookie-bearing responses
Expand All @@ -208,11 +213,24 @@ pub(crate) fn apply_finalize_headers(
);
}

if let Ok(v) = std::env::var(ENV_FASTLY_SERVICE_VERSION) {
if let Ok(value) = HeaderValue::from_str(&v) {
response.headers_mut().insert(HEADER_X_TS_VERSION, value);
} else {
log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value");
apply_git_version_header(response);

// Remove an inherited origin value when the version is unknown so it is
// never reported as ours.
match std::env::var(ENV_FASTLY_SERVICE_VERSION) {
Ok(v) => match HeaderValue::from_str(&v) {
Ok(value) => {
response
.headers_mut()
.insert(HEADER_X_TS_FASTLY_VERSION, value);
}
Err(_) => {
log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value");
response.headers_mut().remove(HEADER_X_TS_FASTLY_VERSION);
}
},
Err(_) => {
response.headers_mut().remove(HEADER_X_TS_FASTLY_VERSION);
}
}

Expand Down Expand Up @@ -813,4 +831,28 @@ mod tests {
"should reach the handler when auth is not required"
);
}

#[test]
fn version_headers_split_git_and_fastly_versions() {
let settings = settings_with_response_headers(vec![]);
let mut response = empty_response();
response.headers_mut().insert(
HEADER_X_TS_FASTLY_VERSION,
HeaderValue::from_static("origin-value"),
);

apply_finalize_headers(&settings, None, &mut response);

let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok());
assert_eq!(
header("x-ts-version"),
trusted_server_core::constants::TS_GIT_VERSION,
"should report the compiled-in git version as x-ts-version"
);
assert_eq!(
header("x-ts-fastly-version"),
std::env::var(ENV_FASTLY_SERVICE_VERSION).ok().as_deref(),
"should report FASTLY_SERVICE_VERSION as x-ts-fastly-version"
);
}
}
7 changes: 7 additions & 0 deletions crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,13 @@ mod tests {
200,
"GET /health must return 200 from the startup fallback"
);
assert_eq!(
resp.headers()
.get("x-ts-version")
.and_then(|v| v.to_str().ok()),
trusted_server_core::constants::TS_GIT_VERSION,
"startup-fallback /health should report the compiled-in git version"
);
let body = resp.into_body().into_bytes().unwrap_or_default();
assert_eq!(
&body[..],
Expand Down
20 changes: 20 additions & 0 deletions crates/trusted-server-adapter-spin/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ 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);

// 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 @@ -347,4 +349,22 @@ mod tests {
"should remove both configured trust headers before the handler"
);
}

#[test]
fn emits_git_version_header() {
let mut response = empty_response();
apply_finalize_headers(
&settings_with_response_headers(vec![]),
false,
&mut response,
);
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 as x-ts-version"
);
}
}
Loading
Loading