diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c13260704f..cf573f7f37 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -532,6 +532,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" @@ -574,6 +579,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, @@ -600,6 +606,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(), @@ -1576,6 +1583,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")] @@ -1597,6 +1605,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, @@ -5474,6 +5483,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 288653c1bb..52c6291cfa 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -35,6 +35,7 @@ import { userIdConfigNameAliases, userIdSubmoduleKey, } from './user_id_modules'; +import { createRelayConsentGuard } from './relay_consent'; /** * Prebid.js public API surface (type-only; erased at build time). @@ -187,6 +188,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. */ @@ -2478,6 +2481,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) => pbjs.setConfig(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. @@ -2657,6 +2665,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 80ba90580c..7c565dcd66 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 @@ -223,12 +223,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) { @@ -325,17 +329,90 @@ 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); - // The original shim measures 40,354 characters. Shared strict trace JSON - // validation, request-ID hook association, and bounded pending lifecycle add - // 11,676 characters (52,030 total). Keep a narrow 52.5 KB guard: Prebid remains a separate artifact above 200 KB, and importing + // Keep a narrow 54 KB guard including relay consent handling. + // Prebid remains a separate artifact above 200 KB, and importing // its runtime or private rendering helpers must still fail this regression. - expect(shimCode.length).toBeLessThan(52_500); + expect(shimCode.length).toBeLessThan(54_000); expect(shimCode).toContain('markWinningBidAsUsed'); }); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index b7a05ac7be..14b66ac5f0 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 | @@ -284,6 +286,26 @@ 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. + +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 7b17751835..d6b7d7e35a 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -530,6 +530,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.