Skip to content
Draft
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
187 changes: 185 additions & 2 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@ pub struct PrebidIntegrationConfig {
/// manages both lists explicitly.
#[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")]
pub client_side_bidders: Vec<String>,
/// GAM ad-unit-path suffixes excluded from Trusted Server refresh auctions.
///
/// Matching is exact and case-sensitive. Excluded slots still refresh through
/// GAM, but are not included in synthetic Prebid refresh ad units.
#[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")]
#[validate(custom(function = "validate_excluded_gam_ad_unit_path_suffixes"))]
pub excluded_gam_ad_unit_path_suffixes: Vec<String>,
/// Compatibility sugar for per-bidder, per-zone param overrides.
///
/// This preserves the natural `bidder -> zone -> params` config shape for
Expand Down Expand Up @@ -348,6 +355,58 @@ impl IntegrationConfig for PrebidIntegrationConfig {
}
}

fn excluded_gam_ad_unit_path_suffix_validation_error(message: &'static str) -> ValidationError {
let mut error = ValidationError::new("invalid_gam_ad_unit_path_suffix");
error.message = Some(message.into());
error
}

fn validate_excluded_gam_ad_unit_path_suffix(value: &str) -> Result<(), ValidationError> {
if value.trim() != value {
return Err(excluded_gam_ad_unit_path_suffix_validation_error(
"excluded_gam_ad_unit_path_suffixes entries must not have surrounding whitespace",
));
}

if value.is_empty() {
return Err(excluded_gam_ad_unit_path_suffix_validation_error(
"excluded_gam_ad_unit_path_suffixes entries must not be empty",
));
}

if !value.starts_with('/') {
return Err(excluded_gam_ad_unit_path_suffix_validation_error(
"excluded_gam_ad_unit_path_suffixes entries must start with '/'",
));
}

if value == "/" {
return Err(excluded_gam_ad_unit_path_suffix_validation_error(
"excluded_gam_ad_unit_path_suffixes entries must identify a non-root path suffix",
));
}

Ok(())
}

fn validate_excluded_gam_ad_unit_path_suffixes(values: &[String]) -> Result<(), ValidationError> {
for value in values {
validate_excluded_gam_ad_unit_path_suffix(value)?;
}

Ok(())
}

fn canonicalize_excluded_gam_ad_unit_path_suffixes(config: &mut PrebidIntegrationConfig) {
let mut canonical = Vec::with_capacity(config.excluded_gam_ad_unit_path_suffixes.len());
for suffix in std::mem::take(&mut config.excluded_gam_ad_unit_path_suffixes) {
if !canonical.contains(&suffix) {
canonical.push(suffix);
}
}
config.excluded_gam_ad_unit_path_suffixes = canonical;
}

/// Validate enabled Prebid config using the same startup-only checks as runtime registration.
///
/// # Errors
Expand All @@ -357,11 +416,12 @@ impl IntegrationConfig for PrebidIntegrationConfig {
pub fn validate_config_for_startup(
settings: &Settings,
) -> Result<Option<PrebidIntegrationConfig>, Report<TrustedServerError>> {
let Some(config) =
let Some(mut config) =
settings.integration_config::<PrebidIntegrationConfig>(PREBID_INTEGRATION_ID)?
else {
return Ok(None);
};
canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config);
BidParamOverrideEngine::try_from_config(&config)?;
validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?;
Ok(Some(config))
Expand Down Expand Up @@ -879,11 +939,12 @@ fn escape_html_attr(value: &str) -> String {
fn build(
settings: &Settings,
) -> Result<Option<Arc<PrebidIntegration>>, Report<TrustedServerError>> {
let Some(config) =
let Some(mut config) =
settings.integration_config::<PrebidIntegrationConfig>(PREBID_INTEGRATION_ID)?
else {
return Ok(None);
};
canonicalize_excluded_gam_ad_unit_path_suffixes(&mut config);

validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?;

Expand Down Expand Up @@ -1013,6 +1074,8 @@ impl IntegrationHeadInjector for PrebidIntegration {
bidders: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
client_side_bidders: &'a [String],
#[serde(skip_serializing_if = "<[String]>::is_empty")]
excluded_gam_ad_unit_path_suffixes: &'a [String],
}

let payload = InjectedPrebidClientConfig {
Expand All @@ -1021,6 +1084,7 @@ impl IntegrationHeadInjector for PrebidIntegration {
debug: self.config.debug,
bidders: &self.config.bidders,
client_side_bidders: &self.config.client_side_bidders,
excluded_gam_ad_unit_path_suffixes: &self.config.excluded_gam_ad_unit_path_suffixes,
};

// Escape `</` to prevent breaking out of the script tag.
Expand Down Expand Up @@ -2490,6 +2554,7 @@ mod tests {
external_bundle_sha256: None,
external_bundle_sri: None,
client_side_bidders: Vec::new(),
excluded_gam_ad_unit_path_suffixes: Vec::new(),
bid_param_zone_overrides: HashMap::default(),
bid_param_overrides: HashMap::default(),
bid_param_override_rules: Vec::new(),
Expand Down Expand Up @@ -2800,6 +2865,94 @@ passphrase = "test-secret-key-32-bytes-minimum"
serde_json::from_value(value).expect("should build JSON object")
}

#[test]
fn excluded_gam_ad_unit_path_suffixes_default_to_empty() {
let config = parse_prebid_toml(
r#"
[integrations.prebid]
server_url = "https://prebid.example/openrtb2/auction"
"#,
);

assert!(
config.excluded_gam_ad_unit_path_suffixes.is_empty(),
"should default to no refresh-auction exclusions"
);
}

#[test]
fn startup_validation_canonicalizes_excluded_gam_ad_unit_path_suffixes() {
let mut settings = make_settings();
settings
.integrations
.insert_config(
PREBID_INTEGRATION_ID,
&json!({
"enabled": true,
"server_url": "https://prebid.example/openrtb2/auction",
"external_bundle_url": "https://assets.example/prebid/trusted-prebid.js",
"excluded_gam_ad_unit_path_suffixes": [
"/trackingonly",
"/measurement-only",
"/trackingonly"
]
}),
)
.expect("should replace Prebid test configuration");

let config = validate_config_for_startup(&settings)
.expect("should validate Prebid configuration")
.expect("should return enabled Prebid configuration");

assert_eq!(
config.excluded_gam_ad_unit_path_suffixes,
["/trackingonly", "/measurement-only"],
"should retain only the first declaration of each suffix"
);

let integration = PrebidIntegration::new(config);
let document_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &document_state,
};
let inserts = integration.head_inserts(&ctx);

assert!(
inserts[0].contains(
r#""excludedGamAdUnitPathSuffixes":["/trackingonly","/measurement-only"]"#
),
"should inject the canonical suffix list: {}",
inserts[0]
);
}

#[test]
fn excluded_gam_ad_unit_path_suffixes_reject_invalid_values() {
for (suffix, expected_message) in [
("", "must not be empty"),
(" /trackingonly", "must not have surrounding whitespace"),
("trackingonly", "must start with '/'"),
("/", "must identify a non-root path suffix"),
] {
let error = parse_prebid_toml_result(&format!(
r#"
[integrations.prebid]
server_url = "https://prebid.example/openrtb2/auction"
excluded_gam_ad_unit_path_suffixes = ["{suffix}"]
"#
))
.expect_err("should reject an invalid refresh-auction exclusion suffix");

assert!(
error.to_string().contains(expected_message),
"should report why suffix {suffix:?} is invalid: {error}"
);
}
}

#[test]
fn attribute_rewriter_removes_prebid_scripts() {
let integration = PrebidIntegration::new(base_config());
Expand Down Expand Up @@ -3662,6 +3815,36 @@ external_bundle_sri = "sha384-AAAA"
"should include bidders array: {}",
script
);
assert!(
!script.contains("excludedGamAdUnitPathSuffixes"),
"should omit empty refresh-auction exclusions: {}",
script
);
}

#[test]
fn head_injector_includes_excluded_gam_ad_unit_path_suffixes() {
let mut config = base_config();
config.excluded_gam_ad_unit_path_suffixes =
vec!["/trackingonly".to_string(), "/measurement-only".to_string()];
let integration = PrebidIntegration::new(config);
let document_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &document_state,
};

let inserts = integration.head_inserts(&ctx);
let script = &inserts[0];
assert!(
script.contains(
r#""excludedGamAdUnitPathSuffixes":["/trackingonly","/measurement-only"]"#
),
"should inject refresh-auction exclusion suffixes: {}",
script
);
}

#[test]
Expand Down
34 changes: 33 additions & 1 deletion crates/trusted-server-js/lib/src/integrations/prebid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ interface InjectedPrebidConfig {
bidders?: string[];
/** Bidders that run client-side via native Prebid.js adapters. */
clientSideBidders?: string[];
/** GAM ad-unit-path suffixes excluded from refresh auctions. */
excludedGamAdUnitPathSuffixes?: string[];
}

interface PrebidUserIdDiagnostics {
Expand Down Expand Up @@ -259,6 +261,7 @@ type PrebidUserIdEid = {

type RefreshGptSlot = {
getSlotElementId?: () => string;
getAdUnitPath?: () => string;
getTargeting?: (key: string) => string[];
clearTargeting?: (key?: string) => RefreshGptSlot;
getSizes?: () => unknown[];
Expand Down Expand Up @@ -458,6 +461,25 @@ function serverSideBidderParamsForRefresh(
return params;
}

function isExcludedFromRefreshAuction(
slot: RefreshGptSlot,
excludedGamAdUnitPathSuffixes: Set<string>
): boolean {
if (excludedGamAdUnitPathSuffixes.size === 0) return false;

try {
const adUnitPath = slot.getAdUnitPath?.();
return (
typeof adUnitPath === 'string' &&
[...excludedGamAdUnitPathSuffixes].some((suffix) => adUnitPath.endsWith(suffix))
);
} catch {
// GPT path metadata is optional for this optimization. If it is unavailable,
// preserve normal refresh-auction behavior rather than suppressing demand.
return false;
}
}

function clearRefreshTargeting(slot: RefreshGptSlot): void {
if (typeof slot.clearTargeting !== 'function') return;

Expand Down Expand Up @@ -768,7 +790,17 @@ export function installRefreshHandler(timeoutMs = 1500): void {

targetSlots.forEach(clearRefreshTargeting);

const adUnits = targetSlots.map((slot) => {
const excludedGamAdUnitPathSuffixes = new Set(
getInjectedConfig()?.excludedGamAdUnitPathSuffixes ?? []
);
const auctionSlots = targetSlots.filter(
(slot) => !isExcludedFromRefreshAuction(slot, excludedGamAdUnitPathSuffixes)
);
if (!auctionSlots.length) {
return originalRefresh(targetSlots, opts);
}

const adUnits = auctionSlots.map((slot) => {
const injectedSlot = findInjectedSlotForRefresh(slot);
const zone =
injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY));
Expand Down
Loading
Loading