diff --git a/CHANGELOG.md b/CHANGELOG.md
index 101087b8d..ce3904fea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- `rewrite.exclude_domains` matching is now case-insensitive and ignores surrounding whitespace. Entries written with uppercase letters previously never matched and now take effect, so those hosts stop being proxied and click-wrapped, and `/first-party/sign` now rejects them with `502`; it used to sign them, or return `403` when the host was also outside `proxy.allowed_domains`. Empty and bare `"*"` `exclude_domains` entries, which never matched a host, are dropped at load with a warning. Absolute `http(s)` creative URLs that cannot be parsed (for example, a host containing a space) are now left untouched; previously the attribute was re-quoted and a link also gained `data-tsclick`. Audit `exclude_domains` for mixed-case entries before upgrading.
- `[auction].allowed_context_keys` now serializes in sorted, deduplicated order, so ESI template-cache fingerprints and `ts config diff`/`push` envelope hashes are stable across loads. Template fingerprints also sort object keys independently of `serde_json/preserve_order`. Existing envelopes may show a one-time allowlist reorder after upgrading; push once to settle it. The updated fingerprint format causes one template-cache miss per cached page after deployment.
- TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide.
- Protocol-relative creative URLs now honor `rewrite.exclude_domains`, so excluded creative assets stay direct and excluded absolute or protocol-relative URLs submitted to `/first-party/sign` are rejected.
@@ -40,7 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery.
+- Added `[auction].rewrite_clicks` to control creative click-through wrapping (``/`` → signed `/first-party/click`) independently of asset rewriting. Unset (the default) follows `rewrite_creatives` for `POST /auction` and SSAT/page-bids and keeps wrapping links in HTML fetched through `/first-party/proxy`, so existing configs behave as before; an explicit value applies to every path. `rewrite_creatives` now governs asset URLs only; bidder `` removal and creative TSJS injection run when either setting is on. Upgrading: deploy the binary first, then push a config that sets `rewrite_clicks`. Rolling back: remove any explicit `rewrite_clicks` (and its environment override), push the resulting config, then roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` in both directions, so rolling back turns clicks back on for `rewrite_creatives = true` with `rewrite_clicks = false`, and off for `rewrite_creatives = false` with `rewrite_clicks = true`.
+- Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (asset URL conversion to `/first-party/proxy`, bidder `` removal; creative TSJS injection on `POST /auction` only). Click-through wrapping is controlled by `[auction].rewrite_clicks`, which follows `rewrite_creatives` when unset. Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery.
- `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 opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape.
- Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint.
diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs
index d81b0366a..06cc3c058 100644
--- a/crates/trusted-server-cli/tests/config_env_overlay.rs
+++ b/crates/trusted-server-cli/tests/config_env_overlay.rs
@@ -29,6 +29,7 @@ ids = ["trusted_server_secrets"]
"#;
const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES";
const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES";
+const CLICKS_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CLICKS";
const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED";
const AD_TEMPLATES_ENABLED_ENV: &str = "TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__ENABLED";
const PROVIDER_ENDPOINT_ENV: &str = "TRUSTED_SERVER__AUCTION__PROVIDERS__PBS-MAIN__ENDPOINT";
@@ -82,6 +83,76 @@ fn validate_with_overlay(project: &MigratedProject, raw_value: &str) -> Output {
.expect("should run ts config validate")
}
+fn pushed_auction_with_env(
+ project: &MigratedProject,
+ key: &str,
+ raw_value: &str,
+) -> serde_json::Value {
+ let output = Command::new(env!("CARGO_BIN_EXE_ts"))
+ .args(["config", "push", "--adapter", "axum", "--manifest"])
+ .arg(&project.manifest_path)
+ .arg("--app-config")
+ .arg(&project.config_path)
+ .args(["--yes", "--no-diff"])
+ .current_dir(project.directory.path())
+ .env(key, raw_value)
+ .output()
+ .expect("should run ts config push");
+ assert!(
+ output.status.success(),
+ "config push should succeed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let local_store_path = project
+ .directory
+ .path()
+ .join(".edgezero/local-config-trusted_server_config.json");
+ let local_store: serde_json::Value = serde_json::from_str(
+ &fs::read_to_string(local_store_path).expect("should read pushed local config"),
+ )
+ .expect("should parse local config store");
+ let envelope_json = local_store
+ .as_object()
+ .and_then(|entries| entries.values().next())
+ .and_then(serde_json::Value::as_str)
+ .expect("should contain a blob envelope");
+ let envelope: serde_json::Value =
+ serde_json::from_str(envelope_json).expect("should parse blob envelope");
+ envelope["data"]["auction"].clone()
+}
+
+#[test]
+fn rewrite_clicks_environment_override_applies_when_leaf_present() {
+ let project = migrated_project();
+ let mut document = fs::read_to_string(&project.config_path)
+ .expect("should read migrated config")
+ .parse::()
+ .expect("should parse migrated config");
+ document["auction"]["rewrite_clicks"] = value(true);
+ fs::write(&project.config_path, document.to_string()).expect("should write config");
+
+ let auction = pushed_auction_with_env(&project, CLICKS_ENV, "false");
+
+ assert_eq!(
+ auction["rewrite_clicks"],
+ serde_json::Value::Bool(false),
+ "pushed config should contain the rewrite_clicks environment override"
+ );
+}
+
+#[test]
+fn rewrite_clicks_environment_override_is_ignored_without_leaf() {
+ let project = migrated_project();
+
+ let auction = pushed_auction_with_env(&project, CLICKS_ENV, "false");
+
+ assert!(
+ auction.get("rewrite_clicks").is_none(),
+ "an override for a missing leaf should be ignored and the unset default omitted"
+ );
+}
+
#[test]
fn config_validate_explains_legacy_provider_list_migration() {
let project = migrated_project();
diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md
index 8a745eabc..7b0ae585b 100644
--- a/crates/trusted-server-core/src/auction/README.md
+++ b/crates/trusted-server-core/src/auction/README.md
@@ -108,8 +108,10 @@ as `pbs-main`.
response.
- `sanitize_creatives = true` strips executable markup. It is opt-in.
-- `rewrite_creatives = true` rewrites eligible URLs through first-party routes
- and removes bidder `` elements. It is enabled by default.
+- `rewrite_creatives = true` rewrites eligible asset URLs through first-party
+ routes and removes bidder `` elements. It is enabled by default.
+- `rewrite_clicks` wraps creative links in `/first-party/click`. Unset, it
+ follows `rewrite_creatives`.
- The publisher inline delivery path uses absolute first-party URLs without
injecting the direct endpoint's creative runtime.
- Creatives over the configured hard cap are rejected.
diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs
index ab3585e3d..10600e945 100644
--- a/crates/trusted-server-core/src/auction/endpoints.rs
+++ b/crates/trusted-server-core/src/auction/endpoints.rs
@@ -84,10 +84,14 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024;
/// ## Response
///
/// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's
-/// `adm` field after mandatory server-side sanitization. First-party resource
-/// and click URL rewriting plus creative TSJS injection are enabled by default;
-/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`]
-/// to `false` skips only that rewrite pass.
+/// `adm` field after optional sanitization
+/// ([`auction.sanitize_creatives`][`crate::auction_config_types::AuctionConfig::sanitize_creatives`]).
+/// First-party asset rewriting
+/// ([`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`])
+/// and click wrapping
+/// ([`auction.rewrite_clicks`][`crate::auction_config_types::AuctionConfig::rewrite_clicks`])
+/// are enabled by default. Bidder `` removal and creative TSJS injection
+/// run when either is on.
///
/// ## Scroll, refresh, and SPA navigation
///
diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs
index a0df63d94..956e81e32 100644
--- a/crates/trusted-server-core/src/auction/formats.rs
+++ b/crates/trusted-server-core/src/auction/formats.rs
@@ -316,14 +316,16 @@ pub(crate) struct OpenRtbResponseConversion {
///
/// Creative HTML in the `adm` field is optionally sanitized and optionally
/// rewritten according to the auction configuration
-/// ([`AuctionConfig::sanitize_creatives`], opt-in, and
-/// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the
-/// creative ships exactly as the bidder returned it, subject to the 1 MiB
-/// per-creative cap. Typed renderers are serialized in the response extension
+/// ([`AuctionConfig::sanitize_creatives`], opt-in;
+/// [`AuctionConfig::rewrite_creatives`] for assets, default-on; and
+/// [`AuctionConfig::rewrite_clicks`] for links, following `rewrite_creatives`
+/// when unset); with all disabled the creative ships exactly as the bidder
+/// returned it, subject to the 1 MiB per-creative cap. Typed renderers are serialized in the response extension
/// instead of entering that pipeline at all.
///
/// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives
/// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives
+/// [`AuctionConfig::rewrite_clicks`]: crate::auction_config_types::AuctionConfig::rewrite_clicks
///
/// # Errors
///
@@ -373,9 +375,10 @@ pub(crate) fn convert_to_openrtb_response_with_report(
let height = to_openrtb_i32(bid.height, "height", &bid_context);
// Ordinary markup goes through the configured creative processing:
- // sanitization is opt-in, rewriting is on by default, and with both
- // disabled the creative ships exactly as the bidder returned it. A typed
- // renderer is serialized separately and never enters that pipeline.
+ // sanitization is opt-in, asset rewriting and click wrapping are on by
+ // default, and with all three disabled the creative ships exactly as
+ // the bidder returned it. A typed renderer is serialized separately and
+ // never enters that pipeline.
let serialize_renderer = |renderer: &BidRenderer| {
(BidExt {
trusted_server: BidTrustedServerExt { renderer },
@@ -398,12 +401,13 @@ pub(crate) fn convert_to_openrtb_response_with_report(
let processed = creative::process_auction_creative(settings, raw_creative);
log::debug!(
- "Processed creative for auction {} slot {} bidder {} (sanitize {}, rewrite {}, raw {} bytes, output {} bytes)",
+ "Processed creative for auction {} slot {} bidder {} (sanitize {}, rewrite {}, clicks {}, raw {} bytes, output {} bytes)",
auction_request.id,
slot_id,
bid.bidder,
settings.auction.sanitize_creatives,
rewrite_creatives,
+ settings.auction.rewrites_auction_clicks(),
raw_creative.len(),
processed.len()
);
@@ -1413,6 +1417,37 @@ mod tests {
);
}
+ #[test]
+ fn convert_to_openrtb_response_wraps_clicks_without_rewriting_assets() {
+ let mut settings = make_settings();
+ settings.auction.sanitize_creatives = false;
+ settings.auction.rewrite_creatives = false;
+ settings.auction.rewrite_clicks = Some(true);
+ let auction_request = make_auction_request();
+ let result = make_result(make_complete_creative_bid());
+
+ let response = convert_to_openrtb_response(&result, &settings, &auction_request, false)
+ .expect("should convert creative with click rewriting only");
+ let adm = response_adm(response);
+
+ assert!(
+ adm.contains("/first-party/click?tsurl=") && adm.contains("data-tsclick"),
+ "should wrap the landing link: {adm}"
+ );
+ assert!(
+ !adm.contains("/first-party/proxy?tsurl="),
+ "should not proxy any asset: {adm}"
+ );
+ assert!(
+ adm.contains(r#"src="https://cdn.example.com/ad.png""#),
+ "should keep the image URL direct: {adm}"
+ );
+ assert!(
+ adm.contains("tsjs-unified.min.js"),
+ "should inject the creative runtime for the click guard: {adm}"
+ );
+ }
+
#[test]
fn sanitize_creatives_defaults_to_disabled() {
let config = crate::auction_config_types::AuctionConfig::default();
diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs
index 204201e60..3d2044e32 100644
--- a/crates/trusted-server-core/src/auction/orchestrator.rs
+++ b/crates/trusted-server-core/src/auction/orchestrator.rs
@@ -4696,6 +4696,7 @@ mod tests {
enabled: true,
sanitize_creatives: true,
rewrite_creatives: true,
+ rewrite_clicks: None,
providers: AuctionConfig::legacy_provider_map(&[]),
bidders: Default::default(),
mediator: None,
diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs
index 56171e1ea..42aab07ef 100644
--- a/crates/trusted-server-core/src/auction_config_types.rs
+++ b/crates/trusted-server-core/src/auction_config_types.rs
@@ -36,8 +36,13 @@ pub struct AuctionConfig {
)]
pub sanitize_creatives: bool,
- /// Rewrite winning-bid creative HTML to first-party endpoints (applied
- /// after sanitization when [`Self::sanitize_creatives`] is enabled).
+ /// Rewrite winning-bid creative asset URLs (images, scripts, styles,
+ /// media, iframes, CSS `url()`) to first-party `/first-party/proxy`
+ /// endpoints, applied after sanitization when
+ /// [`Self::sanitize_creatives`] is enabled.
+ ///
+ /// Bidder `` removal and creative TSJS injection run whenever this
+ /// or click rewriting ([`Self::rewrites_auction_clicks`]) is on.
///
/// The default stays omitted from serialized config blobs to avoid adding
/// this field when it has no effect. Any rollback across schema versions
@@ -49,6 +54,18 @@ pub struct AuctionConfig {
)]
pub rewrite_creatives: bool,
+ /// Wrap creative click-through links (``, ``) in signed
+ /// `/first-party/click` redirects.
+ ///
+ /// Unset keeps each path's existing behavior: auction creatives follow
+ /// [`Self::rewrite_creatives`], and HTML fetched through
+ /// `/first-party/proxy` keeps wrapping. An explicit value applies to every
+ /// path. Unset is omitted from serialized config blobs, so older binaries
+ /// keep loading them; any explicit value is serialized and rejected by
+ /// binaries that predate this field.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub rewrite_clicks: Option,
+
/// Operator-defined bidder-provider instances, keyed by provider ID.
#[serde(default, deserialize_with = "deserialize_provider_map")]
pub providers: BTreeMap,
@@ -87,6 +104,7 @@ impl Default for AuctionConfig {
enabled: false,
sanitize_creatives: default_sanitize_creatives(),
rewrite_creatives: default_rewrite_creatives(),
+ rewrite_clicks: None,
providers: BTreeMap::new(),
bidders: BTreeMap::new(),
mediator: None,
@@ -160,6 +178,25 @@ fn default_allowed_context_keys() -> BTreeSet {
}
impl AuctionConfig {
+ /// Whether auction creatives (`POST /auction` and inline SSAT/page-bids)
+ /// wrap click-through links.
+ ///
+ /// Unset [`Self::rewrite_clicks`] follows [`Self::rewrite_creatives`].
+ #[must_use]
+ pub fn rewrites_auction_clicks(&self) -> bool {
+ self.rewrite_clicks.unwrap_or(self.rewrite_creatives)
+ }
+
+ /// Whether HTML fetched through `/first-party/proxy` wraps click-through
+ /// links.
+ ///
+ /// Unset [`Self::rewrite_clicks`] keeps wrapping, as before the setting
+ /// existed.
+ #[must_use]
+ pub fn rewrites_proxied_clicks(&self) -> bool {
+ self.rewrite_clicks.unwrap_or(true)
+ }
+
#[cfg(test)]
pub(crate) fn legacy_provider_map(names: &[&str]) -> BTreeMap {
names
@@ -255,6 +292,72 @@ mod tests {
);
}
+ #[test]
+ fn default_rewrite_clicks_is_unset_and_not_serialized() {
+ let config = AuctionConfig::default();
+
+ assert_eq!(
+ config.rewrite_clicks, None,
+ "should leave click rewriting unset by default"
+ );
+ let serialized = serde_json::to_value(&config).expect("should serialize defaults");
+ assert!(
+ serialized.get("rewrite_clicks").is_none(),
+ "should omit the unset click setting from serialized config"
+ );
+ }
+
+ #[test]
+ fn explicit_rewrite_clicks_is_serialized() {
+ for value in [true, false] {
+ let config = AuctionConfig {
+ rewrite_clicks: Some(value),
+ ..AuctionConfig::default()
+ };
+
+ let serialized =
+ serde_json::to_value(config).expect("should serialize explicit click setting");
+
+ assert_eq!(
+ serialized.get("rewrite_clicks"),
+ Some(&serde_json::Value::Bool(value)),
+ "should serialize explicit rewrite_clicks = {value}"
+ );
+ }
+ }
+
+ #[test]
+ fn click_rewriting_resolves_per_entry_point() {
+ // (rewrite_creatives, rewrite_clicks, auction clicks, proxied clicks)
+ let cases = [
+ (true, None, true, true),
+ (false, None, false, true),
+ (true, Some(false), false, false),
+ (false, Some(true), true, true),
+ (true, Some(true), true, true),
+ (false, Some(false), false, false),
+ ];
+
+ for (rewrite_creatives, rewrite_clicks, auction, proxied) in cases {
+ let config = AuctionConfig {
+ rewrite_creatives,
+ rewrite_clicks,
+ ..AuctionConfig::default()
+ };
+
+ assert_eq!(
+ config.rewrites_auction_clicks(),
+ auction,
+ "auction clicks for rewrite_creatives={rewrite_creatives} rewrite_clicks={rewrite_clicks:?}"
+ );
+ assert_eq!(
+ config.rewrites_proxied_clicks(),
+ proxied,
+ "proxied clicks for rewrite_creatives={rewrite_creatives} rewrite_clicks={rewrite_clicks:?}"
+ );
+ }
+ }
+
#[test]
fn default_sanitize_creatives_is_not_serialized() {
let serialized =
diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs
index c9690b01f..eee5d7ee8 100644
--- a/crates/trusted-server-core/src/config_payload.rs
+++ b/crates/trusted-server-core/src/config_payload.rs
@@ -749,6 +749,50 @@ mod tests {
);
}
+ #[test]
+ fn legacy_blob_without_rewrite_clicks_follows_rewrite_creatives() {
+ for rewrite_creatives in [true, false] {
+ let mut original = test_settings();
+ original.auction.rewrite_creatives = rewrite_creatives;
+ let data = serde_json::to_value(&original).expect("should serialize settings to JSON");
+ assert!(
+ data["auction"].get("rewrite_clicks").is_none(),
+ "should omit unset rewrite_clicks from the payload"
+ );
+
+ let reconstructed = load_settings(&envelope_json(&original))
+ .expect("should reconstruct settings without rewrite_clicks");
+
+ assert_eq!(
+ reconstructed.auction.rewrite_clicks, None,
+ "should load a blob without rewrite_clicks as unset"
+ );
+ assert_eq!(
+ reconstructed.auction.rewrites_auction_clicks(),
+ rewrite_creatives,
+ "should follow rewrite_creatives = {rewrite_creatives} when unset"
+ );
+ }
+ }
+
+ #[test]
+ fn explicit_rewrite_clicks_survives_blob_round_trip() {
+ for (rewrite_creatives, rewrite_clicks) in [(true, false), (false, true)] {
+ let mut original = test_settings();
+ original.auction.rewrite_creatives = rewrite_creatives;
+ original.auction.rewrite_clicks = Some(rewrite_clicks);
+
+ let reconstructed = load_settings(&envelope_json(&original))
+ .expect("should reconstruct explicit rewrite_clicks");
+
+ assert_eq!(
+ reconstructed.auction.rewrite_clicks,
+ Some(rewrite_clicks),
+ "should preserve rewrite_clicks = {rewrite_clicks} with rewrite_creatives = {rewrite_creatives}"
+ );
+ }
+ }
+
#[test]
fn strings_that_look_like_json_scalars_round_trip_as_strings() {
let mut original = test_settings();
diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs
index 578d86d68..074e5f35d 100644
--- a/crates/trusted-server-core/src/creative.rs
+++ b/crates/trusted-server-core/src/creative.rs
@@ -3,10 +3,11 @@
//! Goals:
//! - Normalize external asset fetches in ad creatives (HTML/CSS) to a single
//! first-party proxy endpoint so the publisher can control egress.
+//! - Route click-through links through a signed first-party click redirect.
//! - Leave relative URLs and non-network schemes untouched.
//!
//! Key behaviors:
-//! - Absolute and protocol-relative URLs (http/https or `//`) are proxied to
+//! - Absolute and protocol-relative asset URLs (http/https or `//`) are proxied to
//! `/first-party/proxy?tsurl=&&tstoken=` across these locations:
//! - `
`, `data-src`, `[srcset]`, `[imagesrcset]`
//! - `
AdExcludedMail