diff --git a/CHANGELOG.md b/CHANGELOG.md index e0261cf72..36655763c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory creative sanitization across `POST /auction` and publisher SSAT/page-bids delivery while skipping first-party resource/click URL rewriting; it also skips creative TSJS injection on `POST /auction`. +- `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 40bf0e6c3..cdee3b222 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -218,6 +218,46 @@ mod tests { use super::*; use crate::test_support::tests::crate_test_settings_str; + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + #[allow(dead_code)] + struct LegacyCreativeOpportunitiesConfig { + gam_network_id: String, + #[serde(default)] + auction_timeout_ms: Option, + #[serde(default)] + price_granularity: serde_json::Value, + #[serde(default)] + slot: Vec, + } + + fn serialized_creative_opportunities(gam_unit_path: Option<&str>) -> serde_json::Value { + let mut toml = crate_test_settings_str(); + toml.push_str( + r#" + +[creative_opportunities] +gam_network_id = "99999" + +[[creative_opportunities.slot]] +id = "example-slot" +page_patterns = ["/*"] +formats = [{ width = 300, height = 250 }] +"#, + ); + if let Some(gam_unit_path) = gam_unit_path { + toml.push_str(&format!("gam_unit_path = {gam_unit_path:?}\n")); + } + + let app_config: TrustedServerAppConfig = + toml::from_str(&toml).expect("should deserialize app config wrapper"); + serde_json::to_value(app_config) + .expect("should serialize app config wrapper") + .get("creative_opportunities") + .cloned() + .expect("should contain creative opportunities") + } + fn valid_settings() -> Settings { let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); @@ -254,6 +294,37 @@ mod tests { ); } + #[test] + fn dynamic_gam_unit_templates_are_rejected_by_legacy_schema() { + for gam_unit_path in ["/{network_id}/example", "/example/{slot_id}"] { + let creative_opportunities = serialized_creative_opportunities(Some(gam_unit_path)); + let err = + serde_json::from_value::(creative_opportunities) + .expect_err("should reject dynamic GAM unit template"); + + assert!( + err.to_string().contains("section_segment"), + "legacy error should name section_segment: {err}" + ); + } + } + + #[test] + fn static_gam_unit_template_is_accepted_by_legacy_schema() { + let creative_opportunities = serialized_creative_opportunities(Some("/99999/example/home")); + + serde_json::from_value::(creative_opportunities) + .expect("should accept static GAM unit template"); + } + + #[test] + fn absent_gam_unit_template_is_accepted_by_legacy_schema() { + let creative_opportunities = serialized_creative_opportunities(None); + + serde_json::from_value::(creative_opportunities) + .expect("should accept absent GAM unit template"); + } + #[test] fn deploy_validation_rejects_placeholders() { let settings = Settings::from_toml( diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 9ce741f3f..10a85b3e8 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -14,6 +14,175 @@ use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::price_bucket::PriceGranularity; use crate::settings::vec_from_seq_or_map; +const MAX_DYNAMIC_GAM_UNIT_PATH_BYTES: usize = 100; +const MAX_SECTION_BYTES: usize = 100; + +/// 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, +} + +impl UnitTemplatePart { + fn is_placeholder(&self) -> bool { + !matches!(self, Self::Literal(_)) + } +} + +/// 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) +} + +fn resolved_unit_template_part<'a>( + part: &'a UnitTemplatePart, + gam_network_id: &'a str, + section: &'a str, + slot_id: &'a str, +) -> &'a str { + match part { + UnitTemplatePart::Literal(value) => value, + UnitTemplatePart::NetworkId => gam_network_id, + UnitTemplatePart::Section => section, + UnitTemplatePart::SlotId => slot_id, + } +} + +fn render_dynamic_unit_path( + parts: &[UnitTemplatePart], + gam_network_id: &str, + section: &str, + slot_id: &str, +) -> Option { + let rendered_len = parts.iter().try_fold(0usize, |len, part| { + let value = resolved_unit_template_part(part, gam_network_id, section, slot_id); + len.checked_add(value.len()) + })?; + if rendered_len > MAX_DYNAMIC_GAM_UNIT_PATH_BYTES { + return None; + } + + let mut rendered = String::with_capacity(rendered_len); + for part in parts { + rendered.push_str(resolved_unit_template_part( + part, + gam_network_id, + section, + slot_id, + )); + } + Some(rendered) +} + +fn is_section_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' +} + +/// Collapses each run of characters outside `[A-Za-z0-9_-]` to a single `_`. +/// +/// Returns a non-empty, request-derived ASCII string for any non-empty input, +/// capped at 100 ASCII (and therefore UTF-8) bytes. +fn sanitize_section(segment: &str) -> String { + let mut out = String::with_capacity(segment.len().min(MAX_SECTION_BYTES)); + let mut in_bad_run = false; + let mut chars = segment.chars(); + while out.len() < MAX_SECTION_BYTES { + let Some(ch) = chars.next() else { + break; + }; + if is_section_char(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. +/// +/// Takes the non-empty path segment at `section_segment` (0-based, counting +/// only non-empty segments), sanitizes it to `[A-Za-z0-9_-]`, and caps the +/// request-derived result at 100 ASCII/UTF-8 bytes. Falls back to `section_root` +/// when the path has no such segment — the site root (`/`), repeated slashes, +/// or a path shorter than the configured index. +/// +/// `section_segment` exists because the URL→section convention is +/// publisher-specific: a site that prefixes a locale (`/en/news/article`) sets +/// `section_segment = 1` to get `news` rather than `en`. +/// +/// 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`. +#[must_use] +fn derive_section(path: &str, section_root: &str, section_segment: usize) -> String { + match path + .split('/') + .filter(|segment| !segment.is_empty()) + .nth(section_segment) + { + 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,12 +209,67 @@ 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 segment + /// at [`section_segment`](Self::section_segment), such as `/` or a path + /// shorter than that configured index. + /// + /// 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 it stays in config, not core. + /// + /// Static and absent [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) + /// configurations remain compatible with the legacy schema only when both + /// this key and [`section_segment`](Self::section_segment) are omitted. + /// These structs use `deny_unknown_fields`, so any pushed new key makes an + /// older binary fail configuration load. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section_root: Option, + /// Index of the path segment `{section}` is taken from, 0-based over + /// non-empty segments. Defaults to `0` (the first segment). + /// + /// The URL→section convention is publisher-specific: a site that prefixes a + /// locale (`/en/news/article`) sets `section_segment = 1` to select `news` + /// instead of `en`. Paths with no segment at this index fall back to + /// [`section_root`](Self::section_root), so on `/en` a config with + /// `section_segment = 1` renders the root section. + /// + /// During typed/startup finalization, after successfully parsing any + /// placeholder-bearing template, + /// [`compile_unit_templates`](Self::compile_unit_templates) materializes + /// `Some(0)` when this is unset as an automatic compatibility marker: an + /// older `deny_unknown_fields` binary then fails loudly rather than silently + /// accepting a dynamic configuration it does not understand. Static and + /// absent [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) + /// configurations remain legacy-compatible only when both this key and + /// [`section_root`](Self::section_root) are omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section_segment: 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, } impl CreativeOpportunitiesConfig { + /// Derives the `{section}` value for `path` under this config's section + /// policy ([`section_root`](Self::section_root) and + /// [`section_segment`](Self::section_segment)). + /// + /// This keeps both policy knobs together so callers consistently apply the + /// configured section-selection and fallback rules. + /// + /// An unset [`section_root`](Self::section_root) yields an empty section for + /// a path with no matching segment. [`validate_runtime`](Self::validate_runtime) + /// rejects that combination for any template that uses `{section}`, so it + /// cannot reach a rendered unit path. + #[must_use] + pub fn section_for_path(&self, path: &str) -> String { + derive_section( + path, + self.section_root.as_deref().unwrap_or_default(), + self.section_segment.unwrap_or(0), + ) + } + /// Pre-compile glob patterns for all slots. Call once after deserialization. pub fn compile_slots(&mut self) { for slot in &mut self.slot { @@ -53,15 +277,92 @@ 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. During + /// typed/startup finalization, after all templates parse successfully, + /// materializes `section_segment = Some(0)` for a placeholder-bearing + /// template that omitted it, so rollback to an older `deny_unknown_fields` + /// binary fails loudly. + pub fn compile_unit_templates(&mut self) -> Result<(), String> { + for slot in &mut self.slot { + slot.compile_unit_template()?; + } + if self.section_segment.is_none() + && self + .slot + .iter() + .any(CreativeOpportunitySlot::template_is_dynamic) + { + self.section_segment = Some(0); + } + Ok(()) + } + /// Validate all slot definitions after runtime preparation. /// + /// Call [`compile_unit_templates`](Self::compile_unit_templates) first so + /// malformed templates fail at startup. When the cache is absent, validation + /// also reads a valid raw template so placeholder-dependent requirements are + /// still enforced; compilation remains required to reject malformed raw + /// templates. [`Settings::prepare_runtime`](crate::settings::Settings::prepare_runtime) + /// 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. + /// Returns an error string when [`gam_network_id`](Self::gam_network_id) is + /// blank but consumed by a default path or `{network_id}` template; when a + /// slot has an invalid identifier, page pattern set, format list, or + /// dimensions; when a `{section}` template lacks a valid + /// [`section_root`](Self::section_root); or when configured values make a + /// dynamic path exceed 100 UTF-8 bytes. pub fn validate_runtime(&self) -> Result<(), String> { + // A network ID is required only when a slot renders the default + // `//` path or substitutes `{network_id}`. Static + // and `{slot_id}`/`{section}`-only templates leave it inert. + let network_id_consumed = self + .slot + .iter() + .any(|slot| slot.gam_unit_path.is_none() || slot.template_uses_network_id()); + if network_id_consumed && self.gam_network_id.trim().is_empty() { + return Err("gam_network_id must not be empty".to_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(is_section_char) => {} + _ => { + return Err("section_root is required and must match [A-Za-z0-9_-]+ \ + when a gam_unit_path template uses {section}" + .to_string()); + } + } + } + + let configured_section = self.section_root.as_deref().unwrap_or_default(); + for slot in &self.slot { + if slot.template_is_dynamic() + && slot + .render_gam_unit_path(&self.gam_network_id, configured_section) + .is_none() + { + return Err(format!( + "slot `{}` dynamic gam_unit_path must render to at most \ + {MAX_DYNAMIC_GAM_UNIT_PATH_BYTES} UTF-8 bytes using configured values", + slot.id + )); + } } Ok(()) @@ -106,6 +407,20 @@ 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` means *not compiled* — either the slot has no explicit + /// `gam_unit_path`, or it was deserialized/built without running + /// [`CreativeOpportunitiesConfig::compile_unit_templates`]. Callers must + /// therefore fall back to [`gam_unit_path`](Self::gam_unit_path) rather than + /// treating `None` as "no template"; see + /// [`render_gam_unit_path`](Self::render_gam_unit_path). + /// + /// `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 +430,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 +485,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 +571,124 @@ 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 + /// + /// 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(()) + } + + fn template_is_dynamic(&self) -> bool { + let is_dynamic = + |parts: &[UnitTemplatePart]| parts.iter().any(UnitTemplatePart::is_placeholder); + match (&self.compiled_unit, &self.gam_unit_path) { + (Some(parts), _) => is_dynamic(parts), + (None, Some(raw)) => parse_unit_template(raw).is_ok_and(|parts| is_dynamic(&parts)), + (None, None) => false, + } + } + + /// 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 `//` only when the slot has no + /// [`gam_unit_path`](Self::gam_unit_path) at all. + /// + /// Returns `None` when a dynamic template would render beyond the 100-byte + /// GAM unit-path limit. Explicit static paths and the default path retain + /// their pre-template behavior and are not subject to this dynamic limit. + /// + /// This is the path-aware replacement for the pre-templating + /// `resolved_gam_unit_path(&self, gam_network_id)`. /// - /// Uses the explicit [`gam_unit_path`](Self::gam_unit_path) override when set, - /// otherwise constructs `//`. + /// # Performance + /// + /// The hot path reads the [`compiled_unit`](Self::compiled_unit) cache. A + /// slot with an explicit `gam_unit_path` but no cache (built by hand, or + /// deserialized without [`CreativeOpportunitiesConfig::compile_unit_templates`]) + /// re-parses its template on every call — same fallback shape as + /// [`matches_path`](Self::matches_path). It must never silently degrade to + /// the default path, which would bid against the wrong inventory. + /// Dynamic templates compute their exact UTF-8 byte length with checked + /// arithmetic before allocating the final string, then allocate once at + /// the exact capacity. #[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 fn render_gam_unit_path(&self, gam_network_id: &str, section: &str) -> Option { + let is_dynamic = + |parts: &[UnitTemplatePart]| parts.iter().any(UnitTemplatePart::is_placeholder); + match (&self.compiled_unit, &self.gam_unit_path) { + (Some(parts), _) if is_dynamic(parts) => { + render_dynamic_unit_path(parts, gam_network_id, section, &self.id) + } + (Some(_), Some(raw)) => Some(raw.clone()), + (Some(parts), None) => Some( + parts + .iter() + .map(|part| { + resolved_unit_template_part(part, gam_network_id, section, &self.id) + }) + .collect(), + ), + // A malformed template cannot reach a compiled config (startup + // rejects it), so on this path use the raw string verbatim — the + // pre-templating behaviour — instead of dropping to the default. + (None, Some(raw)) => match parse_unit_template(raw) { + Ok(parts) if is_dynamic(&parts) => { + render_dynamic_unit_path(&parts, gam_network_id, section, &self.id) + } + Ok(_) | Err(_) => Some(raw.clone()), + }, + (None, None) => Some(format!("/{}/{}", gam_network_id, self.id)), + } + } + + /// Returns `true` if this slot's `gam_unit_path` template contains `{section}`. + /// + /// Reads the raw template when [`compiled_unit`](Self::compiled_unit) is + /// empty so validation cannot silently skip the + /// [`section_root`](CreativeOpportunitiesConfig::section_root) requirement + /// for an uncompiled config. + #[must_use] + pub(crate) fn template_uses_section(&self) -> bool { + let uses_section = |parts: &[UnitTemplatePart]| { + parts.iter().any(|p| matches!(p, UnitTemplatePart::Section)) + }; + match (&self.compiled_unit, &self.gam_unit_path) { + (Some(parts), _) => uses_section(parts), + (None, Some(raw)) => parse_unit_template(raw).is_ok_and(|parts| uses_section(&parts)), + (None, None) => false, + } + } + + /// Returns `true` if this slot's `gam_unit_path` template contains `{network_id}`. + /// + /// Reads the raw template when [`compiled_unit`](Self::compiled_unit) is + /// empty so validation cannot silently skip the network ID requirement for + /// an uncompiled config. + fn template_uses_network_id(&self) -> bool { + let uses_network_id = |parts: &[UnitTemplatePart]| { + parts + .iter() + .any(|part| matches!(part, UnitTemplatePart::NetworkId)) + }; + match (&self.compiled_unit, &self.gam_unit_path) { + (Some(parts), _) => uses_network_id(parts), + (None, Some(raw)) => { + parse_unit_template(raw).is_ok_and(|parts| uses_network_id(&parts)) + } + (None, None) => false, + } } /// Returns the div element ID for this slot. @@ -455,6 +881,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, } } @@ -530,28 +957,677 @@ 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", 0), "news"); + assert_eq!(derive_section("/news/article-123", "home", 0), "news"); + assert_eq!(derive_section("/my-section/x", "home", 0), "my-section"); + } + + #[test] + fn derive_section_uses_configured_segment_index() { + // A locale-prefixed site sets section_segment = 1. + assert_eq!(derive_section("/en/news/article", "home", 1), "news"); + assert_eq!(derive_section("/en/news", "home", 1), "news"); + // Repeated separators are not counted as segments. + assert_eq!(derive_section("//en//news//x", "home", 1), "news"); + } + + #[test] + fn derive_section_uses_root_when_segment_index_out_of_range() { + // Section landing page of a locale-prefixed site: no segment 1 exists, + // so the root value stands in rather than reusing the locale. + assert_eq!(derive_section("/en", "home", 1), "home"); + assert_eq!(derive_section("/", "home", 1), "home"); + } + + #[test] + fn derive_section_uses_root_when_no_segment() { + assert_eq!(derive_section("/", "homepage", 0), "homepage"); + assert_eq!(derive_section("///", "homepage", 0), "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", 0), "new_20s"); + // A run of disallowed chars collapses to one '_'. + assert_eq!(derive_section("/a..b", "home", 0), "a_b"); + } + + #[test] + fn derive_section_caps_safe_segment_at_one_hundred_ascii_bytes() { + let path = format!("/{}", "a".repeat(150)); + + let section = derive_section(&path, "home", 0); + + assert_eq!( + section, + "a".repeat(100), + "should cap a safe request segment at 100 ASCII bytes" + ); + assert!(section.is_ascii(), "section output should remain ASCII"); + assert_eq!( + section.len(), + 100, + "section should contain exactly 100 bytes" + ); + } + + #[test] + fn derive_section_caps_disallowed_run_to_one_underscore() { + let path = format!("/{}%!?z", "a".repeat(99)); + + let section = derive_section(&path, "home", 0); + + assert_eq!( + section, + format!("{}_", "a".repeat(99)), + "a disallowed run at the cap should emit one underscore and stop" + ); + assert_eq!(section.len(), 100, "section should stop at the byte cap"); + assert!( + !section.contains('z'), + "a safe character beyond the cap should not leak into the section" + ); + } + + #[test] + fn derive_section_stops_before_disallowed_run_when_cap_is_full() { + let path = format!("/{}%!?z", "a".repeat(100)); + + let section = derive_section(&path, "home", 0); + + assert_eq!( + section, + "a".repeat(100), + "a full safe prefix should prevent scanning or emitting the later run" + ); + assert!( + !section.contains('_') && !section.contains('z'), + "nothing beyond the full safe prefix should be emitted" + ); + } + + #[test] + fn section_for_path_applies_both_policy_knobs() { + let mut config = make_config_with_section_template(Some("home")); + assert_eq!( + config.section_for_path("/en/news/article"), + "en", + "should default to the first segment when section_segment is unset" + ); + + config.section_segment = Some(1); + assert_eq!( + config.section_for_path("/en/news/article"), + "news", + "should honour the configured segment index" + ); + assert_eq!( + config.section_for_path("/en"), + "home", + "should fall back to section_root when the index is out of range" + ); + } + + #[test] + fn section_segment_is_omitted_from_serialized_config_when_unset() { + // Same rollback contract as section_root: `deny_unknown_fields` on the + // previous binary rejects a blob carrying keys it does not know. + let config = make_config_with_section_template(None); + let value = serde_json::to_value(&config).expect("should serialize config"); + assert!( + value.get("section_segment").is_none(), + "unset section_segment should not be serialized, got {value}" + ); + } + + #[test] + fn derive_section_is_non_empty_for_all_disallowed_segment() { + assert_eq!(derive_section("/%%%/x", "home", 0), "_"); + } + + 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), + section_segment: None, + 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.render_gam_unit_path("99999", "news"), + Some("/99999/example/news".to_string()) + ); + } + + #[test] + fn render_gam_unit_path_omits_over_limit_compiled_dynamic_template() { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{section}/{section}".to_string()); + slot.compile_unit_template() + .expect("should compile template"); + + let rendered = slot.render_gam_unit_path("99999", &"a".repeat(60)); + assert_eq!( - slot.resolved_gam_unit_path("21765378893"), - "/21765378893/atf" + rendered, None, + "a compiled dynamic path over 100 bytes should be omitted" ); } #[test] - fn resolved_gam_unit_path_uses_override_when_set() { + fn render_gam_unit_path_omits_over_limit_raw_dynamic_template() { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{section}/{section}".to_string()); + assert!( + slot.compiled_unit.is_none(), + "test should exercise the raw parsing fallback" + ); + + let rendered = slot.render_gam_unit_path("99999", &"a".repeat(60)); + + assert_eq!( + rendered, None, + "a raw dynamic path over 100 bytes should be omitted" + ); + } + + #[test] + fn render_gam_unit_path_accepts_exact_multibyte_byte_limit() { + let expected = format!("{}ax", "é".repeat(49)); + assert_eq!( + expected.len(), + 100, + "test fixture should render to exactly 100 UTF-8 bytes" + ); + + for compile_template in [true, false] { + let cache_kind = if compile_template { "compiled" } else { "raw" }; + let mut slot = make_slot("x", vec!["/"]); + slot.gam_unit_path = Some(format!("{}a{{slot_id}}", "é".repeat(49))); + if compile_template { + slot.compile_unit_template() + .expect("should compile multibyte template"); + } + assert_eq!( + slot.compiled_unit.is_some(), + compile_template, + "test should exercise the {cache_kind} template path" + ); + + let rendered = slot.render_gam_unit_path("unused", "unused"); + + assert_eq!( + rendered, + Some(expected.clone()), + "{cache_kind} dynamic template should accept exactly 100 UTF-8 bytes" + ); + } + } + + #[test] + fn render_gam_unit_path_rejects_multibyte_byte_limit_plus_one() { + let hypothetical_render = format!("{}abx", "é".repeat(49)); + assert_eq!( + hypothetical_render.len(), + 101, + "test fixture should render to 101 UTF-8 bytes" + ); + assert!( + hypothetical_render.chars().count() < 100, + "fixture should fail if rendering counts characters instead of UTF-8 bytes" + ); + + for compile_template in [true, false] { + let cache_kind = if compile_template { "compiled" } else { "raw" }; + let mut slot = make_slot("x", vec!["/"]); + slot.gam_unit_path = Some(format!("{}ab{{slot_id}}", "é".repeat(49))); + if compile_template { + slot.compile_unit_template() + .expect("should compile multibyte template"); + } + assert_eq!( + slot.compiled_unit.is_some(), + compile_template, + "test should exercise the {cache_kind} template path" + ); + + let rendered = slot.render_gam_unit_path("unused", "unused"); + + assert_eq!( + rendered, None, + "{cache_kind} dynamic template should reject 101 UTF-8 bytes" + ); + } + } + + #[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"), + Some("/99999/sidebar".to_string()), + "an absent template should retain the default path behavior" + ); + } + + #[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"), + Some("/99999/example/homepage".to_string()) ); } #[test] - fn resolved_div_id_defaults_to_slot_id() { - let slot = make_slot("atf", vec!["/"]); - assert_eq!(slot.resolved_div_id(), "atf"); + fn render_gam_unit_path_preserves_over_limit_static_template() { + let static_path = format!("/{}", "a".repeat(100)); + let mut slot = make_slot("atf", vec!["/"]); + slot.gam_unit_path = Some(static_path.clone()); + slot.compile_unit_template() + .expect("should compile static template"); + + let rendered = slot.render_gam_unit_path("99999", "news"); + + assert_eq!( + rendered, + Some(static_path), + "an explicit static path should retain pre-template behavior" + ); + } + + #[test] + 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 validate_runtime_rejects_dynamic_template_over_limit_with_configured_root() { + let root = "a".repeat(60); + let mut config = make_config_with_section_template(Some(&root)); + config.slot[0].gam_unit_path = Some("/{section}/{section}".to_string()); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + + let err = config + .validate_runtime() + .expect_err("should reject a configured dynamic path over 100 bytes"); + + assert!( + err.contains("ad-header-0"), + "error should identify the over-limit slot, got: {err}" + ); + assert!( + err.contains("100"), + "error should identify the dynamic path byte limit, got: {err}" + ); + } + + #[test] + fn render_gam_unit_path_honours_raw_template_without_compiled_cache() { + // A slot deserialized straight from JSON (or built by a test helper) + // never ran `compile_unit_templates`. It must still render its explicit + // path — dropping to `//` would bid the wrong inventory. + let slot: CreativeOpportunitySlot = serde_json::from_value(serde_json::json!({ + "id": "ad-header-0", + "gam_unit_path": "/{network_id}/example/{section}", + "page_patterns": ["/news/*"], + "formats": [{ "width": 728, "height": 90 }], + })) + .expect("should deserialize slot"); + assert!( + slot.compiled_unit.is_none(), + "direct deserialization should leave the template cache empty" + ); + assert_eq!( + slot.render_gam_unit_path("99999", "news"), + Some("/99999/example/news".to_string()), + "uncompiled slot should still substitute placeholders" + ); + } + + #[test] + fn render_gam_unit_path_honours_static_path_without_compiled_cache() { + let mut slot = make_slot("atf", vec!["/"]); + slot.gam_unit_path = Some("/99999/example/homepage".to_string()); + assert_eq!( + slot.render_gam_unit_path("99999", "news"), + Some("/99999/example/homepage".to_string()), + "uncompiled static path should render verbatim, not the default" + ); + } + + #[test] + fn render_gam_unit_path_preserves_malformed_raw_template() { + let mut slot = make_slot("atf", vec!["/"]); + slot.gam_unit_path = Some("/{unknown}".to_string()); + + let rendered = slot.render_gam_unit_path("99999", "news"); + + assert_eq!( + rendered, + Some("/{unknown}".to_string()), + "a malformed raw template should retain direct-caller compatibility" + ); + } + + #[test] + fn validate_runtime_requires_section_root_for_uncompiled_template() { + // `template_uses_section` must read the raw template, otherwise an + // uncompiled config silently skips the section_root requirement. + let mut config = make_config_with_section_template(None); + config.compile_slots(); + assert!( + config.slot[0].compiled_unit.is_none(), + "test precondition: template cache is empty" + ); + let err = config + .validate_runtime() + .expect_err("should require section_root even without compiled templates"); + assert!( + err.contains("section_root"), + "error should mention section_root" + ); + } + + #[test] + fn validate_runtime_allows_blank_network_id_with_static_paths() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/12345/example/homepage".to_string()); + config.gam_network_id = " ".to_string(); + config.compile_slots(); + config + .compile_unit_templates() + .expect("should compile static template"); + config + .validate_runtime() + .expect("should allow a blank unused network id"); + } + + #[test] + fn validate_runtime_allows_blank_network_id_with_slot_id_template() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/example/{slot_id}".to_string()); + config.gam_network_id = String::new(); + config.compile_slots(); + config + .compile_unit_templates() + .expect("should compile slot-id template"); + config + .validate_runtime() + .expect("should allow a blank unused network id"); + } + + #[test] + fn validate_runtime_rejects_blank_network_id_when_default_path_uses_it() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = None; + config.gam_network_id = String::new(); + config.compile_slots(); + config + .compile_unit_templates() + .expect("should compile default path"); + let err = config + .validate_runtime() + .expect_err("blank network id should fail when the default path uses it"); + assert_eq!( + err, "gam_network_id must not be empty", + "should report the blank network id" + ); + } + + #[test] + fn validate_runtime_rejects_blank_network_id_when_compiled_template_uses_it() { + // `gam_unit_path = "{network_id}"` renders to an empty string with a + // blank network id, which reaches googletag.defineSlot as an invalid path. + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("{network_id}".to_string()); + config.gam_network_id = String::new(); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + let err = config + .validate_runtime() + .expect_err("blank gam_network_id should fail startup validation"); + assert!( + err.contains("gam_network_id"), + "error should name gam_network_id, got: {err}" + ); + } + + #[test] + fn validate_runtime_rejects_blank_network_id_when_raw_template_uses_it() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/{network_id}/example".to_string()); + config.gam_network_id = String::new(); + config.compile_slots(); + assert!( + config.slot[0].compiled_unit.is_none(), + "test precondition: template cache is empty" + ); + let err = config + .validate_runtime() + .expect_err("blank network id should fail when a raw template uses it"); + assert_eq!( + err, "gam_network_id must not be empty", + "should report the blank network id" + ); + } + + #[test] + fn validate_runtime_allows_blank_network_id_when_no_slots_configured() { + // An empty slot list disables the feature, so the id is never rendered. + // Failing startup there would break a deploy over an unused value. + let mut config = make_config_with_section_template(Some("home")); + config.gam_network_id = String::new(); + config.slot.clear(); + config + .validate_runtime() + .expect("a disabled creative_opportunities stack should not fail on a blank id"); + } + + #[test] + fn section_root_is_omitted_from_serialized_config_when_unset() { + // Older binaries deserialize this struct with `deny_unknown_fields`, so + // a pushed config blob must not carry `"section_root": null`. + let config = CreativeOpportunitiesConfig { + gam_network_id: "99999".to_string(), + auction_timeout_ms: None, + price_granularity: PriceGranularity::default(), + section_root: None, + section_segment: None, + slot: Vec::new(), + }; + let value = serde_json::to_value(&config).expect("should serialize config"); + assert!( + value.get("section_root").is_none(), + "unset section_root should not be serialized, got {value}" + ); + + let with_root = CreativeOpportunitiesConfig { + section_root: Some("home".to_string()), + ..config + }; + assert_eq!( + serde_json::to_value(&with_root) + .expect("should serialize config") + .get("section_root") + .and_then(serde_json::Value::as_str), + Some("home"), + "a set section_root should still round-trip" + ); + } + + #[test] + fn documented_page_patterns_match_and_render_their_documented_paths() { + // Mirrors the example in docs/guide/configuration.md. `/news/*` alone + // does NOT match `/news` (the glob needs the trailing separator), so the + // documented config must list the section landing pages explicitly. + let mut slot = make_slot( + "ad-header", + vec!["/", "/news", "/news/*", "/reviews", "/reviews/*"], + ); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_patterns(); + slot.compile_unit_template() + .expect("should compile template"); + let slots = vec![slot]; + + for (path, expected) in [ + ("/", "/123456789/example/home"), + ("/news", "/123456789/example/news"), + ("/news/article", "/123456789/example/news"), + ("/reviews/x", "/123456789/example/reviews"), + ] { + let matched = match_slots(&slots, path); + assert_eq!( + matched.len(), + 1, + "`{path}` should match the documented slot" + ); + assert_eq!( + matched[0].render_gam_unit_path("123456789", &derive_section(path, "home", 0)), + Some(expected.to_string()), + "`{path}` should render the documented unit path" + ); + } + } + + #[test] + fn bare_section_pattern_does_not_match_without_trailing_separator() { + // Guards the docs fix above: a `"/news/*"`-only config loses the section + // landing page entirely. + let mut slot = make_slot("ad-header", vec!["/news/*"]); + slot.compile_patterns(); + assert!( + !slot.matches_path("/news"), + "`/news/*` must not match `/news`" + ); + assert!( + slot.matches_path("/news/article"), + "`/news/*` should match descendants" + ); + } + + #[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 +1639,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 +1663,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 1cb366adf..d3410b4ed 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2632,11 +2632,13 @@ pub async fn handle_publisher_request( let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); - let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { - crate::creative_opportunities::match_slots(auction.slots, &request_path) - .into_iter() - .cloned() - .collect() + let matched_slots = if is_get { + settings + .creative_opportunities + .as_ref() + .map_or_else(Vec::new, |co_config| { + match_renderable_slots(auction.slots, co_config, &request_path) + }) } else { Vec::new() }; @@ -2919,7 +2921,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 }; @@ -3443,12 +3445,14 @@ pub(crate) fn build_empty_bids_script() -> String { /// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single /// 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( +/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit +/// path exceeds its rendering limit. +pub(crate) fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, -) -> serde_json::Value { - let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id); + section: &str, +) -> Option { + let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, section)?; let div_id = slot.resolved_div_id(); let formats: Vec = slot .formats @@ -3460,13 +3464,40 @@ fn build_slot_json( .iter() .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) .collect(); - serde_json::json!({ + Some(serde_json::json!({ "id": slot.id, "gam_unit_path": gam_path, "div_id": div_id, "formats": formats, "targeting": targeting, - }) + })) +} + +/// Match creative-opportunity slots and omit dynamic GAM paths that cannot be +/// rendered for this request before they can enter an auction. +fn match_renderable_slots( + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, +) -> Vec { + let section = co_config.section_for_path(request_path); + crate::creative_opportunities::match_slots(slots, request_path) + .into_iter() + .filter_map(|slot| { + if slot + .render_gam_unit_path(&co_config.gam_network_id, §ion) + .is_none() + { + log::warn!( + "Omitting slot `{}`: dynamic gam_unit_path exceeds the render limit for path `{}`", + slot.id, + request_path + ); + return None; + } + Some(slot.clone()) + }) + .collect() } /// Build the `tsjs.adSlots` `"); @@ -8125,6 +8161,91 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in script content"); } + #[test] + fn ad_slots_script_omits_only_over_limit_dynamic_slot() { + let mut over_limit = make_slot(); + over_limit.id = "over_limit_dynamic".to_string(); + over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); + over_limit + .compile_unit_template() + .expect("template should compile"); + let mut valid_static = make_slot(); + valid_static.id = "valid_static_sibling".to_string(); + valid_static.gam_unit_path = Some("/12345/example/static".to_string()); + let slots = vec![over_limit, valid_static]; + let config = make_config(); + let request_path = format!("/{}", "a".repeat(60)); + + let script = build_ad_slots_script(&slots, &config, &request_path); + + assert!( + !script.contains("over_limit_dynamic"), + "should omit the over-limit dynamic slot" + ); + assert!( + script.contains("valid_static_sibling"), + "should retain the valid static sibling" + ); + } + + #[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_section = config.section_for_path("/news/article-123"); + let news = crate::publisher::build_slot_json(&slot, &config, &news_section) + .expect("should render slot"); + assert_eq!( + news["gam_unit_path"], "/99999/example/news", + "section should derive from the first path segment" + ); + + let home_section = config.section_for_path("/"); + let home = crate::publisher::build_slot_json(&slot, &config, &home_section) + .expect("should render slot"); + assert_eq!( + home["gam_unit_path"], "/99999/example/homepage", + "root path should use section_root" + ); + } + + #[test] + fn build_slot_json_honours_configured_section_segment() { + // Locale-prefixed publisher: `/en/news/article` must resolve to the + // `news` unit, not `en`. + let mut config = make_config(); + config.gam_network_id = "99999".to_string(); + config.section_root = Some("homepage".to_string()); + config.section_segment = Some(1); + 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_section = config.section_for_path("/en/news/article-123"); + let news = crate::publisher::build_slot_json(&slot, &config, &news_section) + .expect("should render slot"); + assert_eq!( + news["gam_unit_path"], "/99999/example/news", + "section should derive from the configured segment index" + ); + + let locale_root_section = config.section_for_path("/en"); + let locale_root = + crate::publisher::build_slot_json(&slot, &config, &locale_root_section) + .expect("should render slot"); + assert_eq!( + locale_root["gam_unit_path"], "/99999/example/homepage", + "a path with no segment at the configured index should use section_root" + ); + } + #[test] fn bid_map_includes_nurl_and_burl() { let mut winning_bids = HashMap::new(); @@ -9222,6 +9343,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } @@ -9585,6 +9707,46 @@ mod tests { ); } + #[tokio::test] + async fn page_bids_omits_only_over_limit_dynamic_slot() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut over_limit = article_slot() + .into_iter() + .next() + .expect("should build over-limit slot"); + over_limit.id = "over_limit_dynamic".to_string(); + over_limit.page_patterns = vec!["/*".to_string()]; + over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); + over_limit + .compile_unit_template() + .expect("template should compile"); + let mut valid_static = article_slot() + .into_iter() + .next() + .expect("should build valid static slot"); + valid_static.id = "valid_static_sibling".to_string(); + valid_static.page_patterns = vec!["/*".to_string()]; + valid_static.gam_unit_path = Some("/12345/example/static".to_string()); + let slots = vec![over_limit, valid_static]; + let request_path = format!("/{}", "a".repeat(60)); + let mut req = make_page_bids_request(&request_path); + set_test_header(&mut req, "sec-purpose", "prefetch"); + + let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + let returned_slots = body["slots"].as_array().expect("slots should be array"); + + assert_eq!( + returned_slots.len(), + 1, + "should omit only the over-limit dynamic slot" + ); + assert_eq!( + returned_slots[0]["id"], "valid_static_sibling", + "should retain the valid static sibling" + ); + } + #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { // Slots exist but request path does not match — no auction, no injection. @@ -9864,9 +10026,49 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } + fn slots_with_over_limit_dynamic_sibling() -> Vec { + let mut over_limit = article_slot() + .into_iter() + .next() + .expect("should build over-limit slot"); + over_limit.id = "over_limit_dynamic".to_string(); + over_limit.page_patterns = vec!["/*".to_string()]; + over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); + over_limit + .compile_unit_template() + .expect("should compile dynamic GAM unit template"); + + let mut valid_static = article_slot() + .into_iter() + .next() + .expect("should build valid static slot"); + valid_static.id = "valid_static_sibling".to_string(); + valid_static.page_patterns = vec!["/*".to_string()]; + valid_static.gam_unit_path = Some("/12345/example/static".to_string()); + + vec![over_limit, valid_static] + } + + fn assert_only_renderable_slot_was_auctioned( + captured: &Arc>>, + ) { + let request = captured + .lock() + .expect("should lock captured request") + .clone() + .expect("should dispatch an auction request"); + let slot_ids: Vec<_> = request.slots.iter().map(|slot| slot.id.as_str()).collect(); + assert_eq!( + slot_ids, + ["valid_static_sibling"], + "auction request should exclude the over-limit dynamic slot" + ); + } + /// [`EcContext`] whose consent context permits the server-side auction. fn consent_allowing_ec_context() -> EcContext { let consent = crate::consent::ConsentContext { @@ -10041,5 +10243,90 @@ mod tests { assert_configured_domain(&captured, &telemetry_sink); } + + #[tokio::test] + async fn initial_navigation_auctions_only_renderable_slots() { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + telemetry_sink, + ); + let mut ec_context = consent_allowing_ec_context(); + let request_path = format!("/{}", "a".repeat(60)); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}{request_path}")) + .header(header::HOST, EDGE_HOST) + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + let slots = slots_with_over_limit_dynamic_sibling(); + + let _ = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + req, + ) + .await + .expect("should proxy publisher request"); + + assert_only_renderable_slot_was_auctioned(&captured); + } + + #[tokio::test] + async fn page_bids_auctions_only_renderable_slots() { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with( + Arc::new(crate::platform::test_support::NoopHttpClient), + telemetry_sink, + ); + let ec_context = consent_allowing_ec_context(); + let request_path = format!("/{}", "a".repeat(60)); + let mut req = HttpRequest::builder() + .method(Method::GET) + .uri(format!( + "https://{EDGE_HOST}/_ts/page-bids?path={request_path}" + )) + .header(header::HOST, EDGE_HOST) + .body(EdgeBody::empty()) + .expect("should build test request"); + req.headers_mut().insert( + header::HeaderName::from_static("sec-fetch-site"), + HeaderValue::from_static("same-origin"), + ); + let slots = slots_with_over_limit_dynamic_sibling(); + + let _ = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_only_renderable_slot_was_auctioned(&captured); + } } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index e193a70aa..147da724a 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2062,6 +2062,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. @@ -4991,6 +4998,13 @@ passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] gam_network_id = "21765378893" auction_timeout_ms = 500 +section_root = "home" + +[[creative_opportunities.slot]] +id = "atf" +gam_unit_path = "/{network_id}/example/{section}" +page_patterns = ["/"] +formats = [{ width = 300, height = 250 }] "#; let settings = Settings::from_toml(toml).expect("should parse"); let co = settings @@ -4998,6 +5012,11 @@ auction_timeout_ms = 500 .expect("should have creative_opportunities"); assert_eq!(co.gam_network_id, "21765378893"); assert_eq!(co.auction_timeout_ms, Some(500)); + assert_eq!( + co.section_segment, + Some(0), + "startup finalization should materialize the dynamic-template compatibility marker" + ); } #[test] @@ -5171,7 +5190,25 @@ 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", + ); + } + + #[test] + fn settings_rejects_dynamic_gam_unit_path_over_byte_limit_using_configured_values() { + let gam_unit_path = "{network_id}".repeat(10); + let slot_body = format!( + r#" +id = "atf" +gam_unit_path = "{gam_unit_path}" +page_patterns = ["/"] +formats = [{{ width = 300, height = 250 }}] +"# + ); + + assert_creative_opportunity_slot_config_rejected( + &slot_body, + "must render to at most 100 UTF-8 bytes", ); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 018ba1271..d0685d1c8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1304,6 +1304,129 @@ 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" +# Which path segment names the section, 0-based. Default 0 (first segment). +# Set to 1 for locale-prefixed URLs such as "/en/news/article". +# section_segment = 0 + +[[creative_opportunities.slot]] +id = "ad-header" +gam_unit_path = "/{network_id}/example/{section}" +# List each section landing page as well as its subtree: `/news/*` matches +# `/news/article` but NOT `/news` — the glob requires the trailing separator. +page_patterns = ["/", "/news", "/news/*", "/reviews", "/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}` | non-empty path segment at `section_segment` (default: first; see 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. + +Trusted Server conservatively caps the whole rendered dynamic path at 100 UTF-8 +bytes, informed by Google's [100-character per-ad-unit-code +limit](https://support.google.com/admanager/answer/1628457?hl=en). If a +request-specific substitution would exceed the dynamic limit, only that slot is +omitted before auction dispatch; the response itself still succeeds. Trusted +Server logs a warning containing the slot ID and request path. Explicit static +paths and absent/default paths retain legacy behavior and are not subject to this +dynamic-only limit. + +### `{section}` derivation + +`{section}` is derived from the request path at request time: + +- It is the non-empty path segment at `section_segment` (0-based, default `0`). + With the default, `/news/article-123` → `news`. A site that prefixes a locale + sets `section_segment = 1`, so `/en/news/article` → `news` rather than `en`. +- It is sanitized: each run of characters outside `[A-Za-z0-9_-]` becomes a + single `_`, and the request-derived result is capped at 100 ASCII bytes. +- Casing is preserved. [Google documents GAM ad-unit codes as + case-insensitive](https://support.google.com/admanager/answer/10477476?hl=en), + so do not lowercase the value. +- 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 at that index — the site root (`/`, or repeated + slashes), or a path shorter than `section_segment` — `{section}` is + `section_root`. So with `section_segment = 1`, the path `/en` renders the root + section rather than reusing the locale. + +`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. Startup fails if `{section}` is used without a valid +`section_root`. Startup rejects a blank `gam_network_id` only when an absent +path/default or a `{network_id}` template consumes it; static paths and +templates without `{network_id}` do not consume it. A +`[creative_opportunities]` block with no slots is disabled, so its +`gam_network_id` is not checked. + +Both knobs are config-driven, so the URL→section convention stays with the +publisher: `section_segment` selects which segment names the section, and +`section_root` names the section when there is none. + +During typed/startup finalization, after templates parse successfully, every +placeholder-bearing dynamic template that omits `section_segment` has +`section_segment = 0` materialized, so an older binary rejects the pushed blob +loudly. Static and absent paths remain compatible with the legacy config schema +only when both `section_root` and `section_segment` are omitted. Before rolling +back below this feature, replace or remove dynamic paths, remove both +`section_root` and `section_segment`, re-push and finalize the config, then +roll back the binary. + +Example resolution for `gam_unit_path = "/{network_id}/example/{section}"` with +`gam_network_id = "123456789"`, `section_root = "home"`, and the +`page_patterns` shown above: + +| Request path | `gam_unit_path` | +| --------------- | ---------------------------- | +| `/` | `/123456789/example/home` | +| `/news` | `/123456789/example/news` | +| `/news/article` | `/123456789/example/news` | +| `/reviews/x` | `/123456789/example/reviews` | + +The same config with `section_segment = 1` and locale-prefixed patterns +(`["/en", "/en/news", "/en/news/*"]`): + +| Request path | `gam_unit_path` | +| ------------------ | ------------------------- | +| `/en` | `/123456789/example/home` | +| `/en/news` | `/123456789/example/news` | +| `/en/news/article` | `/123456789/example/news` | + +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..d124b38fd --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md @@ -0,0 +1,219 @@ +# 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 lives in config, not core — honoring the issue's +constraint that the URL→section convention is publisher-specific: +`section_segment` selects which path segment names the section, and a required +`section_root` supplies the value when the path has no such segment. + +**Revision (2026-07-29, PR review):** `section_segment` was originally listed as +a non-goal (below) with first-segment derivation hardcoded. Review found that +this left the convention only half configurable, so `section_segment` was pulled +into scope. + +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**~~ — **now in scope.** Deriving `{section}` from a segment + other than the first (e.g. `/en/news` → `news`) is configured with + `section_segment` (0-based, default `0`). +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} +section_segment = 0 # which segment names the section (default 0) + +[[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}` | segment at `section_segment`; `section_root` when absent | + +### 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 = non-empty segment #section_segment of the RAW path, + runs of [^A-Za-z0-9_-] replaced with a single '_'; + section_root when the path has no segment at that index + ("/", repeated slashes, or a path shorter than the index) + render template + else: + "/{network_id}/{slot_id}" # existing default (back-compat) +``` + +### Section derivation rules (deterministic) + +- Extract the non-empty path segment at `section_segment` (0-based, default + `0`), counting only non-empty segments. +- 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 at that index (`/`, + repeated slashes, or a path with fewer segments than the index). +- 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 both publisher-specific knobs (`section_segment`, + `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 939fccff5..e78d2b255 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -165,7 +165,41 @@ 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} -> a path segment of the request, sanitized to [A-Za-z0-9_-]; +# `section_segment` picks which one, `section_root` covers +# paths that have no such segment. +# 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_segment` is the 0-based index of the segment that names the section; +# it defaults to 0 (the first segment). Set it to 1 for locale-prefixed URLs, so +# "/en/news/article" resolves to "news" instead of "en". +# +# Both are left commented out: no slot below uses {section}, and an unused key +# still ships in the pushed config blob. +# section_root = "home" +# section_segment = 0 + # 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). Uncomment +# `section_root` above when enabling it. Note that "/news/*" does not match +# "/news" — list the section landing page separately. +# [[creative_opportunities.slot]] +# id = "ad-header" +# gam_unit_path = "/{network_id}/example/{section}" +# page_patterns = ["/", "/news", "/news/*", "/reviews", "/reviews/*"] +# formats = [{ width = 728, height = 90 }] +# "/" -> /123456789/example/home +# "/news" -> /123456789/example/news +# "/news/x" -> /123456789/example/news +# "/reviews/y" -> /123456789/example/reviews