Repository navigation
Allow consented first-party relay auctions under TCF #1498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<PrebidNpmConfig>): 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 P1 — Relay updates can override a later publisher denial With managed User IDs, a CMP, and I independently reproduced this with the built Prebid artifact and shim: after a static Purpose 1 denial and a subsequent CMP grant, the page made one stubbed identity request and wrote Route internal updates through the managed consent owner and guard every resulting subscription, with combined lifecycle regression tests. Simply bypassing the publisher wrapper is insufficient if the replacement subscription loses its callback guard. Apply manually: the correction touches the existing activation/ownership code outside this diff and tests in another file, so it cannot be expressed as a one-click suggestion. |
||
| ); | ||
|
|
||
| // 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<PrebidNpmConfig>): typeof pbjs | |
| }; | ||
|
|
||
| try { | ||
| if (adUnits.length > 0 && injected?.relaySoftVendorException === true) { | ||
| prepareRelayConsent(); | ||
| } | ||
| return originalRequestBids(opts); | ||
| } catch (error) { | ||
| for (const [adUnitCode, token] of firstImpressionTokens) { | ||
|
|
||
| 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; | ||
| } | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { createRelayConsentGuard } from '../../../src/integrations/prebid/relay_consent'; | ||
|
|
||
| function setup(consent: Record<string, unknown>, mapping?: Record<string, unknown>) { | ||
| let currentConsent = consent; | ||
| let currentMapping = mapping; | ||
| const setConfig = vi.fn((value: { consentManagement: Record<string, unknown> }) => { | ||
| 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<string, unknown>) => { | ||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 wrench: Relay rule writes go through the managed-ID ownership wrapper
With
managed_user_idsconfigured and a CMP on the page, the shim activates IAB consent itself and replacespbjs.setConfigwith a wrapper that treats anyconsentManagement.gdprwrite as a publisher takeover. This setter resolves to that wrapper at call time, so the first non-empty auction withrelay_soft_vendor_exception = trueruns this sequence:retireAutomaticTcfConsentreads the relay rules as publisher ownership. It setsacceptCmpEvents = falseand writes{ gdpr: { enabled: false } }.cmpApi: 'iab'back on. Prebid 10.26setConsentConfigcallsloadConsentData()synchronously, so Prebid immediately opens a new__tcfapiaddEventListenersubscription on the unguarded API.setConfigwith a static GDPR denial finds no automatic owner left to retire. The next CMP event on the unguarded subscription overwrites the publisher's denial.Repro on f22a83f: apply the three relay cases from the #1545 port of
prebid-consent-enforcement.test.mjsunchanged (git diff <this head> <#1545 head> -- crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs | git apply). 4 of the 5 runs fail. After the relay auction and a static Purpose 1 / vendor 97 denial, IdentityLink still requests the LiveRamp envelope and writesidl_env. The checkpoint log shows the writes{"gdpr":{"enabled":false}}then{"gdpr":{"cmpApi":"iab","rules":[...]}}, with automatic listener 2 replaced by unguarded listener 3. Your comment on this PR notes that the correction is not on this branch; the PR description should say so too until it lands.Scope: only
relay_soft_vendor_exception = truetogether withmanaged_user_idsand a CMP. That is the testing setup this flag exists for.Proposed fix (apply manually: it touches
activateManagedUserIdTcfConsentoutside this diff and adds tests in another file, so it can't be a one-click suggestion). Route internal relay writes through the automatic owner, and keep its__tcfapiguard installed while Prebid subscribes:Then add the three #1545 relay regression tests to
prebid-consent-enforcement.test.mjs. I applied this change to f22a83f in a scratch tree: all 5 relay cases pass, the full vitest run passes (81 files, 3,318 tests, no type errors), Prettier and ESLint are clean, and the shim is 53,628 characters, under the 54,000 guard.