diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 9ce741f3f..723c22960 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -14,6 +14,105 @@ use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::price_bucket::PriceGranularity; use crate::settings::vec_from_seq_or_map; +/// A single parsed segment of a [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) template. +#[derive(Debug, Clone)] +pub(crate) enum UnitTemplatePart { + /// Verbatim text between placeholders. + Literal(String), + /// `{network_id}` — replaced with the GAM network id. + NetworkId, + /// `{section}` — replaced with the request-derived section. + Section, + /// `{slot_id}` — replaced with the slot id. + SlotId, +} + +/// Parses a `gam_unit_path` template into an ordered list of parts. +/// +/// Supported placeholders: `{network_id}`, `{section}`, `{slot_id}`. A template +/// with no placeholders is a single [`UnitTemplatePart::Literal`] and renders +/// verbatim. +/// +/// # Errors +/// +/// Returns an error string for an empty template, an unmatched or nested `{`, +/// a stray `}`, or an unknown placeholder name. +fn parse_unit_template(raw: &str) -> Result, String> { + if raw.is_empty() { + return Err("gam_unit_path template must not be empty".to_string()); + } + let mut parts = Vec::new(); + let mut literal = String::new(); + let mut chars = raw.chars(); + while let Some(c) = chars.next() { + match c { + '{' => { + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(std::mem::take(&mut literal))); + } + let mut name = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some('{') => return Err(format!("nested '{{' in template `{raw}`")), + Some(ch) => name.push(ch), + None => return Err(format!("unmatched '{{' in template `{raw}`")), + } + } + match name.as_str() { + "network_id" => parts.push(UnitTemplatePart::NetworkId), + "section" => parts.push(UnitTemplatePart::Section), + "slot_id" => parts.push(UnitTemplatePart::SlotId), + other => { + return Err(format!( + "unknown placeholder `{{{other}}}` in template `{raw}`" + )); + } + } + } + '}' => return Err(format!("stray '}}' in template `{raw}`")), + other => literal.push(other), + } + } + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(literal)); + } + Ok(parts) +} + +/// Collapses each run of characters outside `[A-Za-z0-9_-]` to a single `_`. +/// +/// Returns a non-empty string for any non-empty input. +fn sanitize_section(segment: &str) -> String { + let mut out = String::with_capacity(segment.len()); + let mut in_bad_run = false; + for ch in segment.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + out.push(ch); + in_bad_run = false; + } else if !in_bad_run { + out.push('_'); + in_bad_run = true; + } + } + out +} + +/// Derives the `{section}` value from a request path. +/// +/// Uses the first non-empty path segment, sanitized to `[A-Za-z0-9_-]`. Falls +/// back to `section_root` when the path has no segment (`/`, repeated slashes). +/// +/// The path is used **raw** (not percent-decoded) so this stays consistent with +/// how [`page_patterns`](CreativeOpportunitySlot::page_patterns) glob-match the +/// same path — e.g. `/new%20s` yields `new_20s`, never the decoded `new_s`. +pub(crate) fn derive_section(path: &str, section_root: &str) -> String { + match path.split('/').find(|segment| !segment.is_empty()) { + Some(segment) => sanitize_section(segment), + None => section_root.to_string(), + } +} + /// Top-level configuration for the creative opportunities system. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -40,6 +139,14 @@ pub struct CreativeOpportunitiesConfig { /// Price granularity for header-bidding price bucketing. Defaults to `Dense`. #[serde(default)] pub price_granularity: PriceGranularity, + /// Value substituted for `{section}` when the request path has no first + /// segment (e.g. `/`). + /// + /// Required when any slot's [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) + /// template contains `{section}`. No default — a home-section name is + /// publisher-specific, so the URL→section convention stays in config, not core. + #[serde(default)] + pub section_root: Option, /// Slot templates. Empty vec = feature disabled (no auction fired, no globals injected). #[serde(default, deserialize_with = "vec_from_seq_or_map")] pub slot: Vec, @@ -53,15 +160,54 @@ impl CreativeOpportunitiesConfig { } } + /// Parse every slot's [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) + /// template. Call once after deserialization, before [`validate_runtime`](Self::validate_runtime). + /// + /// # Errors + /// + /// Returns an error string when any slot's template is malformed. + pub fn compile_unit_templates(&mut self) -> Result<(), String> { + for slot in &mut self.slot { + slot.compile_unit_template()?; + } + Ok(()) + } + /// Validate all slot definitions after runtime preparation. /// + /// Call [`compile_unit_templates`](Self::compile_unit_templates) first: the + /// `{section}` → [`section_root`](Self::section_root) requirement is keyed off + /// each slot's compiled template, so an uncompiled config silently skips that + /// check. [`Settings::prepare_runtime`](crate::settings::Settings) enforces + /// this order. + /// /// # Errors /// /// Returns an error string when a slot has an invalid identifier, page - /// pattern set, format list, dimensions, or resolved GAM unit path. + /// pattern set, format list, or dimensions, or when a slot's `gam_unit_path` + /// template uses `{section}` without a valid [`section_root`](Self::section_root). pub fn validate_runtime(&self) -> Result<(), String> { for slot in &self.slot { - slot.validate_runtime(&self.gam_network_id)?; + slot.validate_runtime()?; + } + + if self + .slot + .iter() + .any(CreativeOpportunitySlot::template_uses_section) + { + match self.section_root.as_deref() { + Some(root) + if !root.is_empty() + && root + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => {} + _ => { + return Err("section_root is required and must match [A-Za-z0-9_-]+ \ + when a gam_unit_path template uses {section}" + .to_string()); + } + } } Ok(()) @@ -106,6 +252,14 @@ pub struct CreativeOpportunitySlot { /// crate can construct slots via struct-literal syntax with an empty cache. #[serde(skip, default)] pub(crate) compiled_patterns: Vec, + /// Pre-parsed [`gam_unit_path`](Self::gam_unit_path) template, populated by + /// [`compile_unit_template`](Self::compile_unit_template) at startup. + /// + /// `None` when the slot has no explicit `gam_unit_path` (renders the default + /// `//`). `pub(crate)` so cross-module test helpers can build + /// slots via struct-literal syntax with an empty cache. + #[serde(skip, default)] + pub(crate) compiled_unit: Option>, } impl CreativeOpportunitySlot { @@ -115,7 +269,7 @@ impl CreativeOpportunitySlot { /// /// Returns an error string when required slot fields are empty, invalid, /// or semantically unusable at runtime. - pub fn validate_runtime(&self, gam_network_id: &str) -> Result<(), String> { + pub fn validate_runtime(&self) -> Result<(), String> { validate_slot_id(&self.id)?; if self.page_patterns.is_empty() { @@ -170,13 +324,15 @@ impl CreativeOpportunitySlot { )); } - if self - .resolved_gam_unit_path(gam_network_id) - .trim() - .is_empty() + // A present-but-blank `gam_unit_path` renders to an empty/whitespace + // unit path. An empty string also fails template parsing at startup; + // this keeps the slot-level check self-contained (tests call + // `validate_runtime` without compiling templates first). + if let Some(raw) = &self.gam_unit_path + && raw.trim().is_empty() { return Err(format!( - "slot `{}` resolved GAM unit path must not be empty", + "slot `{}` gam_unit_path must not be empty", self.id )); } @@ -254,15 +410,50 @@ impl CreativeOpportunitySlot { .collect(); } - /// Returns the GAM ad unit path for this slot. + /// Parses [`gam_unit_path`](Self::gam_unit_path) into + /// [`compiled_unit`](Self::compiled_unit). Call once at startup via + /// [`CreativeOpportunitiesConfig::compile_unit_templates`]. + /// + /// # Errors /// - /// Uses the explicit [`gam_unit_path`](Self::gam_unit_path) override when set, - /// otherwise constructs `//`. + /// Returns an error string (prefixed with the slot id) when the template is + /// malformed. See [`parse_unit_template`]. + pub(crate) fn compile_unit_template(&mut self) -> Result<(), String> { + self.compiled_unit = match &self.gam_unit_path { + Some(raw) => { + Some(parse_unit_template(raw).map_err(|e| format!("slot `{}`: {e}", self.id))?) + } + None => None, + }; + Ok(()) + } + + /// Renders the resolved GAM unit path for a given network id and section. + /// + /// Substitutes `{network_id}`, `{section}`, and `{slot_id}` in the parsed + /// template. Falls back to `//` when the slot has no template. + #[must_use] + pub(crate) fn render_gam_unit_path(&self, gam_network_id: &str, section: &str) -> String { + match &self.compiled_unit { + Some(parts) => parts + .iter() + .map(|part| match part { + UnitTemplatePart::Literal(s) => s.as_str(), + UnitTemplatePart::NetworkId => gam_network_id, + UnitTemplatePart::Section => section, + UnitTemplatePart::SlotId => self.id.as_str(), + }) + .collect(), + None => format!("/{}/{}", gam_network_id, self.id), + } + } + + /// Returns `true` if this slot's compiled template contains `{section}`. #[must_use] - pub fn resolved_gam_unit_path(&self, gam_network_id: &str) -> String { - self.gam_unit_path - .clone() - .unwrap_or_else(|| format!("/{}/{}", gam_network_id, self.id)) + pub(crate) fn template_uses_section(&self) -> bool { + self.compiled_unit + .as_ref() + .is_some_and(|parts| parts.iter().any(|p| matches!(p, UnitTemplatePart::Section))) } /// Returns the div element ID for this slot. @@ -455,6 +646,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, } } @@ -530,28 +722,181 @@ mod tests { } #[test] - fn resolved_gam_unit_path_uses_default_when_absent() { + fn resolved_div_id_defaults_to_slot_id() { let slot = make_slot("atf", vec!["/"]); + assert_eq!(slot.resolved_div_id(), "atf"); + } + + #[test] + fn parse_unit_template_accepts_known_placeholders() { + let parts = parse_unit_template("/{network_id}/example/{section}") + .expect("should parse valid template"); + assert_eq!(parts.len(), 4, "should split into literal+ph+literal+ph"); + } + + #[test] + fn parse_unit_template_accepts_static_path() { + let parts = parse_unit_template("/99999/example/homepage") + .expect("should parse a static path as a single literal"); + assert!( + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/99999/example/homepage"), + "should be one literal part" + ); + } + + #[test] + fn parse_unit_template_rejects_unknown_placeholder() { + let err = parse_unit_template("/{network_id}/{oops}") + .expect_err("should reject unknown placeholder"); + assert!( + err.contains("oops"), + "error should name the bad placeholder" + ); + } + + #[test] + fn parse_unit_template_rejects_unmatched_brace() { + parse_unit_template("/{network_id}/{section").expect_err("should reject unmatched '{'"); + parse_unit_template("/a}b").expect_err("should reject stray '}'"); + } + + #[test] + fn parse_unit_template_rejects_nested_brace() { + parse_unit_template("/{net{work}_id}").expect_err("should reject nested '{'"); + } + + #[test] + fn parse_unit_template_rejects_empty() { + parse_unit_template("").expect_err("should reject empty template"); + } + + #[test] + fn derive_section_uses_first_segment() { + assert_eq!(derive_section("/news", "home"), "news"); + assert_eq!(derive_section("/news/article-123", "home"), "news"); + assert_eq!(derive_section("/my-section/x", "home"), "my-section"); + } + + #[test] + fn derive_section_uses_root_when_no_segment() { + assert_eq!(derive_section("/", "homepage"), "homepage"); + assert_eq!(derive_section("///", "homepage"), "homepage"); + } + + #[test] + fn derive_section_sanitizes_unsafe_runs_to_single_underscore() { + // Not decoded: in "new%20s" only '%' is disallowed ('2' and '0' are + // alphanumeric), so it collapses to a single '_' -> "new_20s". This is + // exactly the no-decode contract: had we decoded, %20 would be a space + // and yield "new_s"; we do NOT decode. + assert_eq!(derive_section("/new%20s", "home"), "new_20s"); + // A run of disallowed chars collapses to one '_'. + assert_eq!(derive_section("/a..b", "home"), "a_b"); + } + + #[test] + fn derive_section_is_non_empty_for_all_disallowed_segment() { + assert_eq!(derive_section("/%%%/x", "home"), "_"); + } + + 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 { + gam_network_id: "99999".to_string(), + auction_timeout_ms: None, + price_granularity: PriceGranularity::default(), + section_root: section_root.map(str::to_string), + slot: vec![slot], + } + } + + #[test] + fn render_gam_unit_path_substitutes_placeholders() { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template() + .expect("should compile template"); assert_eq!( - slot.resolved_gam_unit_path("21765378893"), - "/21765378893/atf" + slot.render_gam_unit_path("99999", "news"), + "/99999/example/news" ); } #[test] - fn resolved_gam_unit_path_uses_override_when_set() { + fn render_gam_unit_path_defaults_when_no_template() { + let mut slot = make_slot("sidebar", vec!["/*"]); + slot.gam_unit_path = None; + slot.compile_unit_template() + .expect("should compile (no template)"); + assert_eq!( + slot.render_gam_unit_path("99999", "ignored"), + "/99999/sidebar" + ); + } + + #[test] + fn render_gam_unit_path_uses_static_template_verbatim() { let mut slot = make_slot("atf", vec!["/"]); - slot.gam_unit_path = Some("/21765378893/publisher/atf-sidebar".to_string()); + slot.gam_unit_path = Some("/99999/example/homepage".to_string()); + slot.compile_unit_template() + .expect("should compile static template"); assert_eq!( - slot.resolved_gam_unit_path("21765378893"), - "/21765378893/publisher/atf-sidebar" + slot.render_gam_unit_path("99999", "news"), + "/99999/example/homepage" ); } #[test] - fn resolved_div_id_defaults_to_slot_id() { - let slot = make_slot("atf", vec!["/"]); - assert_eq!(slot.resolved_div_id(), "atf"); + fn validate_runtime_requires_section_root_when_template_uses_section() { + let mut config = make_config_with_section_template(None); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + let err = config + .validate_runtime() + .expect_err("should require section_root"); + assert!( + err.contains("section_root"), + "error should mention section_root" + ); + } + + #[test] + fn validate_runtime_rejects_invalid_section_root() { + let mut config = make_config_with_section_template(Some("has space")); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + config + .validate_runtime() + .expect_err("should reject non [A-Za-z0-9_-] root"); + } + + #[test] + fn validate_runtime_accepts_section_template_with_valid_root() { + let mut config = make_config_with_section_template(Some("homepage")); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + config + .validate_runtime() + .expect("should accept valid section_root"); + } + + #[test] + fn compile_unit_templates_surfaces_parse_error() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/{bad}".to_string()); + config.compile_slots(); + config + .compile_unit_templates() + .expect_err("should surface unknown-placeholder error"); } #[test] @@ -563,19 +908,19 @@ mod tests { slot.div_id = Some(String::new()); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "empty div_id override should fail validation" ); slot.div_id = Some(" ".to_string()); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "whitespace-only div_id override should fail validation" ); slot.div_id = Some("div-ad-x".to_string()); assert!( - slot.validate_runtime("1234").is_ok(), + slot.validate_runtime().is_ok(), "a concrete div_id override should pass validation" ); } @@ -587,31 +932,31 @@ mod tests { slot.floor_price = Some(-0.01); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "negative floor_price should fail validation" ); slot.floor_price = Some(f64::NAN); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "NaN floor_price should fail validation" ); slot.floor_price = Some(f64::INFINITY); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "infinite floor_price should fail validation" ); slot.floor_price = Some(0.0); assert!( - slot.validate_runtime("1234").is_ok(), + slot.validate_runtime().is_ok(), "zero floor_price should pass validation" ); slot.floor_price = None; assert!( - slot.validate_runtime("1234").is_ok(), + slot.validate_runtime().is_ok(), "absent floor_price should pass validation" ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d6e9d2ee..9631b5242 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1789,7 +1789,7 @@ pub async fn handle_publisher_request( settings .creative_opportunities .as_ref() - .map(|co_config| build_ad_slots_script(&matched_slots, co_config)) + .map(|co_config| build_ad_slots_script(&matched_slots, co_config, &request_path)) } else { None }; @@ -2202,11 +2202,18 @@ pub(crate) fn build_empty_bids_script() -> String { /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, /// `formats`, and `targeting`. -fn build_slot_json( +pub(crate) fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, ) -> serde_json::Value { - let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id); + // `{section}` derives from the same raw path `page_patterns` matched + // against; `section_root` covers the no-segment case (`/`). + let section = crate::creative_opportunities::derive_section( + request_path, + co_config.section_root.as_deref().unwrap_or_default(), + ); + let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, §ion); let div_id = slot.resolved_div_id(); let formats: Vec = slot .formats @@ -2234,10 +2241,11 @@ fn build_slot_json( pub(crate) fn build_ad_slots_script( matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, ) -> String { let slots: Vec = matched_slots .iter() - .map(|slot| build_slot_json(slot, co_config)) + .map(|slot| build_slot_json(slot, co_config, request_path)) .collect(); let json = serde_json::to_string(&slots) .expect("serde_json::to_string of Vec should be infallible"); @@ -2563,7 +2571,7 @@ pub async fn handle_page_bids( let slots_json: Vec = if ad_stack_enabled { matched_slots .iter() - .map(|slot| build_slot_json(slot, co_config)) + .map(|slot| build_slot_json(slot, co_config, &path_param)) .collect() } else { Vec::new() @@ -4275,6 +4283,7 @@ mod tests { gam_network_id: "21765378893".to_string(), auction_timeout_ms: Some(500), price_granularity: PriceGranularity::Dense, + section_root: None, slot: Vec::new(), } } @@ -4296,6 +4305,7 @@ mod tests { .collect(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, } } @@ -4330,7 +4340,7 @@ mod tests { fn ad_slots_script_contains_slot_data() { let slots = vec![make_slot()]; let config = make_config(); - let script = build_ad_slots_script(&slots, &config); + let script = build_ad_slots_script(&slots, &config, "/"); assert!( script.contains("window.tsjs=window.tsjs||{}"), "should initialise tsjs namespace" @@ -4351,7 +4361,7 @@ mod tests { fn ad_slots_script_is_xss_safe() { let slots = vec![make_slot()]; let config = make_config(); - let script = build_ad_slots_script(&slots, &config); + let script = build_ad_slots_script(&slots, &config, "/"); let inner = script .trim_start_matches(""); @@ -4359,6 +4369,29 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in script content"); } + #[test] + fn build_slot_json_renders_section_from_request_path() { + let mut config = make_config(); + config.gam_network_id = "99999".to_string(); + config.section_root = Some("homepage".to_string()); + let mut slot = make_slot(); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template() + .expect("template should compile"); + + let news = crate::publisher::build_slot_json(&slot, &config, "/news/article-123"); + assert_eq!( + news["gam_unit_path"], "/99999/example/news", + "section should derive from the first path segment" + ); + + let home = crate::publisher::build_slot_json(&slot, &config, "/"); + assert_eq!( + home["gam_unit_path"], "/99999/example/homepage", + "root path should use section_root" + ); + } + #[test] fn bid_map_includes_nurl_and_burl() { let mut winning_bids = HashMap::new(); @@ -4943,6 +4976,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } @@ -5445,6 +5479,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c49e99686..4514d12bd 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2077,6 +2077,13 @@ impl Settings { if let Some(co) = &mut self.creative_opportunities { co.compile_slots(); + // Parse `gam_unit_path` templates once here (mirrors the compiled + // glob cache) so request-time rendering is substitution-only. + co.compile_unit_templates().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity gam_unit_path template: {err}"), + }) + })?; // Slots flow into injected HTML/JS, provider payloads, and GPT // calls. Env/private config can bypass static review, so validate // the full runtime shape on every load path. @@ -5602,7 +5609,7 @@ gam_unit_path = "" page_patterns = ["/"] formats = [{ width = 300, height = 250 }] "#, - "resolved GAM unit path must not be empty", + "gam_unit_path template must not be empty", ); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b975590c6..f5b778a15 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1229,6 +1229,81 @@ TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 TRUSTED_SERVER__AUCTION__CREATIVE_STORE=creative_store ``` +## Creative Opportunities Configuration + +### `[creative_opportunities]` + +Defines the ad slots the trusted server offers on a page: which pages each slot +appears on (`page_patterns`), its supported sizes (`formats`), and the GAM ad +unit it maps to (`gam_unit_path`). + +```toml +[creative_opportunities] +gam_network_id = "123456789" +price_granularity = "dense" + +# Shared placeholder value for the site root ("/") — see {section} below. +section_root = "home" + +[[creative_opportunities.slot]] +id = "ad-header" +gam_unit_path = "/{network_id}/example/{section}" +page_patterns = ["/", "/news/*", "/reviews/*"] +formats = [{ width = 728, height = 90 }] +``` + +### `gam_unit_path` templating + +`gam_unit_path` is a template. A publisher whose ad unit varies by site section +expresses that in **one** slot rule instead of one rule per (slot × section). + +Supported placeholders: + +| Placeholder | Resolves to | +| -------------- | -------------------------------------------------------------- | +| `{network_id}` | `gam_network_id` | +| `{slot_id}` | the slot's `id` | +| `{section}` | first path segment of the request (see derivation rules below) | + +A template with **no** placeholders is used verbatim. A slot with **no** +`gam_unit_path` falls back to `//`. Both preserve the +pre-templating behavior, so existing static configs are unchanged. + +### `{section}` derivation + +`{section}` is derived from the request path at request time: + +- It is the **first non-empty path segment**. `/news/article-123` → `news`. +- It is sanitized: each run of characters outside `[A-Za-z0-9_-]` becomes a + single `_`. +- The path is used **raw — it is not percent-decoded**. So `/new%20s` → + `new_20s` (only `%` is disallowed; `2` and `0` are kept), never the decoded + `new_s`. This keeps `{section}` consistent with how `page_patterns` match the + same raw path. +- When the path has no segment (`/`, or repeated slashes), `{section}` is + `section_root`. + +`section_root` is **required** whenever any slot's template uses `{section}`, +and must match `[A-Za-z0-9_-]+`. There is no default: the home-section name is +publisher-specific, so the URL→section convention lives in config, not core. +Startup fails if `{section}` is used without a valid `section_root`. + +Example resolution for `gam_unit_path = "/{network_id}/example/{section}"` with +`gam_network_id = "123456789"` and `section_root = "home"`: + +| Request path | `gam_unit_path` | +| --------------- | ---------------------------- | +| `/` | `/123456789/example/home` | +| `/news` | `/123456789/example/news` | +| `/news/article` | `/123456789/example/news` | +| `/reviews/x` | `/123456789/example/reviews` | + +An **unmatched route** — a path matched by no slot's `page_patterns` — produces +no slot at all, so no template is rendered for it. + +Startup validation rejects a malformed template: an unknown placeholder (e.g. +`{oops}`), an unmatched or nested `{`, a stray `}`, or an empty `gam_unit_path`. + ## Fastly Runtime Config Store After the EdgeZero cutover, the Fastly adapter always dispatches through the diff --git a/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md b/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md new file mode 100644 index 000000000..cbd4a2ed1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md @@ -0,0 +1,746 @@ +# Per-Section `gam_unit_path` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `creative_opportunities.slot.gam_unit_path` a template with a +`{section}` placeholder derived from the request path, so one slot rule serves +all site sections instead of one rule per (slot × section). + +**Architecture:** Parse each slot's `gam_unit_path` into a cached template at +startup (alongside the existing compiled-glob cache); reject malformed templates +and a `{section}` template missing its `section_root`. At request time derive +`{section}` from the raw path (sanitized) and render the template inside +`build_slot_json`, which gains a `request_path` argument. Server-only — the +client keeps receiving a resolved `gam_unit_path` string, so no JS change. + +**Tech Stack:** Rust 2024, `trusted-server-core`. Tests via `cargo test_details` +(native host, `aarch64-apple-darwin`) for iteration and `cargo test-fastly` +(core + fastly on `wasm32-wasip1` via Viceroy) for the CI gate. + +**Spec:** `docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md` + +**Issue:** https://github.com/IABTechLab/trusted-server/issues/954 + +--- + +## File Structure + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` + - new: `UnitTemplatePart` enum, `parse_unit_template`, `sanitize_section`, + `derive_section` + - new on `CreativeOpportunitySlot`: `compiled_unit` field, + `compile_unit_template`, `render_gam_unit_path`, `template_uses_section` + - new on `CreativeOpportunitiesConfig`: `section_root` field, + `compile_unit_templates`; extend `validate_runtime` + - unit tests in the existing `#[cfg(test)] mod tests` +- Modify: `crates/trusted-server-core/src/publisher.rs` + - `build_slot_json` gains `request_path: &str`; renders via `render_gam_unit_path` + - `build_ad_slots_script` gains `request_path: &str`; threads it through + - `handle_page_bids` passes its normalized `path` to `build_slot_json` +- Modify: `crates/trusted-server-core/src/settings.rs` + - `prepare_runtime` calls `compile_unit_templates` and surfaces parse errors +- Modify: `docs/guide/configuration.md` (add creative_opportunities section) +- Modify: `trusted-server.example.toml` and the live example config + +Notes on lifecycle: `page_patterns` inheritance is **out of scope** (sibling +issue). Templates are parsed at startup and cached with `#[serde(skip)]`, +mirroring the existing `compiled_patterns` field. + +--- + +## Task 1: Template parser + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Test: same file, `#[cfg(test)] mod tests` + +- [ ] **Step 1: Write the failing tests** + +Add to `mod tests`: + +```rust +#[test] +fn parse_unit_template_accepts_known_placeholders() { + let parts = parse_unit_template("/{network_id}/example/{section}") + .expect("should parse valid template"); + assert_eq!(parts.len(), 4, "should split into literal+ph+literal+ph"); +} + +#[test] +fn parse_unit_template_accepts_static_path() { + let parts = parse_unit_template("/99999/example/homepage") + .expect("should parse a static path as a single literal"); + assert!( + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/99999/example/homepage"), + "should be one literal part" + ); +} + +#[test] +fn parse_unit_template_rejects_unknown_placeholder() { + let err = parse_unit_template("/{network_id}/{oops}").expect_err("should reject unknown placeholder"); + assert!(err.contains("oops"), "error should name the bad placeholder"); +} + +#[test] +fn parse_unit_template_rejects_unmatched_brace() { + parse_unit_template("/{network_id}/{section").expect_err("should reject unmatched '{'"); + parse_unit_template("/a}b").expect_err("should reject stray '}'"); +} + +#[test] +fn parse_unit_template_rejects_nested_brace() { + parse_unit_template("/{net{work}_id}").expect_err("should reject nested '{'"); +} + +#[test] +fn parse_unit_template_rejects_empty() { + parse_unit_template("").expect_err("should reject empty template"); +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::parse_unit_template` +Expected: FAIL — `cannot find function parse_unit_template` / `UnitTemplatePart`. + +- [ ] **Step 3: Implement the enum + parser** + +Add near the top of the module body (after imports): + +```rust +/// A single parsed segment of a `gam_unit_path` template. +#[derive(Debug, Clone)] +pub(crate) enum UnitTemplatePart { + /// Verbatim text between placeholders. + Literal(String), + /// `{network_id}` — replaced with the GAM network id. + NetworkId, + /// `{section}` — replaced with the request-derived section. + Section, + /// `{slot_id}` — replaced with the slot id. + SlotId, +} + +/// Parses a `gam_unit_path` template into an ordered list of parts. +/// +/// # Errors +/// +/// Returns an error string for an empty template, an unmatched or nested `{`, +/// a stray `}`, or an unknown placeholder name. +fn parse_unit_template(raw: &str) -> Result, String> { + if raw.is_empty() { + return Err("gam_unit_path template must not be empty".to_string()); + } + let mut parts = Vec::new(); + let mut literal = String::new(); + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '{' => { + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(std::mem::take(&mut literal))); + } + let mut name = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some('{') => { + return Err(format!("nested '{{' in template `{raw}`")); + } + Some(ch) => name.push(ch), + None => return Err(format!("unmatched '{{' in template `{raw}`")), + } + } + match name.as_str() { + "network_id" => parts.push(UnitTemplatePart::NetworkId), + "section" => parts.push(UnitTemplatePart::Section), + "slot_id" => parts.push(UnitTemplatePart::SlotId), + other => { + return Err(format!( + "unknown placeholder `{{{other}}}` in template `{raw}`" + )); + } + } + } + '}' => return Err(format!("stray '}}' in template `{raw}`")), + other => literal.push(other), + } + } + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(literal)); + } + Ok(parts) +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::parse_unit_template` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative_opportunities.rs +git commit -m "Add gam_unit_path template parser" +``` + +--- + +## Task 2: Section derivation + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Test: same file + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn derive_section_uses_first_segment() { + assert_eq!(derive_section("/news", "home"), "news"); + assert_eq!(derive_section("/news/article-123", "home"), "news"); + assert_eq!(derive_section("/my-section/x", "home"), "my-section"); +} + +#[test] +fn derive_section_uses_root_when_no_segment() { + assert_eq!(derive_section("/", "homepage"), "homepage"); + assert_eq!(derive_section("///", "homepage"), "homepage"); +} + +#[test] +fn derive_section_sanitizes_unsafe_runs_to_single_underscore() { + // Not decoded: in "new%20s" only '%' is disallowed ('2' and '0' are + // alphanumeric), so it collapses to a single '_' -> "new_20s". This is + // exactly the no-decode contract: had we decoded, %20 would be a space and + // yield "new_s"; we do NOT decode. + assert_eq!(derive_section("/new%20s", "home"), "new_20s"); + // A run of disallowed chars collapses to one '_'. + assert_eq!(derive_section("/a..b", "home"), "a_b"); +} + +#[test] +fn derive_section_is_non_empty_for_all_disallowed_segment() { + assert_eq!(derive_section("/%%%/x", "home"), "_"); +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::derive_section` +Expected: FAIL — `cannot find function derive_section`. + +- [ ] **Step 3: Implement the two functions** + +```rust +/// Collapses each run of characters outside `[A-Za-z0-9_-]` to a single `_`. +/// +/// Returns a non-empty string for any non-empty input. +fn sanitize_section(segment: &str) -> String { + let mut out = String::with_capacity(segment.len()); + let mut in_bad_run = false; + for ch in segment.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + out.push(ch); + in_bad_run = false; + } else if !in_bad_run { + out.push('_'); + in_bad_run = true; + } + } + out +} + +/// Derives the `{section}` value from a request path. +/// +/// Uses the first non-empty path segment, sanitized to `[A-Za-z0-9_-]`. Falls +/// back to `section_root` when the path has no segment (`/`, repeated slashes). +/// The path is used **raw** (not percent-decoded) so this stays consistent with +/// how `page_patterns` glob-match the same path. +pub(crate) fn derive_section(path: &str, section_root: &str) -> String { + match path.split('/').find(|segment| !segment.is_empty()) { + Some(segment) => sanitize_section(segment), + None => section_root.to_string(), + } +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::derive_section` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative_opportunities.rs +git commit -m "Add request-path section derivation" +``` + +--- + +## Task 3: Config field, template compile + render, startup validation + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Test: same file + +- [ ] **Step 1: Write the failing tests** + +```rust +// NOTE: the existing helper signature is `make_slot(id: &str, patterns: Vec<&str>)` +// (see creative_opportunities.rs:443) — pass `vec![...]`, not `&[...]`. +#[test] +fn render_gam_unit_path_substitutes_placeholders() { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template().expect("should compile template"); + assert_eq!( + slot.render_gam_unit_path("99999", "news"), + "/99999/example/news" + ); +} + +#[test] +fn render_gam_unit_path_defaults_when_no_template() { + let mut slot = make_slot("sidebar", vec!["/*"]); + slot.gam_unit_path = None; + slot.compile_unit_template().expect("should compile (no template)"); + assert_eq!(slot.render_gam_unit_path("99999", "ignored"), "/99999/sidebar"); +} + +#[test] +fn render_gam_unit_path_uses_static_template_verbatim() { + let mut slot = make_slot("atf", vec!["/"]); + slot.gam_unit_path = Some("/99999/example/homepage".to_string()); + slot.compile_unit_template().expect("should compile static template"); + assert_eq!(slot.render_gam_unit_path("99999", "news"), "/99999/example/homepage"); +} + +#[test] +fn validate_runtime_requires_section_root_when_template_uses_section() { + let mut config = make_config_with_section_template(None); // section_root = None + config.compile_slots(); + config.compile_unit_templates().expect("templates compile"); + let err = config.validate_runtime().expect_err("should require section_root"); + assert!(err.contains("section_root"), "error should mention section_root"); +} + +#[test] +fn validate_runtime_rejects_invalid_section_root() { + let mut config = make_config_with_section_template(Some("has space")); + config.compile_slots(); + config.compile_unit_templates().expect("templates compile"); + config.validate_runtime().expect_err("should reject non [A-Za-z0-9_-] root"); +} + +#[test] +fn validate_runtime_accepts_section_template_with_valid_root() { + let mut config = make_config_with_section_template(Some("homepage")); + config.compile_slots(); + config.compile_unit_templates().expect("templates compile"); + config.validate_runtime().expect("should accept valid section_root"); +} + +#[test] +fn compile_unit_templates_surfaces_parse_error() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/{bad}".to_string()); + config.compile_slots(); + config.compile_unit_templates().expect_err("should surface unknown-placeholder error"); +} +``` + +Add test helpers to `mod tests` if not present (adapt to the existing helper +style in this module): + +```rust +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 { + gam_network_id: "99999".to_string(), + auction_timeout_ms: None, + price_granularity: PriceGranularity::default(), + section_root: section_root.map(str::to_string), + slot: vec![slot], + } +} +``` + +The `make_slot(id: &str, patterns: Vec<&str>)` helper **already exists** at +`creative_opportunities.rs:443` and constructs a `CreativeOpportunitySlot` via +struct-literal syntax. Because the struct uses `#[serde(deny_unknown_fields)]` +and the helper names every field explicitly, adding `compiled_unit` to the +struct makes this helper fail to compile until updated — see Step 3's helper-fix +sub-step. + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests` +Expected: FAIL — missing `section_root`, `compiled_unit`, `compile_unit_template`, +`render_gam_unit_path`, `compile_unit_templates`. + +- [ ] **Step 3: Add the field, cache, methods, and validation** + +On `CreativeOpportunitiesConfig` (add field): + +```rust +/// Value substituted for `{section}` when the request path has no first +/// segment (e.g. `/`). Required when any slot's `gam_unit_path` template +/// contains `{section}`. No default — a home-section name is publisher-specific. +#[serde(default)] +pub section_root: Option, +``` + +On `CreativeOpportunitySlot` (add cached template, parallel to `compiled_patterns`): + +```rust +/// Pre-parsed [`gam_unit_path`](Self::gam_unit_path) template, populated by +/// [`compile_unit_template`](Self::compile_unit_template) at startup. `None` +/// when the slot has no explicit `gam_unit_path` (uses the default path). +#[serde(skip, default)] +pub(crate) compiled_unit: Option>, +``` + +Slot methods: + +```rust +/// Parses [`gam_unit_path`](Self::gam_unit_path) into [`compiled_unit`](Self::compiled_unit). +/// +/// # Errors +/// +/// Returns an error string when the template is malformed (see +/// [`parse_unit_template`]). +pub fn compile_unit_template(&mut self) -> Result<(), String> { + self.compiled_unit = match &self.gam_unit_path { + Some(raw) => Some(parse_unit_template(raw).map_err(|e| format!("slot `{}`: {e}", self.id))?), + None => None, + }; + Ok(()) +} + +/// Renders the resolved GAM unit path for a given network id and section. +/// +/// Uses the parsed template when present, otherwise the default +/// `//`. +#[must_use] +pub fn render_gam_unit_path(&self, gam_network_id: &str, section: &str) -> String { + match &self.compiled_unit { + Some(parts) => parts + .iter() + .map(|part| match part { + UnitTemplatePart::Literal(s) => s.as_str(), + UnitTemplatePart::NetworkId => gam_network_id, + UnitTemplatePart::Section => section, + UnitTemplatePart::SlotId => self.id.as_str(), + }) + .collect(), + None => format!("/{}/{}", gam_network_id, self.id), + } +} + +/// Returns `true` if this slot's compiled template contains `{section}`. +#[must_use] +pub(crate) fn template_uses_section(&self) -> bool { + self.compiled_unit + .as_ref() + .is_some_and(|parts| parts.iter().any(|p| matches!(p, UnitTemplatePart::Section))) +} +``` + +On `CreativeOpportunitiesConfig` (compile all templates + extend validation): + +```rust +/// Parse every slot's `gam_unit_path` template. Call once after deserialization. +/// +/// # Errors +/// +/// Returns an error string when any slot's template is malformed. +pub fn compile_unit_templates(&mut self) -> Result<(), String> { + for slot in &mut self.slot { + slot.compile_unit_template()?; + } + Ok(()) +} +``` + +In `validate_runtime`, after the existing per-slot loop, add: + +```rust +if self.slot.iter().any(CreativeOpportunitySlot::template_uses_section) { + match self.section_root.as_deref() { + Some(root) + if !root.is_empty() + && root.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => {} + _ => { + return Err( + "section_root is required and must match [A-Za-z0-9_-]+ when a \ + gam_unit_path template uses {section}" + .to_string(), + ); + } + } +} +``` + +Remove the old path-render emptiness check in `validate_runtime` +(the block calling `resolved_gam_unit_path(...).trim().is_empty()`); malformed or +empty templates are now caught at parse time by `compile_unit_templates`, and a +rendered result is non-empty by construction. + +**Update the existing test helper (required — adding `compiled_unit` breaks it):** +Add `compiled_unit: None` to the `CreativeOpportunitySlot` struct-literal in +`make_slot` at `crates/trusted-server-core/src/creative_opportunities.rs:443`. +The struct uses `#[serde(deny_unknown_fields)]` and the helper names every field, +so a missing field is a compile error, not a `#[serde(default)]` fill-in. + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests` +Expected: PASS (Task 1–3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative_opportunities.rs +git commit -m "Add section_root, unit-template compile/render, and startup validation" +``` + +--- + +## Task 4: Render at request time (thread the path through publisher.rs) + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` (remove now-unused `resolved_gam_unit_path`, or keep if other callers remain — grep first) +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Test: `crates/trusted-server-core/src/publisher.rs` `#[cfg(test)] mod tests` + +- [ ] **Step 0: Update publisher.rs struct-literal test helpers (required — new fields break them)** + +Adding `section_root` to `CreativeOpportunitiesConfig` and `compiled_unit` to +`CreativeOpportunitySlot` breaks every hand-built literal in `publisher.rs` +tests. Add the new fields to each: + +- `crates/trusted-server-core/src/publisher.rs:4272` — `make_config()`: add `section_root: None`. +- `crates/trusted-server-core/src/publisher.rs:4282` — `make_slot()`: add `compiled_unit: None`. +- `crates/trusted-server-core/src/publisher.rs:4931` — `article_slot()`: add `compiled_unit: None`. +- `crates/trusted-server-core/src/publisher.rs:5433` — `article_slot()` (second module): add `compiled_unit: None`. + +Run: `cargo test_details -p trusted-server-core publisher:: --no-run` +Expected: compiles (no `missing field` errors) before writing the new test. + +- [ ] **Step 1: Write the failing test (equivalence + per-section)** + +In `publisher.rs` tests, add (adapt to the existing test helpers/config builders +in that module): + +```rust +#[test] +fn build_slot_json_renders_section_from_request_path() { + let config = creative_opportunities_config_with_template(); // gam_unit_path = "/{network_id}/example/{section}", section_root = "homepage" + let slot = &config.slot[0]; + + let news = build_slot_json(slot, &config, "/news/article-123"); + assert_eq!(news["gam_unit_path"], "/99999/example/news"); + + let home = build_slot_json(slot, &config, "/"); + assert_eq!(home["gam_unit_path"], "/99999/example/homepage"); +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test_details -p trusted-server-core publisher::tests::build_slot_json_renders_section` +Expected: FAIL — `build_slot_json` takes 2 args / wrong unit value. + +- [ ] **Step 3: Thread `request_path` and render** + +In `build_slot_json` (`crates/trusted-server-core/src/publisher.rs` ~2204): + +```rust +fn build_slot_json( + slot: &crate::creative_opportunities::CreativeOpportunitySlot, + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, +) -> serde_json::Value { + let section = crate::creative_opportunities::derive_section( + request_path, + co_config.section_root.as_deref().unwrap_or_default(), + ); + let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, §ion); + // ...rest unchanged (div_id, formats, targeting, json!)... +} +``` + +In `build_ad_slots_script` (~2233) add `request_path: &str` and pass it: + +```rust +pub(crate) fn build_ad_slots_script( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, +) -> String { + let slots: Vec = matched_slots + .iter() + .map(|slot| build_slot_json(slot, co_config, request_path)) + .collect(); + // ...unchanged... +} +``` + +At the initial-render caller (~publisher.rs:1791) pass `&request_path`: + +```rust +.map(|co_config| build_ad_slots_script(&matched_slots, co_config, &request_path)) +``` + +In `handle_page_bids` (~2562) pass the already-normalized path +(`path_param` / the value from `normalize_page_bids_path`) to `build_slot_json`: + +```rust +.map(|slot| build_slot_json(slot, co_config, &path_param)) +``` + +Update any existing `build_ad_slots_script(...)` / `build_slot_json(...)` test +call sites in `publisher.rs` to pass a path argument (e.g. `"/"`). + +- [ ] **Step 4: Update `settings.rs::prepare_runtime`** + +In `crates/trusted-server-core/src/settings.rs` (~2078), compile templates and +surface parse errors: + +```rust +if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + co.compile_unit_templates().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity gam_unit_path template: {err}"), + }) + })?; + co.validate_runtime().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity slot config: {err}"), + }) + })?; +} +``` + +- [ ] **Step 5: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core publisher::tests` +Expected: PASS. + +- [ ] **Step 6: Fix the existing empty-`gam_unit_path` settings test if needed** + +`settings.rs::settings_rejects_creative_opportunity_slot_with_empty_gam_unit_path` +now fails at template-parse (empty template) rather than the render check. Verify +it still asserts rejection; update the expected error substring if it pins a +message. + +Run: `cargo test_details -p trusted-server-core settings::` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/creative_opportunities.rs crates/trusted-server-core/src/settings.rs +git commit -m "Render gam_unit_path template per request across initial and SPA paths" +``` + +--- + +## Task 5: Docs + config + +**Files:** + +- Modify: `docs/guide/configuration.md` +- Modify: `trusted-server.example.toml` +- Modify: the live example `trusted-server.toml` (operator-owned, gitignored — update locally, do not commit) + +- [ ] **Step 1: Add a creative_opportunities section to configuration.md** + +Cover: the placeholder set (`{network_id}`, `{section}`, `{slot_id}`); section +derivation (first path segment, sanitized to `[A-Za-z0-9_-]`, raw/undecoded); +`section_root` requirement and validation; behavior on an unmatched route (no +slot, template never rendered); back-compat (static path used verbatim; no +`gam_unit_path` → `//`). Use fictional values +(`example.com`, network `99999`) per the repo's docs rule. + +- [ ] **Step 2: Update `trusted-server.example.toml`** + +Show one templated slot with `section_root` and a `{section}` `gam_unit_path`, +using fictional values. + +- [ ] **Step 3: Docs format check** + +Run: `cd docs && npm run format` +Expected: no diff / formatting clean. + +- [ ] **Step 4: Commit** + +```bash +git add docs/guide/configuration.md trusted-server.example.toml +git commit -m "Document per-section gam_unit_path templating" +``` + +--- + +## Task 6: Full verification (CI gate) + +- [ ] **Step 1: Format** + +Run: `cargo fmt --all -- --check` +Expected: clean. + +- [ ] **Step 2: Core + Fastly tests under Viceroy (full module, not filtered)** + +Run: `cargo test-fastly` +Expected: PASS. (Runs the full creative_opportunities + publisher test modules on +`wasm32-wasip1`; a format-changing edit can hide later failures when filtered, so +run the whole suite here.) + +- [ ] **Step 3: Other adapters (no behavior change expected, guard against signature breaks)** + +Run: `cargo test-axum && cargo test-cloudflare && cargo test-spin` +Expected: PASS. + +- [ ] **Step 4: Clippy across adapter targets** + +Run: `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm` +Expected: no warnings. + +- [ ] **Step 5: JS unaffected (sanity)** + +Run: `cd crates/trusted-server-js/lib && npx vitest run` +Expected: PASS (no JS change; confirms wire shape unbroken). + +- [ ] **Step 6: Final commit if any fixups** + +```bash +git add -A +git commit -m "Fix clippy/fmt for per-section gam_unit_path" +``` + +--- + +## Acceptance criteria mapping + +- **N slots × M sections without N×M rules** — Task 1–4 (one templated slot rule + serves all sections). +- **Resolution tested (`/`, single/multi-segment, no-match, encoded)** — Task 2 + tests + Task 4 equivalence + the unmatched-route case (no slot matched → no + `build_slot_json` call; covered by existing `match_slots` empty tests). +- **Existing static configs unchanged** — Task 3 `render_gam_unit_path` verbatim + - default tests. +- **Startup catches empty/unknown/malformed template + missing/invalid + `section_root`** — Task 1 + Task 3 validation tests. +- **`{section}` sanitized, raw path** — Task 2 tests. +- **Documented** — Task 5. diff --git a/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md new file mode 100644 index 000000000..b18c1bcb0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md @@ -0,0 +1,208 @@ +# Per-Section `gam_unit_path` Design + +**Date:** 2026-07-23 + +**Status:** Proposed + +**Issue:** [IABTechLab/trusted-server#954](https://github.com/IABTechLab/trusted-server/issues/954) + +## Summary + +`creative_opportunities.slot.gam_unit_path` is a static string, so a publisher +whose GAM ad unit varies by site section cannot express that in one rule. The +only way to model it today is one slot rule per (slot × section), which +multiplies out fast: 3 slots across 10 sections needs 30 near-identical rules. + +This design makes `gam_unit_path` a **template** with a small, fixed placeholder +set — `{network_id}`, `{section}`, `{slot_id}` — where `{section}` is derived +from the request path at render time. One slot rule then covers all sections. + +The derivation policy that matters (`{section}` for the site root) lives in +config via a required `section_root`, not in core — honoring the issue's +constraint that the URL→section convention is publisher-specific. + +Scope is deliberately narrow: **only** `gam_unit_path` templating. Sharing +`page_patterns`/`gam_unit_path` defaults across slots is a related but distinct +duplication problem, tracked as a sibling issue, not built here. + +## Goals + +1. A publisher with N slots across M sections expresses per-section ad units + without N×M rules. +2. `{section}` is derived from the request path with a config-supplied value for + the site root; no URL convention is hardcoded in core. +3. Existing static `gam_unit_path` configs keep working, byte-for-byte + unchanged. +4. Startup rejects unresolvable configuration: unknown placeholders, malformed + templates, and a `{section}` template missing its `section_root`. +5. Resolution is covered by tests including `/`, single- and multi-segment + paths, unsafe/encoded segments, and paths matching no slot. +6. Documented in `docs/guide/configuration.md`, which currently has no + creative_opportunities section. + +## Non-goals + +Documented here so onboarding publishers know the boundary. Each is an additive +extension that does **not** change the config shape below. + +1. **Locale offset** — deriving `{section}` from a segment other than the first + (e.g. `/en/news` → `news`). `{section}` is the first path segment. A + `section_segment` index knob can be added later. +2. **Full-path mirror** — `{section}` spanning multiple segments (`/a/b` → + `a/b`). Real GAM trees bucket by section, not per-article, so this is rare; + use a static per-slot `gam_unit_path` for the exception. +3. **Named per-section overrides** — mapping an irregular section to a renamed + unit (`/reviews` → `editorial/reviews-v2`). Set that one slot's + `gam_unit_path` explicitly, or add named overrides later. +4. **Host- or query-derived sections** — path-only. Out of scope entirely. +5. **Slot-defaults inheritance** — sharing `page_patterns`/`gam_unit_path` at + the `[creative_opportunities]` level. Separate issue; has a startup-lifecycle + concern this design intentionally avoids. + +## Background: how `gam_unit_path` is used + +- `gam_unit_path` is **client-side only**. The resolved string reaches + `googletag.defineSlot(path, sizes, div)` in + `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`. It is **not** in + the OpenRTB bid request — `CreativeOpportunitySlot::to_ad_slot` never emits it. + Therefore this change is **server-only; no JS wire change**. The client keeps + receiving a resolved `gam_unit_path` string. +- Today's resolver is literal-or-default and path-independent + (`crates/trusted-server-core/src/creative_opportunities.rs`): + + ```rust + pub fn resolved_gam_unit_path(&self, gam_network_id: &str) -> String { + self.gam_unit_path + .clone() + .unwrap_or_else(|| format!("/{}/{}", gam_network_id, self.id)) + } + ``` + +- The value is emitted in `build_slot_json` + (`crates/trusted-server-core/src/publisher.rs`), shared by two paths: + - initial render via `build_ad_slots_script` (called where `request_path` is + in scope); + - SPA navigation via `handle_page_bids` (has the normalized `path` param). + + Neither currently passes the path into `build_slot_json`. + +## Design + +### Config shape + +```toml +[creative_opportunities] +gam_network_id = "99999" +auction_timeout_ms = 2000 +price_granularity = "dense" +section_root = "homepage" # required when a template uses {section} + +[[creative_opportunities.slot]] +id = "ad-header-0" +gam_unit_path = "/{network_id}/example/{section}" +page_patterns = ["/", "/news/*", "/reviews/*", "/deals/*"] +formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }] +[creative_opportunities.slot.providers.prebid] +bidders = {} +``` + +### Placeholders + +| placeholder | resolves to | +| -------------- | ----------------------------------------------------- | +| `{network_id}` | `gam_network_id` | +| `{slot_id}` | slot `id` | +| `{section}` | first path segment; `section_root` when path has none | + +### Resolution model + +```text +startup (prepare_runtime, once): + for each slot: + parse slot.gam_unit_path (if Some) into a template: + reject unknown placeholder, unmatched/nested brace, empty template + cache the parsed template (serde-skipped, like compiled_patterns) + if any slot's template contains {section}: + require section_root present AND matching ^[A-Za-z0-9_-]+$ + +request (per matched slot, path known): + if slot has a parsed template: + section = first non-empty segment of the RAW path, + runs of [^A-Za-z0-9_-] replaced with a single '_'; + section_root when the path has no segment ("/", repeated slashes) + render template + else: + "/{network_id}/{slot_id}" # existing default (back-compat) +``` + +### Section derivation rules (deterministic) + +- Extract the **first non-empty** path segment. +- Replace each run of disallowed characters (`[^A-Za-z0-9_-]`) with a single + `_`. Guarantees a non-empty result for any non-empty segment. Because the path + is **not** decoded, `new%20s` → `new_20s` (only `%` is disallowed; `2` and `0` + are alphanumeric) — never silently `news`, and never the decoded `new_s`. +- Use `section_root` **only** when there is no segment (`/`, repeated slashes). +- Derive from the **raw, undecoded** path — the same string `page_patterns` + glob-match against — so matching and derivation never disagree. Percent-encoded + segments are **not** decoded. +- `section_root` validated at startup: non-empty, entirely `[A-Za-z0-9_-]`. + +### Back-compat + +- No template placeholders in a slot's `gam_unit_path` → used verbatim. +- No `gam_unit_path` set on a slot → `/{network_id}/{slot_id}` (unchanged). +- A config with no `{section}` anywhere never requires `section_root`. + +### Validation moves from render to parse + +`validate_runtime` currently calls `resolved_gam_unit_path` and rejects an empty +result. That check becomes path-dependent under templating, so it is replaced by +**startup template validation**: the template parses, all placeholders are +known, and `section_root` is present when `{section}` is used. The rendered +result is non-empty by construction (literals plus non-empty substitutions, or +the `/{network_id}/{slot_id}` default), so no per-request emptiness check is +needed. + +## Alternatives considered + +- **Named sections** (`[section.NAME]` blocks carrying patterns + unit): more + general (expresses irregular units) but forces enumerating every section, and + centralizes patterns — a bigger change that overlaps the deferred + slot-defaults concern. Rejected as the base; the `unit`-override variant is a + possible future extension. +- **Explicit `unit_by_pattern` map per slot** (issue option 2): fully + data-driven but repeats the section→unit table inside every slot, so adding a + section still edits all N slots. Rejected. +- **Hardcoded first-segment derivation** (issue option 3, literal): smallest, + but bakes one site's URL convention into core, which the issue forbids. The + chosen design keeps the one publisher-specific knob (`section_root`) in config. + +## Risks + +- **Client-influenced path.** `{section}` is derived from a request path the + client controls (especially the SPA `path` param). Mitigated by: sanitizing to + `[A-Za-z0-9_-]`; deriving only for paths that already matched a slot's + `page_patterns`; and the fact that `gam_unit_path` is not in the bid request, + so a crafted section only affects the caller's own `defineSlot`. +- **Two render paths drift.** Initial-render and SPA must produce identical + units for the same path. Covered by an equivalence test. + +## Acceptance criteria + +- [ ] N slots × M sections without N×M rules. +- [ ] Resolution tested: `/`, single-segment, multi-segment, no-match, encoded + segment. +- [ ] Existing static `gam_unit_path` configs unchanged. +- [ ] `validate()` (startup) catches empty/unknown/malformed template and a + `{section}` template with missing/invalid `section_root`. +- [ ] `{section}` sanitized to `[A-Za-z0-9_-]`, derived from the raw path. +- [ ] Documented in `docs/guide/configuration.md`, including unmatched-route + behavior and the no-decode rule; example and live example configs updated. + +## Sibling issue (not built here) + +"creative_opportunities: support shared slot defaults for `page_patterns` and +`gam_unit_path`." Inheritance of `page_patterns` must materialize onto each slot +at startup **before** `compile_slots()` (because `match_slots` never sees the +top-level config), which is the lifecycle subtlety this scoped design avoids. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 26d95d681..e2f8994f6 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -157,7 +157,29 @@ gam_network_id = "123456789" auction_timeout_ms = 500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS price_granularity = "dense" +# `gam_unit_path` may be a template. Supported placeholders: +# {network_id} -> gam_network_id +# {slot_id} -> the slot's id +# {section} -> first path segment of the request, sanitized to +# [A-Za-z0-9_-]; `section_root` below is used for "/". +# A template with no placeholders (or an absent gam_unit_path) keeps the old +# behavior: verbatim path, or the default `//`. +# +# `section_root` is REQUIRED when any slot's template uses {section}. There is no +# default — the home-section name is publisher-specific. Must be [A-Za-z0-9_-]+. +section_root = "home" + # No slot templates are enabled in the checked-in default config. Add # `[[creative_opportunities.slot]]` entries via private config or override the # entire array via: # TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' +# +# Example templated slot (one rule serves every section): +# [[creative_opportunities.slot]] +# id = "ad-header" +# gam_unit_path = "/{network_id}/example/{section}" +# page_patterns = ["/", "/news/*", "/reviews/*"] +# formats = [{ width = 728, height = 90 }] +# "/" -> /123456789/example/home +# "/news/x" -> /123456789/example/news +# "/reviews/y" -> /123456789/example/reviews