diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 90693534e4..f4fe225bc7 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -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" @@ -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, @@ -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(), @@ -1574,6 +1581,7 @@ struct InjectedPrebidClientConfig<'a> { managed_user_ids: Vec>, 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")] @@ -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, @@ -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(); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 82c0e8c6b7..5e73e2aa93 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -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). @@ -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. */ @@ -525,7 +528,10 @@ export function auctionBidsToPrebidBids( type PbjsConfig = Parameters[0]; type PrebidGetConfig = (key?: string) => unknown; -type ManagedTcfConsentActivation = { acceptCmpEvents: boolean }; +type ManagedTcfConsentActivation = { + acceptCmpEvents: boolean; + applyConfig: typeof pbjs.setConfig; +}; type TcfApi = ( command: string, version: number, @@ -804,7 +810,10 @@ function activateManagedUserIdTcfConsent( return undefined; } - const activation: ManagedTcfConsentActivation = { acceptCmpEvents: true }; + const activation: ManagedTcfConsentActivation = { + acceptCmpEvents: true, + applyConfig: setConfig, + }; try { const effectiveConsent = effectiveConsentManagement ?? {}; if (publisherOwnsTcfConsentManagement(effectiveConsentManagement)) { @@ -812,9 +821,8 @@ function activateManagedUserIdTcfConsent( } 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); @@ -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( @@ -2041,6 +2053,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const managedPbjs = pbjs as typeof pbjs & Record; const injectedManagedUserIds = injected?.managedUserIds; let trySeedManagedUserIds: (() => void) | undefined; + let applyRelayConsentConfig = (value: PbjsConfig) => pbjs.setConfig(value); if ( injectedManagedUserIds && injectedManagedUserIds.length > 0 && @@ -2057,6 +2070,13 @@ export function installPrebidNpm(config?: Partial): 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. @@ -2389,6 +2409,11 @@ export function installPrebidNpm(config?: Partial): 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. @@ -2568,6 +2593,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }; try { + if (adUnits.length > 0 && injected?.relaySoftVendorException === true) { + prepareRelayConsent(); + } return originalRequestBids(opts); } catch (error) { for (const [adUnitCode, token] of firstImpressionTokens) { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/relay_consent.ts b/crates/trusted-server-js/lib/src/integrations/prebid/relay_consent.ts new file mode 100644 index 0000000000..cb408e8d00 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/relay_consent.ts @@ -0,0 +1,84 @@ +import { log } from '../../core/log'; + +function isRecord(value: unknown): value is Record { + 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 }) => 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; + } + }; +} diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/relay_consent.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/relay_consent.test.ts new file mode 100644 index 0000000000..96d68ba700 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/relay_consent.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createRelayConsentGuard } from '../../../src/integrations/prebid/relay_consent'; + +function setup(consent: Record, mapping?: Record) { + let currentConsent = consent; + let currentMapping = mapping; + const setConfig = vi.fn((value: { consentManagement: Record }) => { + currentConsent = value.consentManagement; + }); + const prepare = createRelayConsentGuard( + 'trustedServer', + (key) => (key === 'consentManagement' ? currentConsent : currentMapping), + setConfig + ); + return { + prepare, + setConfig, + consent: () => currentConsent, + mapVendor: () => { + currentMapping = { trustedServer: 1 }; + }, + replaceConsent: (value: Record) => { + currentConsent = value; + }, + }; +} + +describe('first-party relay TCF configuration', () => { + it('retains both default basic-ads gates and is idempotent', () => { + const { prepare, setConfig, consent } = setup({ gdpr: { cmpApi: 'iab' } }); + + prepare(); + prepare(); + + expect(setConfig).toHaveBeenCalledTimes(1); + expect(consent()).toEqual({ + gdpr: { + cmpApi: 'iab', + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], + }, + ], + }, + }); + }); + + it('preserves publisher settings, other rules, and existing exceptions without mutation', () => { + const storage = { purpose: 'storage', enforcePurpose: true, enforceVendor: true }; + const basicAds = { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + vendorExceptions: ['exampleBidder'], + softVendorExceptions: ['anotherBidder'], + deferS2Sbidders: true, + }; + const original = { + gdpr: { cmpApi: 'iab', timeout: 500, rules: [storage, basicAds] }, + gpp: { cmpApi: 'iab' }, + }; + const { prepare, consent } = setup(original); + + prepare(); + + expect(consent()).toEqual({ + ...original, + gdpr: { + ...original.gdpr, + rules: [storage, { ...basicAds, softVendorExceptions: ['anotherBidder', 'trustedServer'] }], + }, + }); + expect(basicAds.softVendorExceptions).toEqual(['anotherBidder']); + expect(original.gdpr.rules).toEqual([storage, basicAds]); + }); + + it('leaves legacy TCF settings and their merge semantics unchanged', () => { + const original = { + cmpApi: 'static', + consentData: { gdprApplies: true }, + strictStorageEnforcement: true, + }; + const { prepare, consent, setConfig } = setup(original); + + prepare(); + + expect(consent()).toEqual(original); + expect(setConfig).not.toHaveBeenCalled(); + }); + + it.each([{}, { gpp: { cmpApi: 'iab' } }, { gdpr: { enabled: false } }])( + 'does not activate GDPR collection for %j', + (original) => { + const { prepare, setConfig } = setup(original); + + prepare(); + + expect(setConfig).not.toHaveBeenCalled(); + } + ); + + it('honors an explicitly configured relay GVL identity', () => { + const { prepare, setConfig } = setup({ gdpr: { cmpApi: 'iab' } }, { trustedServer: 1 }); + + prepare(); + + expect(setConfig).not.toHaveBeenCalled(); + }); + + it('prepares new publisher rules again before a later auction', () => { + const { prepare, replaceConsent, consent } = setup({ gdpr: { cmpApi: 'iab' } }); + prepare(); + replaceConsent({ + strictStorageEnforcement: true, + gdpr: { + cmpApi: 'iab', + rules: [ + { purpose: 'basicAds', enforcePurpose: true, softVendorExceptions: ['exampleBidder'] }, + ], + }, + }); + + prepare(); + + expect(consent()).toMatchObject({ + strictStorageEnforcement: true, + gdpr: { + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + softVendorExceptions: ['exampleBidder', 'trustedServer'], + }, + ], + }, + }); + }); + + it('retires its own exception when a publisher configures a GVL identity later', () => { + const { prepare, mapVendor, consent } = setup({ gdpr: { cmpApi: 'iab' } }); + + prepare(); + mapVendor(); + prepare(); + + expect(consent()).toMatchObject({ + gdpr: { + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: [], + }, + ], + }, + }); + }); + + it('leaves malformed publisher rules unchanged', () => { + const { prepare, setConfig } = setup({ gdpr: { cmpApi: 'iab', rules: 'invalid' } }); + + prepare(); + + expect(setConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 24db647d31..9d94e1bc4b 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -197,12 +197,16 @@ function expectNoUnexpectedNetworkActivity(stubs) { ).toEqual([]); } -function installServerState(pageWindow, { analytics = false, managedUserIds } = {}) { +function installServerState( + pageWindow, + { analytics = false, managedUserIds, relaySoftVendorException } = {} +) { pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); pageWindow.__tsjs_prebid = { clientSideBidders: [], serverSideBidders: ['appnexus'], ...(managedUserIds ? { managedUserIds } : {}), + ...(relaySoftVendorException === undefined ? {} : { relaySoftVendorException }), }; if (analytics) { @@ -299,18 +303,91 @@ function expectManifest(manifest, analytics) { } describe('tsjs-prebid production artifacts', () => { + it.each([ + { relaySoftVendorException: undefined, purpose2: true, mapping: undefined, auctionRequests: 0 }, + { relaySoftVendorException: false, purpose2: true, mapping: undefined, auctionRequests: 0 }, + { relaySoftVendorException: true, purpose2: true, mapping: undefined, auctionRequests: 1 }, + { relaySoftVendorException: true, purpose2: false, mapping: undefined, auctionRequests: 0 }, + { relaySoftVendorException: true, purpose2: true, mapping: 1, auctionRequests: 0 }, + ])( + 'enforces actual TCF rules for relay consent: %j', + async ({ relaySoftVendorException, purpose2, mapping, auctionRequests }) => { + const dom = createPage(); + try { + const pageWindow = dom.window; + const stubs = installNetworkAndConsoleStubs(pageWindow); + installServerState(pageWindow, { relaySoftVendorException }); + pageWindow.eval(noAnalyticsArtifact.bundleCode); + pageWindow.eval(shimCode); + expect(pageWindow.pbjs.installedModules).toContain('tcfControl'); + + // Grant purposes but no vendor identities: the first-party relay must + // not inherit a third-party bidder's GVL identity or vendor exemption. + const gdpr = { + cmpApi: 'static', + consentData: { + gdprApplies: true, + tcString: 'example-consent', + purpose: { consents: { 1: true, 2: purpose2 }, legitimateInterests: {} }, + vendor: { consents: {}, legitimateInterests: {} }, + }, + }; + pageWindow.pbjs.setConfig({ + ...(mapping === undefined ? {} : { gvlMapping: { trustedServer: mapping } }), + consentManagement: { gdpr }, + }); + const slot = pageWindow.document.createElement('div'); + slot.id = 'consent-slot'; + pageWindow.document.body.appendChild(slot); + + await new Promise((resolve) => { + pageWindow.pbjs.requestBids({ + adUnits: [ + { + code: slot.id, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { bidder: 'appnexus', params: { placementId: 1 } }, + { bidder: 'rubicon', params: { accountId: 1, siteId: 1, zoneId: 1 } }, + ], + }, + ], + timeout: 1000, + bidsBackHandler: resolve, + }); + }); + + const requests = stubs.fetchSpy.mock.calls.filter( + ([resource]) => new URL(requestUrl(resource)).pathname === '/auction' + ); + expect(requests).toHaveLength(auctionRequests); + if (relaySoftVendorException !== true) { + expect(pageWindow.pbjs.getConfig('consentManagement').gdpr).toEqual(gdpr); + } + const enforcement = pageWindow.pbjs + .getEvents() + .filter(({ eventType }) => eventType === 'tcf2Enforcement') + .at(-1).args; + expect(enforcement.biddersBlocked).toContain('rubicon'); + expect(enforcement.biddersBlocked.includes('trustedServer')).toBe(auctionRequests === 0); + expectNoUnexpectedNetworkActivity(stubs); + } finally { + dom.window.close(); + } + } + ); + it('keeps the served shim Prebid-free', () => { expect(analyticsArtifact.bundleCode).toContain(analyticsArtifact.manifest.prebidVersion); expect(analyticsArtifact.bundleCode).toContain('_pbjsGlobals'); expect(shimCode).not.toContain(analyticsArtifact.manifest.prebidVersion); expect(shimCode).not.toContain('_pbjsGlobals'); expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); - // A value-import of Prebid or a private rendering helper would multiply - // the shim size. The bound sits just above the normal compact shim output, - // which is roughly 40 KB: tight enough that material growth has to be - // noticed and re-justified here, and far enough below a multiplication - // that one still fails loudly. The bundle beside it is 200 KB and up. - expect(shimCode.length).toBeLessThan(41_000); + // The baseline shim with relay handling and guarded CMP reloads measures + // 41,948 characters. Keep a narrow 42.5 KB bound, not main's 54 KB budget. + // A value-import of Prebid or private rendering helpers must still fail: + // the separate Prebid artifact above is larger than 200 KB. + expect(shimCode.length).toBeLessThan(42_500); expect(shimCode).toContain('markWinningBidAsUsed'); }); diff --git a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs index 63bd88ec38..67c612271e 100644 --- a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs @@ -144,6 +144,8 @@ async function runGdprPage( deferPrebidCmpResponse = false, lateCmp = false, queueAuction = true, + prepareRelayBeforeLateConfig = false, + throwAfterRelayConfig = false, replaceTcfApiBeforeLateEvent = false, replaceStalledCmp = false, tcDataOverrides, @@ -220,6 +222,7 @@ async function runGdprPage( pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); pageWindow.__tsjs_prebid = { clientSideBidders: [], + relaySoftVendorException: prepareRelayBeforeLateConfig, managedUserIds: [ { name: 'identityLink', @@ -244,6 +247,18 @@ async function runGdprPage( publisherConfig.consentManagement = publisherConsentManagement; } pageWindow.pbjs.setConfig(publisherConfig); + const consentPatches = []; + const originalSetConfig = pageWindow.pbjs.setConfig.bind(pageWindow.pbjs); + pageWindow.pbjs.setConfig = (value) => { + if (Object.prototype.hasOwnProperty.call(value, 'consentManagement')) { + consentPatches.push(JSON.parse(JSON.stringify(value.consentManagement))); + } + const result = originalSetConfig(value); + if (throwAfterRelayConfig && Array.isArray(value.consentManagement?.gdpr?.rules)) { + throw new Error('synthetic relay config subscriber failure'); + } + return result; + }; if (lateCmp && queueAuction) { pageWindow.pbjs.que.push(() => { pageWindow.pbjs.requestBids({ adUnits: [], bidsBackHandler: () => {} }); @@ -255,6 +270,33 @@ async function runGdprPage( pageWindow.__tcfapi = cmp; } + const consentCheckpoint = () => ({ + registered: registeredConsentListenerCount, + removed: removedConsentListenerCount, + remaining: consentListeners.size, + listenerIds: [...consentListeners.keys()], + originalTcfApiRestored: pageWindow.__tcfapi === cmp, + patchCount: consentPatches.length, + consentManagement: JSON.parse( + JSON.stringify(pageWindow.pbjs.getConfig('consentManagement') ?? null) + ), + }); + const beforeRelay = consentCheckpoint(); + if (prepareRelayBeforeLateConfig) { + pageWindow.pbjs.requestBids({ + adUnits: [ + { + code: 'example-relay-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'trustedServer', params: {} }], + }, + ], + timeout: 100, + bidsBackHandler: () => {}, + }); + } + const afterRelay = consentCheckpoint(); + if (latePublisherConsentManagement !== undefined) { pageWindow.pbjs.setConfig({ consentManagement: latePublisherConsentManagement }); } @@ -308,6 +350,10 @@ async function runGdprPage( } return { + beforeRelay, + afterRelay, + consentPatches, + close: () => dom.window.close(), requestedUrls, cookies: pageWindow.document.cookie, consentManagement: pageWindow.pbjs.getConfig('consentManagement'), @@ -480,6 +526,102 @@ describe('external bundle TCF enforcement', () => { expect(consentManagement.gdpr).toEqual(publisherConsentManagement.gdpr); }); + it('keeps automatic CMP ownership while preparing a nonempty relay auction', async () => { + const result = await runGdprPage( + { purpose1: false, vendor97: false }, + { prepareRelayBeforeLateConfig: true } + ); + try { + const relayPatches = result.consentPatches.slice(result.beforeRelay.patchCount); + console.log( + '[relay-automatic-cmp]', + JSON.stringify({ + before: result.beforeRelay, + after: result.afterRelay, + relayPatches, + }) + ); + expect(result.beforeRelay.consentManagement.gdpr.cmpApi).toBe('iab'); + expect(result.beforeRelay.registered).toBe(2); + expect(result.beforeRelay.remaining).toBe(1); + for (const automaticListener of result.beforeRelay.listenerIds) { + expect.soft(result.afterRelay.listenerIds).toContain(automaticListener); + } + expect.soft(relayPatches.some((patch) => patch.gdpr?.enabled === false)).toBe(false); + } finally { + result.close(); + } + }); + + it('retains a pre-existing publisher setter around internal relay rule updates', async () => { + const result = await runGdprPage( + { purpose1: false, vendor97: false }, + { prepareRelayBeforeLateConfig: true } + ); + try { + const rulePatch = result.consentPatches + .slice(result.beforeRelay.patchCount) + .find((patch) => Array.isArray(patch.gdpr?.rules)); + expect(rulePatch?.gdpr.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + purpose: 'basicAds', + softVendorExceptions: expect.arrayContaining(['trustedServer']), + }), + ]) + ); + } finally { + result.close(); + } + }); + + it.each([ + [false, false], + [true, false], + [true, true], + ])( + 'keeps publisher takeover protected after a nonempty automatic-CMP relay auction, deferred=%s, subscriber throws=%s', + async (deferPrebidCmpResponse, throwAfterRelayConfig) => { + const result = await runGdprPage( + { purpose1: false, vendor97: false }, + { + prepareRelayBeforeLateConfig: true, + deferPrebidCmpResponse, + throwAfterRelayConfig, + latePublisherConsentManagement: { + gdpr: { + cmpApi: 'static', + consentData: tcData({ purpose1: false, vendor97: false }), + }, + }, + cmpEventAfterLateConfig: {}, + } + ); + try { + console.log( + '[relay-publisher-takeover]', + JSON.stringify({ + before: result.beforeRelay, + after: result.afterRelay, + consentPatches: result.consentPatches, + registered: result.registeredConsentListenerCount, + removed: result.removedConsentListenerCount, + remaining: result.remainingConsentListenerCount, + vendorRequests: envelopeRequests(result.requestedUrls).length, + }) + ); + expect(result.beforeRelay.consentManagement.gdpr.cmpApi).toBe('iab'); + expect(result.beforeRelay.originalTcfApiRestored).toBe(true); + expect.soft(result.afterRelay.originalTcfApiRestored).toBe(true); + expect.soft(result.remainingConsentListenerCount).toBe(0); + expect.soft(envelopeRequests(result.requestedUrls)).toEqual([]); + expect.soft(result.cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); + } finally { + result.close(); + } + } + ); + it('retires automatic IAB consent when late static GDPR configuration takes ownership', async () => { const deniedStaticConsent = { gdpr: { diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 18db20e237..8f621ad710 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -27,6 +27,7 @@ Prebid configuration has two independent owners: enabled = true timeout_ms = 1000 debug = false +relay_soft_vendor_exception = false client_side_bidders = ["example-browser"] excluded_gam_ad_unit_path_suffixes = ["/example-tracking-only"] script_patterns = ["/prebid.js", "/prebid.min.js"] @@ -95,6 +96,7 @@ provider = "pbs-main" | `account_id` | `None` | Optional browser-injected account value | | `timeout_ms` | `1000` | Browser Prebid.js timeout only | | `debug` | `false` | Browser Prebid.js debug only | +| `relay_soft_vendor_exception` | `false` | Testing opt-in for the relay basic-ads soft vendor exception | | `client_side_bidders` | `[]` | Native browser adapters that are not folded into `trustedServer` | | `excluded_gam_ad_unit_path_suffixes` | `[]` | GAM suffixes omitted from Trusted Server refresh auctions | | `script_patterns` | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | Publisher Prebid scripts intercepted to prevent duplicate instances | @@ -268,6 +270,31 @@ rule, and Purpose 4 controls user-provided-data activity rather than IdentityLink resolution. Validate a regenerated bundle against a live CMP before rolling it out broadly. +The `trustedServer` bidder is a first-party auction relay without its own GVL +vendor ID. Set `integrations.prebid.relay_soft_vendor_exception = true` to opt +into a testing exception. With `consentManagement.gdpr` configured, before each +relay auction the shim adds `trustedServer` to the `basicAds` rule's +`softVendorExceptions`. Purpose 2 enforcement remains active; unrelated bidders, +storage, and User ID modules keep their existing rules. Publisher rules and +existing exceptions are preserved. If the publisher supplies a +`gvlMapping.trustedServer` identity, that identity's vendor consent is enforced +instead. The relay forwards the original consent signals; downstream auction +endpoints remain responsible for enforcing consent for their individual vendors. +Legacy top-level TCF settings are left unchanged; use the namespaced +`consentManagement.gdpr` configuration for relay consent handling. + +Internal relay rule updates retain the automatic CMP subscription's ownership +and guard. A later publisher consent configuration still takes ownership; stale +CMP callbacks must not override that publisher decision, including when the +CMP's first response is deferred. + +For production, leave `relay_soft_vendor_exception = false` or omit it, which +also defaults to false. The shim then leaves publisher consent rules unchanged. +Without a publisher-configured relay GVL identity or exception, Prebid's normal +TCF vendor enforcement can still block relay auctions. Enabling this testing +option is not a substitute for a production consent policy, and it does not set +Prebid Server's `test_mode` or mark auction traffic as non-billable. + When managed User IDs are configured and the page exposes a callable `window.__tcfapi`, the Trusted Server shim activates Prebid's standard IAB GDPR collector by adding only `consentManagement.gdpr.cmpApi = "iab"`. It does not diff --git a/trusted-server.example.toml b/trusted-server.example.toml index db3dcb2b46..74b6501dcd 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -491,6 +491,8 @@ auction_timeout_ms = 500 [integrations.prebid] enabled = false timeout_ms = 1000 +# Testing opt-in only. Leave false for production consent enforcement. +relay_soft_vendor_exception = false debug = false client_side_bidders = [] # bidders running via native Prebid.js adapters # Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions.