Skip to content
Closed
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
56 changes: 56 additions & 0 deletions crates/trusted-server-core/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,11 @@ pub struct PrebidIntegrationConfig {
pub timeout_ms: u32,
#[serde(default)]
pub debug: bool,
/// Opt into the relay's basic-ads soft vendor exception for testing.
///
/// Defaults to false. Purpose consent and publisher GVL mappings remain enforced.
#[serde(default)]
pub relay_soft_vendor_exception: bool,
#[serde(
default = "default_script_patterns",
deserialize_with = "crate::settings::vec_from_seq_or_map"
Expand Down Expand Up @@ -572,6 +577,7 @@ impl Default for PrebidIntegrationConfig {
account_id: None,
timeout_ms: default_timeout_ms(),
debug: false,
relay_soft_vendor_exception: false,
script_patterns: default_script_patterns(),
external_bundle_url: None,
external_bundle_sha256: None,
Expand All @@ -598,6 +604,7 @@ impl From<&LegacyPrebidServerConfig> for PrebidIntegrationConfig {
account_id: config.account_id.clone(),
timeout_ms: config.timeout_ms,
debug: config.debug,
relay_soft_vendor_exception: false,
script_patterns: config.script_patterns.clone(),
external_bundle_url: config.external_bundle_url.clone(),
external_bundle_sha256: config.external_bundle_sha256.clone(),
Expand Down Expand Up @@ -1574,6 +1581,7 @@ struct InjectedPrebidClientConfig<'a> {
managed_user_ids: Vec<InjectedManagedUserId<'a>>,
timeout: u32,
debug: bool,
relay_soft_vendor_exception: bool,
#[serde(skip_serializing_if = "Option::is_none")]
bidders: Option<&'a [String]>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -1595,6 +1603,7 @@ impl<'a> From<&'a PrebidIntegrationConfig> for InjectedPrebidClientConfig<'a> {
.collect(),
timeout: config.timeout_ms,
debug: config.debug,
relay_soft_vendor_exception: config.relay_soft_vendor_exception,
bidders: None,
server_side_bidders: None,
client_side_bidders: &config.client_side_bidders,
Expand Down Expand Up @@ -5318,6 +5327,53 @@ external_bundle_sri = "sha384-AAAA"
);
}

#[test]
fn planned_registration_injects_relay_soft_vendor_exception() {
assert!(
!PrebidIntegrationConfig::default().relay_soft_vendor_exception,
"should default the relay exception to false"
);
for configured in [None, Some(false), Some(true)] {
let mut settings = make_settings();
let mut config = json!({
"enabled": true,
"external_bundle_url": "https://assets.example/prebid/trusted-prebid.js"
});
if let Some(value) = configured {
config["relay_soft_vendor_exception"] = json!(value);
}
settings
.integrations
.insert_config("prebid", &config)
.expect("should configure prebid");
let plan = crate::auction::compile_auction_plan(&settings)
.expect("should compile auction plan");
let document_state = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &document_state,
};
for enabled in [true, false] {
let registration =
register_for_plan(&settings, &plan.clone().with_enabled(enabled))
.expect("should register prebid")
.expect("should enable prebid");
let inserts = registration.head_injectors[0].head_inserts(&ctx);
let expected = format!(
r#""relaySoftVendorException":{}"#,
configured.unwrap_or(false)
);
assert!(
inserts[0].contains(&expected),
"should inject the configured relay exception for {configured:?}: {}",
inserts[0]
);
}
}
}

#[test]
fn planned_registration_injects_managed_user_ids() {
let mut settings = make_settings();
Expand Down
60 changes: 44 additions & 16 deletions crates/trusted-server-js/lib/src/integrations/prebid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
userIdConfigNameAliases,
userIdSubmoduleKey,
} from './user_id_modules';
import { createRelayConsentGuard } from './relay_consent';

/**
* Prebid.js public API surface (type-only; erased at build time).
Expand Down Expand Up @@ -185,6 +186,8 @@ interface InjectedPrebidConfig {
accountId?: string;
timeout?: number;
debug?: boolean;
/** Opt-in relay basic-ads soft vendor exception for testing. Defaults to false. */
relaySoftVendorException?: boolean;
/** Validated browser bidder route codes owned by the server auction plan. */
serverSideBidders?: string[];
/** Bidders that run client-side via native Prebid.js adapters. */
Expand Down Expand Up @@ -525,7 +528,10 @@ export function auctionBidsToPrebidBids(

type PbjsConfig = Parameters<typeof pbjs.setConfig>[0];
type PrebidGetConfig = (key?: string) => unknown;
type ManagedTcfConsentActivation = { acceptCmpEvents: boolean };
type ManagedTcfConsentActivation = {
acceptCmpEvents: boolean;
applyConfig: typeof pbjs.setConfig;
};
type TcfApi = (
command: string,
version: number,
Expand Down Expand Up @@ -804,17 +810,19 @@ function activateManagedUserIdTcfConsent(
return undefined;
}

const activation: ManagedTcfConsentActivation = { acceptCmpEvents: true };
const activation: ManagedTcfConsentActivation = {
acceptCmpEvents: true,
applyConfig: setConfig,
};
try {
const effectiveConsent = effectiveConsentManagement ?? {};
if (publisherOwnsTcfConsentManagement(effectiveConsentManagement)) {
return undefined;
}

const originalTcfApi = tcfApi as TcfApi;
// Prebid owns the callback once it subscribes. Guard only the subscription
// created by this automatic activation so a delayed first CMP response
// cannot overwrite consent after publisher ownership transfers.
// Keep initial and internal rule-update subscriptions under the same owner
// so delayed CMP responses cannot overwrite a later publisher policy.
const guardedTcfApi: TcfApi = function (command, version, callback, parameter) {
if (command !== 'addEventListener' || typeof callback !== 'function') {
return originalTcfApi.call(window, command, version, callback, parameter);
Expand Down Expand Up @@ -845,17 +853,21 @@ function activateManagedUserIdTcfConsent(
};

const tcfWindow = window as typeof window & { __tcfapi: TcfApi };
tcfWindow.__tcfapi = guardedTcfApi;
try {
setConfig({
consentManagement: {
...effectiveConsent,
gdpr: { cmpApi: 'iab' },
},
} as PbjsConfig);
} finally {
if (tcfWindow.__tcfapi === guardedTcfApi) tcfWindow.__tcfapi = originalTcfApi;
}
activation.applyConfig = (value) => {
const previousTcfApi = tcfWindow.__tcfapi;
tcfWindow.__tcfapi = guardedTcfApi;
try {
return setConfig(value);
} finally {
if (tcfWindow.__tcfapi === guardedTcfApi) tcfWindow.__tcfapi = previousTcfApi;
}
};
activation.applyConfig({
consentManagement: {
...effectiveConsent,
gdpr: { cmpApi: 'iab' },
},
} as PbjsConfig);
} catch (error) {
activation.acceptCmpEvents = false;
log.error(
Expand Down Expand Up @@ -2041,6 +2053,7 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
const managedPbjs = pbjs as typeof pbjs & Record<string, unknown>;
const injectedManagedUserIds = injected?.managedUserIds;
let trySeedManagedUserIds: (() => void) | undefined;
let applyRelayConsentConfig = (value: PbjsConfig) => pbjs.setConfig(value);
if (
injectedManagedUserIds &&
injectedManagedUserIds.length > 0 &&
Expand All @@ -2057,6 +2070,13 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
const getConfig = (pbjs as unknown as { getConfig?: PrebidGetConfig }).getConfig;

let automaticTcfConsentActivation: ManagedTcfConsentActivation | undefined;
// Internal relay rules retain the automatic owner's CMP subscription guard.
applyRelayConsentConfig = (value) => {
const activeConsent = automaticTcfConsentActivation;
return activeConsent?.acceptCmpEvents
? activeConsent.applyConfig(value)
: originalSetConfig(value);
};
// Until CMP discovery resolves, managed entries stay out of every
// configuration Prebid sees: a module seeded before the CMP is discoverable
// would call its vendor with the GDPR handler disabled.
Expand Down Expand Up @@ -2389,6 +2409,11 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
});

const originalRequestBids = pbjs.requestBids.bind(pbjs);
const prepareRelayConsent = createRelayConsentGuard(
ADAPTER_CODE,
(key) => (pbjs as unknown as { getConfig?: PrebidGetConfig }).getConfig?.(key),
(value) => applyRelayConsentConfig(value as PbjsConfig)
);

// Browser demand ownership is explicit. Only validated auction-plan route
// codes are folded; every other publisher bidder entry remains in Prebid.js.
Expand Down Expand Up @@ -2568,6 +2593,9 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
};

try {
if (adUnits.length > 0 && injected?.relaySoftVendorException === true) {
prepareRelayConsent();
}
return originalRequestBids(opts);
} catch (error) {
for (const [adUnitCode, token] of firstImpressionTokens) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { log } from '../../core/log';

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Creates a per-auction TCF guard for a first-party auction relay.
*
* The relay has no GVL identity of its own. A basic-ads soft vendor exception
* retains Prebid's purpose check while downstream auction endpoints receive
* the original consent signals for their vendor-specific enforcement.
*/
export function createRelayConsentGuard(
bidder: string,
getConfig: (key: string) => unknown,
setConfig: (config: { consentManagement: Record<string, unknown> }) => unknown
): () => void {
let addedException = false;
let applying = false;

return () => {
if (applying) return;
try {
const consent = getConfig('consentManagement');
if (!isRecord(consent)) return;
// tcfControl reads namespaced rules. Do not migrate publisher-owned
// legacy settings: later mergeConfig calls must retain their semantics.
const gdpr = consent.gdpr;
if (!isRecord(gdpr) || gdpr.enabled === false) return;
if (gdpr.rules !== undefined && !Array.isArray(gdpr.rules)) return;

const mapping = getConfig('gvlMapping');
const mapped = isRecord(mapping) && mapping[bidder] !== undefined;
// An operator-supplied vendor identity keeps its vendor-consent gate.
if (mapped && !addedException) return;

const rules: unknown[] = gdpr.rules ?? [];
let foundBasicAds = false;
let changed = false;
const nextRules = rules.map((rule) => {
if (!isRecord(rule) || rule.purpose !== 'basicAds') return rule;
foundBasicAds = true;
const exceptions = rule.softVendorExceptions ?? [];
if (!Array.isArray(exceptions)) return rule;
const containsRelay = exceptions.includes(bidder);
if (mapped ? !containsRelay : containsRelay) return rule;
changed = true;
return {
...rule,
softVendorExceptions: mapped
? exceptions.filter((entry) => entry !== bidder)
: [...exceptions, bidder],
};
});

if (!foundBasicAds && !mapped) {
// Custom rules replace Prebid's defaults, so retain both default gates.
nextRules.push({
purpose: 'basicAds',
enforcePurpose: true,
enforceVendor: true,
softVendorExceptions: [bidder],
});
changed = true;
}
if (!changed) {
if (mapped) addedException = false;
return;
}

const nextGdpr = { ...gdpr, rules: nextRules };
applying = true;
addedException = !mapped;
setConfig({
consentManagement: { ...consent, gdpr: nextGdpr },
});
} catch (error) {
log.error('[tsjs-prebid] auction relay TCF configuration could not be prepared', error);
} finally {
applying = false;
}
};
}
Loading
Loading