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

- Publisher HTML uses the browser-only `Cache-Control: private, max-age=60` policy for successful GET document responses and their `304 Not Modified` revalidations when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. The `private` directive prevents shared caches that use `Cache-Control` from storing the document. Cookie-bearing responses using the generated inactive policy are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged and continue to control supporting CDNs independently. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers; an absent configuration, an unmatched slot, or a disabled auction also make the stack structurally inactive. An explicit `enabled = false` is not compatible with older binaries: restore the default, re-push and finalize the config before rolling back.
- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape.
- **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters.
- **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries.
Expand Down
50 changes: 30 additions & 20 deletions crates/trusted-server-adapter-fastly/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ mod tests {
use error_stack::Report;
use futures::executor::block_on;
use trusted_server_core::platform::{PlatformError, PlatformGeo};
use trusted_server_core::response_privacy::apply_inactive_ad_stack_browser_cache_policy;

fn empty_response() -> Response {
response_builder()
Expand Down Expand Up @@ -429,29 +430,38 @@ mod tests {
}

#[test]
fn enforce_set_cookie_cache_privacy_downgrades_late_cookie() {
fn enforce_set_cookie_cache_privacy_downgrades_late_cookie_policies() {
// Mirrors the EdgeZero post-ec_finalize guard: a Set-Cookie added after
// finalize headers ran (origin-public response) must be downgraded.
let mut response = response_with_headers(&[
("set-cookie", "ts-ec=abc; Path=/"),
("cache-control", "public, max-age=600"),
("surrogate-control", "max-age=600"),
]);
// finalize headers ran must override both origin-public and inactive
// template cache policies.
for (cache_control, generated_inactive_policy) in [
("public, max-age=600", false),
("private, max-age=60", true),
] {
let mut response = response_with_headers(&[
("set-cookie", "ts-ec=abc; Path=/"),
("cache-control", cache_control),
("surrogate-control", "max-age=600"),
]);
if generated_inactive_policy {
apply_inactive_ad_stack_browser_cache_policy(&mut response);
}

enforce_set_cookie_cache_privacy(&mut response);
enforce_set_cookie_cache_privacy(&mut response);

assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("private, max-age=0"),
"should downgrade a late public cookie response to private"
);
assert!(
response.headers().get("surrogate-control").is_none(),
"should strip surrogate-control from the late cookie response"
);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("private, max-age=0"),
"should downgrade {cache_control} on a cookie response"
);
assert!(
response.headers().get("surrogate-control").is_none(),
"should strip surrogate-control from a {cache_control} cookie response"
);
}
}

#[test]
Expand Down
15 changes: 12 additions & 3 deletions crates/trusted-server-cli/tests/config_env_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ ids = ["trusted_server_secrets"]
const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES";
const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES";
const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED";
const AD_TEMPLATES_ENABLED_ENV: &str = "TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__ENABLED";

struct MigratedProject {
directory: TempDir,
Expand All @@ -45,10 +46,12 @@ fn migrated_legacy_project() -> MigratedProject {
.parse::<DocumentMut>()
.expect("should parse legacy integration config");
// EdgeZero v0.0.4 environment overlays cannot create missing TOML leaves,
// so a migrated config must carry both creative-processing leaves for the
// corresponding environment variables to take effect.
// so a migrated config must carry every leaf whose environment override is
// expected to take effect.
document["auction"]["rewrite_creatives"] = value(true);
document["auction"]["sanitize_creatives"] = value(false);
document["creative_opportunities"]["enabled"] = value(true);
document["creative_opportunities"]["gam_network_id"] = value("123456789");
fs::write(&config_path, document.to_string()).expect("should write migrated config");
fs::write(&manifest_path, MANIFEST).expect("should write test manifest");
MigratedProject {
Expand Down Expand Up @@ -114,7 +117,7 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() {
}

#[test]
fn migrated_legacy_config_applies_gam_attribution_environment_override() {
fn migrated_legacy_config_applies_boolean_environment_overrides() {
let project = migrated_legacy_project();
let output = Command::new(env!("CARGO_BIN_EXE_ts"))
.args(["config", "push", "--adapter", "axum", "--manifest"])
Expand All @@ -124,6 +127,7 @@ fn migrated_legacy_config_applies_gam_attribution_environment_override() {
.args(["--yes", "--no-diff"])
.current_dir(project.directory.path())
.env(GAM_ATTRIBUTION_ENV, "true")
.env(AD_TEMPLATES_ENABLED_ENV, "false")
.output()
.expect("should run ts config push");

Expand Down Expand Up @@ -154,6 +158,11 @@ fn migrated_legacy_config_applies_gam_attribution_environment_override() {
serde_json::Value::Bool(true),
"pushed config should contain the GAM attribution environment override"
);
assert_eq!(
envelope["data"]["creative_opportunities"]["enabled"],
serde_json::Value::Bool(false),
"pushed config should contain the creative opportunities environment override"
);
}

#[test]
Expand Down
125 changes: 122 additions & 3 deletions crates/trusted-server-core/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,10 +587,11 @@ mod tests {
use crate::consent::types::ConsentContext;
use crate::openrtb::Uid;
use crate::platform::test_support::{
NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, noop_services,
NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient,
noop_services,
};
use crate::platform::{ClientInfo, PlatformResponse};
use crate::test_support::tests::create_test_settings;
use crate::platform::{ClientInfo, PlatformHttpClient, PlatformHttpRequest, PlatformResponse};
use crate::test_support::tests::{crate_test_settings_str, create_test_settings};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde_json::json;
Expand Down Expand Up @@ -675,6 +676,124 @@ mod tests {
}
}

/// Provider used to prove that direct `/auction` remains available when
/// publisher server-side ad templates are disabled.
struct TemplateSwitchProbeProvider {
calls: Arc<Mutex<usize>>,
}

#[async_trait::async_trait(?Send)]
impl AuctionProvider for TemplateSwitchProbeProvider {
fn provider_name(&self) -> &'static str {
"template_switch_probe"
}

async fn request_bids(
&self,
_request: &AuctionRequest,
context: &AuctionContext<'_>,
) -> Result<ProviderRequestOutcome, Report<TrustedServerError>> {
*self.calls.lock().expect("should lock provider call count") += 1;
let request = Request::builder()
.method("POST")
.uri("https://bidder.example/auction")
.body(EdgeBody::empty())
.expect("should build probe provider request");
context
.services
.http_client()
.send_async(PlatformHttpRequest::new(
request,
"template-switch-probe-backend",
))
.await
.change_context(TrustedServerError::Auction {
message: "probe provider launch failed".to_string(),
})
.map(ProviderRequestOutcome::pending)
}

async fn parse_response(
&self,
_response: PlatformResponse,
_response_time_ms: u64,
) -> Result<AuctionResponse, Report<TrustedServerError>> {
Ok(AuctionResponse::success(
self.provider_name(),
Vec::new(),
0,
))
}

fn timeout_ms(&self) -> u32 {
100
}

fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option<String> {
Some("template-switch-probe-backend".to_string())
}
}

#[tokio::test]
async fn direct_auction_remains_available_when_templates_are_disabled() {
Comment thread
ChristianPavilonis marked this conversation as resolved.
Comment thread
ChristianPavilonis marked this conversation as resolved.
let settings_toml = format!(
"{}\n[auction]\nenabled = true\nproviders = [\"template_switch_probe\"]\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n",
crate_test_settings_str()
);
let settings = Settings::from_toml(&settings_toml)
.expect("should parse settings with disabled templates");
let calls = Arc::new(Mutex::new(0));
let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone());
orchestrator.register_provider(Arc::new(TemplateSwitchProbeProvider {
calls: Arc::clone(&calls),
}));

let stub = Arc::new(StubHttpClient::new());
stub.push_response(200, b"probe response".to_vec());
let services = RuntimeServices::builder()
.config_store(Arc::new(NoopConfigStore))
.secret_store(Arc::new(NoopSecretStore))
.kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore))
.backend(Arc::new(NoopBackend))
.http_client(Arc::clone(&stub) as Arc<dyn PlatformHttpClient>)
.geo(Arc::new(NoopGeo))
.client_info(ClientInfo::default())
.build();
let ec_context = make_ec_context(Jurisdiction::NonRegulated, None);
let body = json!({
"adUnits": [{
"code": "div-gpt-ad-1",
"mediaTypes": { "banner": { "sizes": [[300, 250]] } }
}]
});
let req = Request::builder()
.method("POST")
.uri("https://test-publisher.com/auction")
.body(EdgeBody::from(
serde_json::to_vec(&body).expect("should serialize body"),
))
.expect("should build auction request");

let response = handle_auction(
&settings,
&orchestrator,
None,
None,
&ec_context,
&services,
req,
)
.await
.expect("direct auction should remain available");

assert_eq!(
*calls.lock().expect("should lock provider call count"),
1,
"disabling publisher templates must not disable direct /auction"
);
assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() {
// GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run
Expand Down
27 changes: 27 additions & 0 deletions crates/trusted-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,37 @@ formats = [{ width = 300, height = 250 }]
fn absent_gam_unit_template_is_accepted_by_legacy_schema() {
let creative_opportunities = serialized_creative_opportunities(None);

assert!(
creative_opportunities.get("enabled").is_none(),
"default template switch should be omitted for legacy binaries"
);
serde_json::from_value::<LegacyCreativeOpportunitiesConfig>(creative_opportunities)
.expect("should accept absent GAM unit template");
}

#[test]
fn disabled_creative_opportunities_flag_is_rejected_by_legacy_schema() {
let mut toml = crate_test_settings_str();
toml.push_str(
r#"

[creative_opportunities]
enabled = false
gam_network_id = "99999"
"#,
);
let app_config: TrustedServerAppConfig =
toml::from_str(&toml).expect("should deserialize app config wrapper");
let creative_opportunities = serde_json::to_value(app_config)
.expect("should serialize app config wrapper")
.get("creative_opportunities")
.cloned()
.expect("should contain creative opportunities");

serde_json::from_value::<LegacyCreativeOpportunitiesConfig>(creative_opportunities)
.expect_err("legacy binaries should reject an explicit disabled switch");
}

#[test]
fn deploy_validation_rejects_placeholders() {
let settings = Settings::from_toml(
Expand Down
47 changes: 46 additions & 1 deletion crates/trusted-server-core/src/creative_opportunities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,27 @@ fn derive_section(path: &str, section_root: &str, section_segment: usize) -> Str
}
}

const fn default_enabled() -> bool {
true
}

const fn is_default_enabled(value: &bool) -> bool {
*value == default_enabled()
}

/// Top-level configuration for the creative opportunities system.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CreativeOpportunitiesConfig {
/// Enables server-side ad template delivery on publisher HTML and page-bids requests.
///
/// This does not disable the direct `POST /auction` endpoint. The default is
/// `true` so existing creative-opportunity configurations retain their behavior.
#[serde(
default = "default_enabled",
skip_serializing_if = "is_default_enabled"
Comment thread
ChristianPavilonis marked this conversation as resolved.
)]
pub enabled: bool,
Comment thread
ChristianPavilonis marked this conversation as resolved.
Comment thread
ChristianPavilonis marked this conversation as resolved.
Comment thread
ChristianPavilonis marked this conversation as resolved.
Comment thread
ChristianPavilonis marked this conversation as resolved.
/// GAM network ID used to build default unit paths.
pub gam_network_id: String,
/// Maximum time in milliseconds to wait for the server-side auction before
Expand Down Expand Up @@ -244,7 +261,7 @@ pub struct CreativeOpportunitiesConfig {
/// [`section_root`](Self::section_root) are omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section_segment: Option<usize>,
/// Slot templates. Empty vec = feature disabled (no auction fired, no globals injected).
/// Slot templates. An empty vec or `enabled = false` disables template delivery.
#[serde(default, deserialize_with = "vec_from_seq_or_map")]
pub slot: Vec<CreativeOpportunitySlot>,
}
Expand Down Expand Up @@ -1143,12 +1160,39 @@ mod tests {
assert_eq!(derive_section("/%%%/x", "home", 0), "_");
}

#[test]
fn enabled_defaults_true_and_is_omitted_from_serialized_config() {
let config = make_config_with_section_template(None);
assert!(
config.enabled,
"template delivery should default to enabled"
);
let value = serde_json::to_value(&config).expect("should serialize config");
assert!(
value.get("enabled").is_none(),
"default enabled value should be omitted for rollback compatibility"
);
}

#[test]
fn disabled_template_switch_is_serialized() {
let mut config = make_config_with_section_template(None);
config.enabled = false;
let value = serde_json::to_value(&config).expect("should serialize config");
assert_eq!(
value.get("enabled"),
Some(&serde_json::Value::Bool(false)),
"explicitly disabled template delivery must remain in config blobs"
);
}

fn make_config_with_section_template(
section_root: Option<&str>,
) -> CreativeOpportunitiesConfig {
let mut slot = make_slot("ad-header-0", vec!["/news/*"]);
slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string());
CreativeOpportunitiesConfig {
enabled: true,
gam_network_id: "99999".to_string(),
auction_timeout_ms: None,
price_granularity: PriceGranularity::default(),
Expand Down Expand Up @@ -1546,6 +1590,7 @@ mod tests {
// Older binaries deserialize this struct with `deny_unknown_fields`, so
// a pushed config blob must not carry `"section_root": null`.
let config = CreativeOpportunitiesConfig {
enabled: true,
gam_network_id: "99999".to_string(),
auction_timeout_ms: None,
price_granularity: PriceGranularity::default(),
Expand Down
Loading