Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `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 now also rejects a blank `gam_network_id` when slots are configured. Note that a config setting `section_root` or `section_segment` requires a binary that knows those keys — rolling the binary back below this release while the keys are present fails config load; configs that omit them roll back cleanly.
- 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
Expand Down
736 changes: 702 additions & 34 deletions crates/trusted-server-core/src/creative_opportunities.rs

Large diffs are not rendered by default.

74 changes: 67 additions & 7 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2857,7 +2857,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
};
Expand Down Expand Up @@ -3335,11 +3335,16 @@ pub(crate) fn build_empty_bids_script() -> String {
/// definition and the two paths cannot silently diverge. Property names match
/// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`,
/// `formats`, and `targeting`.
fn build_slot_json(
pub(crate) fn build_slot_json(
slot: &crate::creative_opportunities::CreativeOpportunitySlot,
co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig,
request_path: &str,
) -> serde_json::Value {
let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id);
// `{section}` derives from the same raw path `page_patterns` matched
// against; `section_root` covers the no-segment case (`/`) and paths shorter
// than the configured `section_segment`.
let section = co_config.section_for_path(request_path);
let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, &section);
let div_id = slot.resolved_div_id();
let formats: Vec<serde_json::Value> = slot
.formats
Expand Down Expand Up @@ -3367,10 +3372,11 @@ fn build_slot_json(
pub(crate) fn build_ad_slots_script(
matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot],
co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig,
request_path: &str,
) -> String {
let slots: Vec<serde_json::Value> = matched_slots
.iter()
.map(|slot| build_slot_json(slot, co_config))
.map(|slot| build_slot_json(slot, co_config, request_path))
.collect();
let json = serde_json::to_string(&slots)
.expect("serde_json::to_string of Vec<Value> should be infallible");
Expand Down Expand Up @@ -3786,7 +3792,7 @@ pub async fn handle_page_bids(
let slots_json: Vec<serde_json::Value> = if ad_stack_enabled {
matched_slots
.iter()
.map(|slot| build_slot_json(slot, co_config))
.map(|slot| build_slot_json(slot, co_config, &path_param))
.collect()
} else {
Vec::new()
Expand Down Expand Up @@ -7197,6 +7203,8 @@ mod tests {
gam_network_id: "21765378893".to_string(),
auction_timeout_ms: Some(500),
price_granularity: PriceGranularity::Dense,
section_root: None,
section_segment: None,
slot: Vec::new(),
}
}
Expand All @@ -7218,6 +7226,7 @@ mod tests {
.collect(),
providers: Default::default(),
compiled_patterns: Vec::new(),
compiled_unit: None,
}
}

Expand Down Expand Up @@ -7252,7 +7261,7 @@ mod tests {
fn ad_slots_script_contains_slot_data() {
let slots = vec![make_slot()];
let config = make_config();
let script = build_ad_slots_script(&slots, &config);
let script = build_ad_slots_script(&slots, &config, "/");
assert!(
script.contains("window.tsjs=window.tsjs||{}"),
"should initialise tsjs namespace"
Expand All @@ -7273,14 +7282,63 @@ mod tests {
fn ad_slots_script_is_xss_safe() {
let slots = vec![make_slot()];
let config = make_config();
let script = build_ad_slots_script(&slots, &config);
let script = build_ad_slots_script(&slots, &config, "/");
let inner = script
.trim_start_matches("<script>")
.trim_end_matches("</script>");
assert!(!inner.contains('<'), "no unescaped < in script content");
assert!(!inner.contains('>'), "no unescaped > in script content");
}

#[test]
fn build_slot_json_renders_section_from_request_path() {
let mut config = make_config();
config.gam_network_id = "99999".to_string();
config.section_root = Some("homepage".to_string());
let mut slot = make_slot();
slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string());
slot.compile_unit_template()
.expect("template should compile");

let news = crate::publisher::build_slot_json(&slot, &config, "/news/article-123");
assert_eq!(
news["gam_unit_path"], "/99999/example/news",
"section should derive from the first path segment"
);

let home = crate::publisher::build_slot_json(&slot, &config, "/");
assert_eq!(
home["gam_unit_path"], "/99999/example/homepage",
"root path should use section_root"
);
}

#[test]
fn 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 = crate::publisher::build_slot_json(&slot, &config, "/en/news/article-123");
assert_eq!(
news["gam_unit_path"], "/99999/example/news",
"section should derive from the configured segment index"
);

let locale_root = crate::publisher::build_slot_json(&slot, &config, "/en");
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();
Expand Down Expand Up @@ -8273,6 +8331,7 @@ mod tests {
targeting: Default::default(),
providers: Default::default(),
compiled_patterns: Vec::new(),
compiled_unit: None,
}]
}

Expand Down Expand Up @@ -8915,6 +8974,7 @@ mod tests {
targeting: Default::default(),
providers: Default::default(),
compiled_patterns: Vec::new(),
compiled_unit: None,
}]
}

Expand Down
9 changes: 8 additions & 1 deletion crates/trusted-server-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2056,6 +2056,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.
Expand Down Expand Up @@ -5130,7 +5137,7 @@ gam_unit_path = ""
page_patterns = ["/"]
formats = [{ width = 300, height = 250 }]
"#,
"resolved GAM unit path must not be empty",
"gam_unit_path template must not be empty",
);
}

Expand Down
105 changes: 105 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,111 @@ 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}` | first path segment of the request (see derivation rules below) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 P3 — Documentation still contains the pre-section_segment description

This table says {section} is always the first path segment, while the implementation and the following section use the configured segment. Related rustdoc in creative_opportunities.rs also describes outdated first-segment and compiled-only behavior.

Suggested change
| `{section}` | first path segment of the request (see derivation rules below) |
| `{section}` | configured non-empty path segment (see derivation rules below) |

Please also update the section_root field docs and clarify that raw-template fallback enforces the section_root requirement even without the cache, while compilation remains required to reject malformed templates.


A template with **no** placeholders is used verbatim. A slot with **no**
`gam_unit_path` falls back to `/<network_id>/<slot_id>`. Both preserve the
pre-templating behavior, so existing static configs are unchanged.

### `{section}` derivation

`{section}` is derived from the request path at request time:

- It is the 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 `_`.
- 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`, and also if `gam_network_id` is blank while any slot is
configured (a template referencing `{network_id}` would otherwise render an
unusable path). 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.

Leave both keys out when no template uses `{section}`. The pushed config blob
carries only the keys your config sets, and a binary older than these keys
rejects a blob containing them — so an unused key would block a rollback.

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
Expand Down
Loading
Loading