Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 `Cache-Control: max-age=60` for successful GET document responses when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. Cookie-bearing responses are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged for inactive templates. 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
10 changes: 5 additions & 5 deletions crates/trusted-server-adapter-fastly/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,12 +429,12 @@ mod tests {
}

#[test]
fn enforce_set_cookie_cache_privacy_downgrades_late_cookie() {
fn enforce_set_cookie_cache_privacy_downgrades_inactive_cache_policy() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — this weakens an existing test that the PR does not otherwise touch.

enforce_set_cookie_cache_privacy is unchanged by this PR, but the fixture moved from public, max-age=600 to max-age=60, which drops the explicit "origin sent a public policy" scenario the test was written to cover. Both values exercise the same branch, so nothing is caught today — but the named scenario is gone.

Fix: keep the original case and add the new one, e.g. loop over ["public, max-age=600", "max-age=60"] so both the origin-public and inactive-template policies are proven to downgrade.

// Mirrors the EdgeZero post-ec_finalize guard: a Set-Cookie added after
// finalize headers ran (origin-public response) must be downgraded.
// finalize headers ran must override the inactive template cache policy.
let mut response = response_with_headers(&[
("set-cookie", "ts-ec=abc; Path=/"),
("cache-control", "public, max-age=600"),
("cache-control", "max-age=60"),
("surrogate-control", "max-age=600"),
]);

Expand All @@ -446,11 +446,11 @@ mod tests {
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("private, max-age=0"),
"should downgrade a late public cookie response to private"
"should downgrade an inactive cache policy on a cookie response"
);
assert!(
response.headers().get("surrogate-control").is_none(),
"should strip surrogate-control from the late cookie response"
"should strip surrogate-control from the inactive cookie response"
);
}

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

struct MigratedProject {
directory: TempDir,
Expand All @@ -44,10 +45,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 @@ -112,6 +115,49 @@ fn migrated_legacy_config_applies_rewrite_creatives_environment_override() {
);
}

#[test]
fn migrated_legacy_config_applies_creative_opportunities_enabled_environment_override() {
let project = migrated_legacy_project();
let output = Command::new(env!("CARGO_BIN_EXE_ts"))
.args(["config", "push", "--adapter", "axum", "--manifest"])
.arg(&project.manifest_path)
.arg("--app-config")
.arg(&project.config_path)
.args(["--yes", "--no-diff"])
.current_dir(project.directory.path())
.env(AD_TEMPLATES_ENABLED_ENV, "false")
.output()
.expect("should run ts config push");

assert!(
output.status.success(),
"valid boolean overlay should push successfully: {}",
String::from_utf8_lossy(&output.stderr)
);

let local_store_path = project
.directory
.path()
.join(".edgezero/local-config-trusted_server_config.json");
let local_store: serde_json::Value = serde_json::from_str(
&fs::read_to_string(local_store_path).expect("should read pushed local config"),
)
.expect("should parse local config store");
let envelope_json = local_store
.as_object()
.and_then(|entries| entries.values().next())
.and_then(serde_json::Value::as_str)
.expect("should contain a blob envelope");
let envelope: serde_json::Value =
serde_json::from_str(envelope_json).expect("should parse blob envelope");

assert_eq!(
envelope["data"]["creative_opportunities"]["enabled"],
serde_json::Value::Bool(false),
"pushed config should contain the environment override"
);
}

#[test]
fn migrated_legacy_config_applies_sanitize_creatives_environment_override() {
let project = migrated_legacy_project();
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praise — this test proves the actual premise of the PR.

The whole justification for a dedicated switch instead of reusing [auction].enabled is that POST /auction must keep working. Standing up a probe provider and asserting calls == 1 verifies that end to end rather than asserting the absence of a code path, which is what makes the separation credible.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — the rollback failure mode is guarded only by documentation.

Because enabled = false is serialized into the pushed config blob and CreativeOpportunitiesConfig carries deny_unknown_fields, a binary released before this field rejects the blob, Settings fails to load, and every request fails. That is a total-outage rollback path whose only guard is the > [!WARNING] block in docs/guide/configuration.md and the CHANGELOG.md note.

👍 for disabled_creative_opportunities_flag_is_rejected_by_legacy_schema in config.rs — characterizing the incompatibility in a test rather than only in prose is the right instinct.

Worth considering a mechanical guard to go with the prose: have ts config push warn when it is about to write a non-default creative_opportunities.enabled, so an operator sees the rollback constraint at the moment they create it rather than only if they read the guide.

/// 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
Loading