From cd0a2a4c3d62a677404069139e9e78c627fe5051 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Tue, 10 Jun 2025 10:00:22 -0700 Subject: [PATCH 1/3] normalize EIDs --- src/adapterManager.js | 10 ------ src/fpd/normalize.js | 35 +++++++++++++++++++ src/prebid.js | 4 +-- test/spec/fpd/normalize_spec.js | 39 +++++++++++++++++++++ test/spec/unit/core/adapterManager_spec.js | 14 -------- test/spec/unit/pbjs_api_spec.js | 40 ++++++++++++++++++++++ 6 files changed, 116 insertions(+), 26 deletions(-) create mode 100644 src/fpd/normalize.js create mode 100644 test/spec/fpd/normalize_spec.js diff --git a/src/adapterManager.js b/src/adapterManager.js index 0db1e28d33a..bf7f2e39e90 100644 --- a/src/adapterManager.js +++ b/src/adapterManager.js @@ -309,15 +309,6 @@ adapterManager.makeBidRequests = hook('sync', function (adUnits, auctionStart, a const ortb2 = ortb2Fragments.global || {}; const bidderOrtb2 = ortb2Fragments.bidder || {}; - function moveUserEidsToExt(o) { - const eids = o.user?.eids; - if (Array.isArray(eids) && eids.length) { - o.user.ext = o.user.ext || {}; - o.user.ext.eids = [...(o.user.ext.eids || []), ...eids]; - delete o.user.eids; - } - } - function addOrtb2(bidderRequest, s2sActivityParams) { const redact = dep.redact( s2sActivityParams != null @@ -326,7 +317,6 @@ adapterManager.makeBidRequests = hook('sync', function (adUnits, auctionStart, a ); const merged = mergeDeep({source: {tid: auctionId}}, ortb2, bidderOrtb2[bidderRequest.bidderCode]); - moveUserEidsToExt(merged); moveSchainToExt(merged, bidderOrtb2[bidderRequest.bidderCode]); const fpd = Object.freeze(redact.ortb2(merged)); bidderRequest.ortb2 = fpd; diff --git a/src/fpd/normalize.js b/src/fpd/normalize.js new file mode 100644 index 00000000000..0f207137b15 --- /dev/null +++ b/src/fpd/normalize.js @@ -0,0 +1,35 @@ +import {deepEqual, deepSetValue} from '../utils.js'; + +export function normalizeFPD(ortb2Fragments) { + applyNormalizer(normalizeEIDs, ortb2Fragments); + return ortb2Fragments; +} + +function applyNormalizer(normalizer, ortb2Fragments) { + ortb2Fragments.global = normalizer(ortb2Fragments.global); + Object.entries(ortb2Fragments.bidder).forEach(([bidder, ortb2]) => { + ortb2Fragments.bidder[bidder] = normalizer(ortb2); + }) +} + +export function normalizeEIDs(target) { + if (!target) return target; + const seen = []; + const eids = [ + ...(target?.user?.eids ?? []).map(eid => [0, eid]), + ...(target?.user?.ext?.eids ?? []).map(eid => [1, eid]) + ].filter(([source, eid]) => { + if (seen.findIndex(([candidateSource, candidateEid]) => + source !== candidateSource && deepEqual(candidateEid, eid) + ) > -1) { + return false; + } else { + seen.push([source, eid]); + return true; + } + }); + + deepSetValue(target, 'user.ext.eids', eids.map(([_, eid]) => eid)); + delete target?.user?.eids; + return target; +} diff --git a/src/prebid.js b/src/prebid.js index d5ac6e16c8d..295dc233df7 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -52,6 +52,7 @@ import { ORTB_BANNER_PARAMS } from './banner.js'; import { BANNER, VIDEO } from './mediaTypes.js'; import {delayIfPrerendering} from './utils/prerendering.js'; import { newBidder } from './adapters/bidderFactory.js'; +import {normalizeFPD} from './fpd/normalize.js'; const pbjsInstance = getGlobal(); const { triggerUserSyncs } = userSync; @@ -591,8 +592,7 @@ pbjsInstance.requestBids = (function() { global: mergeDeep({}, config.getAnyConfig('ortb2') || {}, ortb2 || {}), bidder: Object.fromEntries(Object.entries(config.getBidderConfig()).map(([bidder, cfg]) => [bidder, deepClone(cfg.ortb2)]).filter(([_, ortb2]) => ortb2 != null)) } - // Apply schain precedence rules before enrichment - ortb2Fragments = schainPrecedence(ortb2Fragments); + ortb2Fragments = normalizeFPD(ortb2Fragments); return enrichFPD(PbPromise.resolve(ortb2Fragments.global)).then(global => { ortb2Fragments.global = global; diff --git a/test/spec/fpd/normalize_spec.js b/test/spec/fpd/normalize_spec.js new file mode 100644 index 00000000000..84c0b77df1c --- /dev/null +++ b/test/spec/fpd/normalize_spec.js @@ -0,0 +1,39 @@ +import {normalizeEIDs} from '../../../src/fpd/normalize.js'; + +describe('FPD normalization', () => { + describe('EIDs', () => { + it('should merge user.eids into user.ext.eids', () => { + const fpd = { + user: { + eids: [{source: 'idA'}], + ext: {eids: [{source: 'idB'}]} + } + }; + const result = normalizeEIDs(fpd); + expect(result.user.eids).to.not.exist; + expect(result.user.ext.eids).to.deep.have.members([ + {source: 'idA'}, + {source: 'idB'} + ]) + }); + it('should remove duplicates', () => { + const fpd = { + user: { + eids: [{source: 'id'}], + ext: {eids: [{source: 'id'}]} + } + } + expect(normalizeEIDs(fpd).user.ext.eids).to.eql([ + {source: 'id'} + ]) + }); + it('should NOT remove duplicates if they come from the same place', () => { + const fpd = { + user: { + eids: [{source: 'id'}, {source: 'id'}] + } + } + expect(normalizeEIDs(fpd).user.ext.eids.length).to.eql(2); + }) + }) +}) diff --git a/test/spec/unit/core/adapterManager_spec.js b/test/spec/unit/core/adapterManager_spec.js index a8ac1351f80..b890115df36 100644 --- a/test/spec/unit/core/adapterManager_spec.js +++ b/test/spec/unit/core/adapterManager_spec.js @@ -2084,20 +2084,6 @@ describe('adapterManager tests', function () { requests.appnexus.bids.forEach((bid) => expect(bid.ortb2).to.eql(requests.appnexus.ortb2)); }); - it('should move user.eids into user.ext.eids', () => { - const global = { - user: { - eids: [{source: 'idA'}], - ext: {eids: [{source: 'idB'}]} - } - }; - const reqs = adapterManager.makeBidRequests(adUnits, 123, 'auction-id', 123, [], {global}); - reqs.forEach(req => { - expect(req.ortb2.user.ext.eids).to.deep.equal([{source: 'idB'}, {source: 'idA'}]); - expect(req.ortb2.user.eids).to.not.exist; - }); - }); - describe('source.tid', () => { beforeEach(() => { sinon.stub(dep, 'redact').returns({ diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index ef163b3b547..4f40b97fb5b 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -1818,6 +1818,46 @@ describe('Unit: Prebid Module', function () { await auctionStarted; } + it('with normalized FPD', async () => { + configObj.setBidderConfig({ + bidders: ['test'], + config: { + ortb2: { + user: { + eids: [{source: 'id'}] + } + } + } + }); + configObj.setConfig({ + ortb2: { + user: { + eids: [{source: 'id'}] + } + } + }); + await runAuction(); + sinon.assert.calledWith(startAuctionStub, sinon.match({ + ortb2Fragments: { + global: { + user: { + ext: { + eids: [{source: 'id'}] + } + } + }, + bidder: { + test: { + user: { + ext: { + eids: [{source: 'id'}] + } + } + } + } + } + })); + }) describe('with FPD', () => { let globalFPD, auctionFPD, mergedFPD; beforeEach(() => { From 66e08f6bf4370e21399797010f2cce0e13a49e55 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Tue, 10 Jun 2025 10:14:54 -0700 Subject: [PATCH 2/3] normalize schain --- src/adapterManager.js | 2 - src/fpd/normalize.js | 37 ++- src/fpd/schain.js | 85 ------- src/prebid.js | 1 - test/spec/fpd/normalize_spec.js | 54 ++++- test/spec/fpd/schain_spec.js | 385 -------------------------------- test/spec/unit/pbjs_api_spec.js | 16 +- 7 files changed, 91 insertions(+), 489 deletions(-) delete mode 100644 src/fpd/schain.js delete mode 100644 test/spec/fpd/schain_spec.js diff --git a/src/adapterManager.js b/src/adapterManager.js index bf7f2e39e90..98da1e3e3c1 100644 --- a/src/adapterManager.js +++ b/src/adapterManager.js @@ -40,7 +40,6 @@ import { import {getRefererInfo} from './refererDetection.js'; import {GDPR_GVLIDS, gdprDataHandler, gppDataHandler, uspDataHandler, } from './consentHandler.js'; import * as events from './events.js'; -import {moveSchainToExt} from './fpd/schain.js'; import {EVENTS, S2S} from './constants.js'; import {useMetrics} from './utils/perfMetrics.js'; import {auctionManager} from './auctionManager.js'; @@ -317,7 +316,6 @@ adapterManager.makeBidRequests = hook('sync', function (adUnits, auctionStart, a ); const merged = mergeDeep({source: {tid: auctionId}}, ortb2, bidderOrtb2[bidderRequest.bidderCode]); - moveSchainToExt(merged, bidderOrtb2[bidderRequest.bidderCode]); const fpd = Object.freeze(redact.ortb2(merged)); bidderRequest.ortb2 = fpd; bidderRequest.bids = bidderRequest.bids.map((bid) => { diff --git a/src/fpd/normalize.js b/src/fpd/normalize.js index 0f207137b15..8873ecac422 100644 --- a/src/fpd/normalize.js +++ b/src/fpd/normalize.js @@ -1,18 +1,21 @@ -import {deepEqual, deepSetValue} from '../utils.js'; +import {deepEqual, deepSetValue, logWarn} from '../utils.js'; export function normalizeFPD(ortb2Fragments) { - applyNormalizer(normalizeEIDs, ortb2Fragments); + [ + normalizeEIDs, + normalizeSchain + ].forEach(normalizer => applyNormalizer(normalizer, ortb2Fragments)); return ortb2Fragments; } function applyNormalizer(normalizer, ortb2Fragments) { - ortb2Fragments.global = normalizer(ortb2Fragments.global); + ortb2Fragments.global = normalizer(ortb2Fragments.global, 'global FPD'); Object.entries(ortb2Fragments.bidder).forEach(([bidder, ortb2]) => { - ortb2Fragments.bidder[bidder] = normalizer(ortb2); + ortb2Fragments.bidder[bidder] = normalizer(ortb2, `bidder '${bidder}' FPD`); }) } -export function normalizeEIDs(target) { +export function normalizeEIDs(target, context) { if (!target) return target; const seen = []; const eids = [ @@ -22,14 +25,34 @@ export function normalizeEIDs(target) { if (seen.findIndex(([candidateSource, candidateEid]) => source !== candidateSource && deepEqual(candidateEid, eid) ) > -1) { + logWarn(`Found duplicate EID in user.eids and user.ext.eids (${context})`, eid) return false; } else { seen.push([source, eid]); return true; } }); - - deepSetValue(target, 'user.ext.eids', eids.map(([_, eid]) => eid)); + if (eids.length > 0) { + deepSetValue(target, 'user.ext.eids', eids.map(([_, eid]) => eid)); + } delete target?.user?.eids; return target; } + + +export function normalizeSchain(target, context) { + if (!target) return target; + const schain = target.source?.schain; + const extSchain = target.source?.ext?.schain; + if (schain != null && extSchain != null && !deepEqual(schain, extSchain)) { + logWarn(`Conflicting source.schain and source.ext.schain (${context}), preferring source.schain`, { + 'source.schain': schain, + 'source.ext.schain': extSchain + }) + } + if ((schain ?? extSchain) != null) { + deepSetValue(target, 'source.ext.schain', schain ?? extSchain); + } + delete target.source?.schain; + return target; +} diff --git a/src/fpd/schain.js b/src/fpd/schain.js deleted file mode 100644 index f2e6d3761a6..00000000000 --- a/src/fpd/schain.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * This module handles supply chain (schain) processing and relocation - * between different locations in the ortb2 structure - */ -import {config} from '../config.js'; -import {deepClone, logInfo} from '../utils.js'; - -/** - * Applies schain from config to ortb2 fragments with precedence rules - * @param {Object} ortb2Fragments - The ortb2 fragments object - * @returns {Object} - The updated ortb2Fragments object - */ -export function schainPrecedence(ortb2Fragments) { - if (!ortb2Fragments) return ortb2Fragments; - - // Apply global schain config if available - // config's schain will have more precedence over ortb2.source.schain - const globalSchainConfig = config.getConfig('schain'); - if (globalSchainConfig && globalSchainConfig.config) { - if (!ortb2Fragments?.global?.source?.schain) { - logInfo('Applying global schain config with precedence'); - applySchainToPath(ortb2Fragments, 'global.source', globalSchainConfig.config); - } else { - logInfo('Preserving existing global.source.schain from ortb2'); - } - } - - // Apply bidder-specific schain configs - const bidderConfigs = config.getBidderConfig(); - if (!bidderConfigs) return ortb2Fragments; - - Object.entries(bidderConfigs) - .filter(([_, cfg]) => cfg.schain) - .forEach(([bidderCode, cfg]) => { - const bidderPath = `bidder.${bidderCode}.source`; - const hasSchain = ortb2Fragments?.bidder?.[bidderCode]?.source?.schain; - - if (!hasSchain) { - logInfo(`Applying bidder schain config for ${bidderCode}`); - applySchainToPath(ortb2Fragments, bidderPath, cfg.schain?.config); - } else { - logInfo(`Preserving existing schain for bidder ${bidderCode} from ortb2`); - } - }); - - return ortb2Fragments; -} - -/** - * Helper function to apply schain to a specific path in ortb2Fragments - * @param {Object} fragments - The ortb2 fragments object - * @param {String} path - Dot-notation path where to apply the schain - * @param {Object} schainConfig - The schain configuration to apply - */ -function applySchainToPath(fragments, path, schainConfig) { - const parts = path.split('.'); - let current = fragments; - - // Create path if it doesn't exist - parts.forEach(part => { - current[part] = current[part] || {}; - current = current[part]; - }); - - // Apply the schain config - current.schain = deepClone(schainConfig); -} - -/** - * Relocates schain from source.schain to source.ext.schain - * @param {Object} fpd - The first-party data object - * @returns {Object} - The updated FPD object - */ -export function moveSchainToExt(fpd, bidderOrtb2) { - if (!fpd?.source?.schain) return fpd; - - // Ensure source.ext exists - fpd.source.ext = fpd.source.ext || {}; - - // Move schain to the new location and remove from original - fpd.source.ext.schain = bidderOrtb2?.source?.schain || fpd.source.schain; - delete fpd.source.schain; - - return fpd; -} diff --git a/src/prebid.js b/src/prebid.js index 295dc233df7..73ba49301f7 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -37,7 +37,6 @@ import * as events from './events.js'; import {newMetrics, useMetrics} from './utils/perfMetrics.js'; import {defer, PbPromise} from './utils/promise.js'; import {enrichFPD} from './fpd/enrichment.js'; -import {schainPrecedence} from './fpd/schain.js'; import {allConsent} from './consentHandler.js'; import { insertLocatorFrame, diff --git a/test/spec/fpd/normalize_spec.js b/test/spec/fpd/normalize_spec.js index 84c0b77df1c..e3a818549d6 100644 --- a/test/spec/fpd/normalize_spec.js +++ b/test/spec/fpd/normalize_spec.js @@ -1,6 +1,16 @@ -import {normalizeEIDs} from '../../../src/fpd/normalize.js'; +import {normalizeEIDs, normalizeFPD, normalizeSchain} from '../../../src/fpd/normalize.js'; +import * as utils from '../../../src/utils'; describe('FPD normalization', () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'logWarn'); + }); + afterEach(() => { + sandbox.restore(); + }) + describe('EIDs', () => { it('should merge user.eids into user.ext.eids', () => { const fpd = { @@ -26,6 +36,7 @@ describe('FPD normalization', () => { expect(normalizeEIDs(fpd).user.ext.eids).to.eql([ {source: 'id'} ]) + sinon.assert.called(utils.logWarn); }); it('should NOT remove duplicates if they come from the same place', () => { const fpd = { @@ -34,6 +45,47 @@ describe('FPD normalization', () => { } } expect(normalizeEIDs(fpd).user.ext.eids.length).to.eql(2); + }); + it('should do nothing if there are no eids', () => { + expect(normalizeEIDs({})).to.eql({}); + }) + }) + describe('schain', () => { + it('should move schain to ext.schain', () => { + const fpd = { + source: { + schain: 'foo' + } + } + expect(normalizeSchain(fpd)).to.deep.equal({ + source: { + ext: { + schain: 'foo' + } + } + }) + }); + it('should warn on conflict', () => { + const fpd = { + source: { + schain: 'foo', + ext: { + schain: 'bar' + } + }, + } + expect(normalizeSchain(fpd)).to.eql({ + source: { + ext: { + schain: 'foo' + } + } + }); + sinon.assert.called(utils.logWarn); + }); + + it('should do nothing if there is no schain', () => { + expect(normalizeSchain({})).to.eql({}); }) }) }) diff --git a/test/spec/fpd/schain_spec.js b/test/spec/fpd/schain_spec.js deleted file mode 100644 index 062a4f5d62e..00000000000 --- a/test/spec/fpd/schain_spec.js +++ /dev/null @@ -1,385 +0,0 @@ -import {expect} from 'chai/index.js'; -import * as utils from 'src/utils.js'; -import {config} from 'src/config.js'; -import {schainPrecedence, moveSchainToExt} from 'src/fpd/schain.js'; - -describe('Supply Chain fpd', function() { - const SAMPLE_SCHAIN = { - ver: '1.0', - complete: 1, - nodes: [{ asi: 'example.com', sid: '00001', hp: 1 }] - }; - - const SAMPLE_SCHAIN_2 = { - ver: '2.0', - complete: 1, - nodes: [{ asi: 'bidder.com', sid: '00002', hp: 1 }] - }; - - let sandbox; - let logInfoStub; - let configGetConfigStub; - let configGetBidderConfigStub; - - beforeEach(function() { - sandbox = sinon.createSandbox(); - logInfoStub = sandbox.stub(utils, 'logInfo'); - configGetConfigStub = sandbox.stub(config, 'getConfig'); - configGetBidderConfigStub = sandbox.stub(config, 'getBidderConfig'); - }); - - afterEach(function() { - sandbox.restore(); - }); - - - describe('schainPrecedence', function() { - describe('preserves existing schain values', function() { - it('should preserve existing global.source.schain', function() { - const existingSchain = { - ver: '1.0', - complete: 1, - nodes: [{ asi: 'existing.com', sid: '99999', hp: 1 }] - }; - - const input = { - global: { - source: { - schain: existingSchain - } - } - }; - - const schainConfig = { - config: SAMPLE_SCHAIN - }; - - configGetConfigStub.returns(schainConfig); - configGetBidderConfigStub.returns(null); - - const result = schainPrecedence(input); - - expect(result.global.source.schain).to.deep.equal(existingSchain); - expect(result.global.source.schain).to.not.deep.equal(SAMPLE_SCHAIN); - expect(logInfoStub.calledWith('Preserving existing global.source.schain from ortb2')).to.be.true; - }); - - it('should preserve existing bidder-specific schain ', function() { - const existingBidderSchain = { - ver: '3.0', - complete: 1, - nodes: [{ asi: 'existingbidder.com', sid: '88888', hp: 1 }] - }; - - const input = { - bidder: { - 'bidderA': { - source: { - schain: existingBidderSchain - } - } - } - }; - - const bidderConfigs = { - 'bidderA': { - schain: { - config: SAMPLE_SCHAIN - } - } - }; - - configGetConfigStub.returns(null); - configGetBidderConfigStub.returns(bidderConfigs); - - const result = schainPrecedence(input); - - expect(result.bidder.bidderA.source.schain).to.deep.equal(existingBidderSchain); - expect(result.bidder.bidderA.source.schain).to.not.deep.equal(SAMPLE_SCHAIN); - expect(logInfoStub.calledWith('Preserving existing schain for bidder bidderA from ortb2')).to.be.true; - }); - }); - - describe('handles edge cases', function() { - it('should handle edge cases and no-op scenarios', function() { - expect(schainPrecedence(null)).to.be.null; - expect(schainPrecedence(undefined)).to.be.undefined; - expect(schainPrecedence({})).to.deep.equal({}); - - const input = { - global: { - source: { - tid: '123' - } - } - }; - configGetConfigStub.returns(null); - configGetBidderConfigStub.returns(null); - - const result = schainPrecedence(input); - expect(result).to.deep.equal(input); - expect(logInfoStub.called).to.be.false; - }); - }); - - describe('global schain config handling', function() { - let input; - - beforeEach(function() { - input = { - global: { - source: {} - } - }; - configGetBidderConfigStub.returns(null); - }); - - it('should correctly handle different global schain config scenarios', function() { - const validSchainConfig = { - config: SAMPLE_SCHAIN - }; - configGetConfigStub.returns(validSchainConfig); - - let result = schainPrecedence(input); - expect(result.global.source.schain).to.deep.equal(SAMPLE_SCHAIN); - expect(logInfoStub.calledWith('Applying global schain config with precedence')).to.be.true; - - logInfoStub.reset(); - input = { global: { source: {} } }; - - const invalidSchainConfig = { - validation: 'strict' - }; - configGetConfigStub.returns(invalidSchainConfig); - - result = schainPrecedence(input); - expect(result.global.source.schain).to.be.undefined; - }); - }); - - describe('bidder-specific schain config handling', function() { - let input; - - beforeEach(function() { - input = { - global: {}, - bidder: {} - }; - configGetConfigStub.returns(null); - logInfoStub.reset(); - }); - - it('should handle various bidder-specific schain scenarios', function() { - const singleBidderConfig = { - 'bidderA': { - schain: { - config: SAMPLE_SCHAIN - } - } - }; - configGetBidderConfigStub.returns(singleBidderConfig); - - let result = schainPrecedence(input); - expect(result.bidder.bidderA.source.schain).to.deep.equal(SAMPLE_SCHAIN); - expect(logInfoStub.calledWith('Applying bidder schain config for bidderA')).to.be.true; - - logInfoStub.reset(); - input = { global: {}, bidder: {} }; - - const multiBidderConfig = { - 'bidderA': { - schain: { - config: SAMPLE_SCHAIN - } - }, - 'bidderB': { - schain: { - config: SAMPLE_SCHAIN_2 - } - }, - 'bidderC': { - } - }; - configGetBidderConfigStub.returns(multiBidderConfig); - - result = schainPrecedence(input); - expect(result.bidder.bidderA.source.schain).to.deep.equal(SAMPLE_SCHAIN); - expect(result.bidder.bidderB.source.schain).to.deep.equal(SAMPLE_SCHAIN_2); - expect(result.bidder.bidderC).to.be.undefined; - expect(logInfoStub.calledWith('Applying bidder schain config for bidderA')).to.be.true; - expect(logInfoStub.calledWith('Applying bidder schain config for bidderB')).to.be.true; - - logInfoStub.reset(); - input = { global: {}, bidder: {} }; - - const invalidBidderConfig = { - 'bidderA': { - schain: { - validation: 'strict' - } - } - }; - configGetBidderConfigStub.returns(invalidBidderConfig); - - result = schainPrecedence(input); - expect(result.bidder.bidderA.source.schain).to.deep.equal({}); - }); - }); - - // Test case: both global and bidder-specific schain configs - it('should apply both global and bidder-specific schain configs', function() { - const input = { - global: {}, - bidder: {} - }; - const globalSchainConfig = { - config: { - ver: '1.0', - complete: 1, - nodes: [{ asi: 'global.com', sid: '00001', hp: 1 }] - } - }; - const bidderConfigs = { - 'bidderA': { - schain: { - config: { - ver: '1.0', - complete: 1, - nodes: [{ asi: 'bidderA.com', sid: '00001', hp: 1 }] - } - } - } - }; - configGetConfigStub.returns(globalSchainConfig); - configGetBidderConfigStub.returns(bidderConfigs); - - const result = schainPrecedence(input); - expect(result.global.source.schain).to.deep.equal(globalSchainConfig.config); - expect(result.bidder.bidderA.source.schain).to.deep.equal(bidderConfigs.bidderA.schain.config); - }); - }); - - describe('moveSchainToExt', function() { - it('should handle various input scenarios correctly', function() { - expect(moveSchainToExt(null)).to.be.null; - expect(moveSchainToExt(undefined)).to.be.undefined; - - const inputNoSource = { user: { id: '123' } }; - expect(moveSchainToExt(inputNoSource)).to.deep.equal(inputNoSource); - - const inputNoSchain = { source: { tid: '123' } }; - expect(moveSchainToExt(inputNoSchain)).to.deep.equal(inputNoSchain); - - const basicInput = { - source: { - tid: '123', - schain: SAMPLE_SCHAIN - } - }; - let result = moveSchainToExt(basicInput); - expect(result.source.schain).to.be.undefined; - expect(result.source.ext.schain).to.deep.equal(SAMPLE_SCHAIN); - expect(result.source.tid).to.equal('123'); - - const inputWithExt = { - source: { - tid: '123', - schain: SAMPLE_SCHAIN, - ext: { - dchain: { ver: '1.0' } - } - } - }; - result = moveSchainToExt(inputWithExt); - expect(result.source.schain).to.be.undefined; - expect(result.source.ext.schain).to.deep.equal(SAMPLE_SCHAIN); - expect(result.source.ext.dchain).to.deep.equal({ ver: '1.0' }); - }); - - describe('bidderOrtb2 parameter handling', function() { - const createFreshFpd = () => ({ - source: { - tid: '123', - schain: SAMPLE_SCHAIN - } - }); - - it('should handle bidderOrtb2 parameter variations', function() { - const bidderOrtb2WithSchain = { - source: { - schain: SAMPLE_SCHAIN_2 - } - }; - - let fpd = createFreshFpd(); - let result = moveSchainToExt(fpd, bidderOrtb2WithSchain); - expect(result.source.schain).to.be.undefined; - expect(result.source.ext.schain).to.deep.equal(SAMPLE_SCHAIN_2); - - const bidderOrtb2WithoutSchain = { - source: {} - }; - - fpd = createFreshFpd(); - result = moveSchainToExt(fpd, bidderOrtb2WithoutSchain); - expect(result.source.schain).to.be.undefined; - expect(result.source.ext.schain).to.deep.equal(SAMPLE_SCHAIN); - - fpd = createFreshFpd(); - result = moveSchainToExt(fpd, null); - expect(result.source.schain).to.be.undefined; - expect(result.source.ext.schain).to.deep.equal(SAMPLE_SCHAIN); - }); - }); - }); - - describe('Integration', function() { - it('should handle the full schain workflow with both global and bidder configs', function() { - const ortb2Fragments = { - global: { - source: { - tid: '123' - } - }, - bidder: { - 'bidderA': { - source: {} - } - } - }; - - configGetConfigStub.returns({ config: SAMPLE_SCHAIN }); - configGetBidderConfigStub.returns({ - 'bidderA': { - schain: { - config: SAMPLE_SCHAIN_2 - } - } - }); - - const updatedFragments = schainPrecedence(ortb2Fragments); - - expect(updatedFragments.global.source.schain).to.deep.equal(SAMPLE_SCHAIN); - expect(updatedFragments.bidder.bidderA.source.schain).to.deep.equal(SAMPLE_SCHAIN_2); - - const merged = { - source: { - tid: '123', - schain: SAMPLE_SCHAIN - } - }; - - const bidderOrtb2 = { - source: { - schain: SAMPLE_SCHAIN_2 - } - }; - - const finalFpd = moveSchainToExt(merged, bidderOrtb2); - - expect(finalFpd.source.schain).to.be.undefined; - expect(finalFpd.source.ext.schain).to.deep.equal(SAMPLE_SCHAIN_2); - expect(finalFpd.source.tid).to.equal('123'); - }); - }); -}); diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index 4f40b97fb5b..b7f6a702091 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -1823,16 +1823,16 @@ describe('Unit: Prebid Module', function () { bidders: ['test'], config: { ortb2: { - user: { - eids: [{source: 'id'}] + source: { + schain: 'foo' } } } }); configObj.setConfig({ ortb2: { - user: { - eids: [{source: 'id'}] + source: { + schain: 'bar' } } }); @@ -1840,17 +1840,17 @@ describe('Unit: Prebid Module', function () { sinon.assert.calledWith(startAuctionStub, sinon.match({ ortb2Fragments: { global: { - user: { + source: { ext: { - eids: [{source: 'id'}] + schain: 'bar' } } }, bidder: { test: { - user: { + source: { ext: { - eids: [{source: 'id'}] + schain: 'foo' } } } From 71f828319c14eb44db51fb1df66e47258f21026a Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Tue, 10 Jun 2025 11:01:14 -0700 Subject: [PATCH 3/3] reintroduce schain --- modules/schain.js | 66 ++++++++ modules/schain.md | 35 +++++ src/fpd/normalize.js | 5 +- test/spec/modules/schain_spec.js | 254 +++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 modules/schain.js create mode 100644 modules/schain.md create mode 100644 test/spec/modules/schain_spec.js diff --git a/modules/schain.js b/modules/schain.js new file mode 100644 index 00000000000..0fe980982e3 --- /dev/null +++ b/modules/schain.js @@ -0,0 +1,66 @@ +import {config} from '../src/config.js'; +import {deepClone, logWarn} from '../src/utils.js'; +import {normalizeFPD} from '../src/fpd/normalize.js'; + + +export function applySchainConfig(ortb2Fragments) { + if (!ortb2Fragments) return ortb2Fragments; + + let warned = false; + function warnDeprecated() { + if (!warned) { + logWarn('The schain module is deprecated and no longer needed; you may provide schain directly as FPD (e.g., "setConfig({ortb2: {source: {schain: {...}})")'); + warned = true; + } + } + + // Apply global schain config if available + // config's schain will have more precedence over ortb2.source.schain + const globalSchainConfig = config.getConfig('schain'); + if (globalSchainConfig && globalSchainConfig.config) { + warnDeprecated(); + if (!ortb2Fragments?.global?.source?.schain) { + applySchainToPath(ortb2Fragments, 'global.source', globalSchainConfig.config); + } else { + logWarn('Disregarding global schain config as schain is already provided in FPD') + } + } + + // Apply bidder-specific schain configs + const bidderConfigs = config.getBidderConfig(); + if (!bidderConfigs) return ortb2Fragments; + + Object.entries(bidderConfigs) + .filter(([_, cfg]) => cfg.schain) + .forEach(([bidderCode, cfg]) => { + warnDeprecated(); + const bidderPath = `bidder.${bidderCode}.source`; + const hasSchain = ortb2Fragments?.bidder?.[bidderCode]?.source?.schain; + if (!hasSchain) { + applySchainToPath(ortb2Fragments, bidderPath, cfg.schain?.config); + } else { + logWarn(`Disregarding schain config for bidder "${bidderCode}" as schain is already provided in FPD`); + } + }); + + return ortb2Fragments; +} + +function applySchainToPath(fragments, path, schainConfig) { + const parts = path.split('.'); + let current = fragments; + + // Create path if it doesn't exist + parts.forEach(part => { + current[part] = current[part] || {}; + current = current[part]; + }); + + // Apply the schain config + current.schain = deepClone(schainConfig); +} + +normalizeFPD.before((next, ortb2Fragments) => { + applySchainConfig(ortb2Fragments); + next(ortb2Fragments); +}) diff --git a/modules/schain.md b/modules/schain.md new file mode 100644 index 00000000000..44deef99886 --- /dev/null +++ b/modules/schain.md @@ -0,0 +1,35 @@ +# schain module + +** DEPRECATED **. + +This module is deprecated since prebid 10; schain may be provided directly as fpd, for example: + +```typescript +pbjs.setConfig({ + ortb2: { + source: { + schain: { + "ver":"1.0", + "complete": 1, + "nodes": [ + { + "asi":"indirectseller.com", + "sid":"00001", + "hp":1 + }, + + { + "asi":"indirectseller-2.com", + "sid":"00002", + "hp":1 + } + ] + } + } + } +}) +``` + +You may also use the [FPD validation module](https://docs.prebid.org/dev-docs/modules/validationFpdModule.html) to validate your schain configuration. + + diff --git a/src/fpd/normalize.js b/src/fpd/normalize.js index 8873ecac422..0e8b20de00b 100644 --- a/src/fpd/normalize.js +++ b/src/fpd/normalize.js @@ -1,12 +1,13 @@ import {deepEqual, deepSetValue, logWarn} from '../utils.js'; +import {hook} from '../hook.js'; -export function normalizeFPD(ortb2Fragments) { +export const normalizeFPD = hook('sync', function(ortb2Fragments) { [ normalizeEIDs, normalizeSchain ].forEach(normalizer => applyNormalizer(normalizer, ortb2Fragments)); return ortb2Fragments; -} +}) function applyNormalizer(normalizer, ortb2Fragments) { ortb2Fragments.global = normalizer(ortb2Fragments.global, 'global FPD'); diff --git a/test/spec/modules/schain_spec.js b/test/spec/modules/schain_spec.js new file mode 100644 index 00000000000..0d959b95906 --- /dev/null +++ b/test/spec/modules/schain_spec.js @@ -0,0 +1,254 @@ +import {expect} from 'chai/index.js'; +import * as utils from 'src/utils.js'; +import {config} from 'src/config.js'; +import {applySchainConfig} from 'modules/schain.js'; + +describe('Supply Chain fpd', function() { + const SAMPLE_SCHAIN = { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'example.com', sid: '00001', hp: 1 }] + }; + + const SAMPLE_SCHAIN_2 = { + ver: '2.0', + complete: 1, + nodes: [{ asi: 'bidder.com', sid: '00002', hp: 1 }] + }; + + let sandbox; + let logWarnStub; + let configGetConfigStub; + let configGetBidderConfigStub; + + beforeEach(function() { + sandbox = sinon.createSandbox(); + logWarnStub = sandbox.stub(utils, 'logWarn'); + configGetConfigStub = sandbox.stub(config, 'getConfig'); + configGetBidderConfigStub = sandbox.stub(config, 'getBidderConfig'); + }); + + afterEach(function() { + sandbox.restore(); + }); + + + describe('applySchainConfig', function() { + describe('preserves existing schain values', function() { + it('should preserve existing global.source.schain', function() { + const existingSchain = { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'existing.com', sid: '99999', hp: 1 }] + }; + + const input = { + global: { + source: { + schain: existingSchain + } + } + }; + + const schainConfig = { + config: SAMPLE_SCHAIN + }; + + configGetConfigStub.returns(schainConfig); + configGetBidderConfigStub.returns(null); + + const result = applySchainConfig(input); + + expect(result.global.source.schain).to.deep.equal(existingSchain); + expect(result.global.source.schain).to.not.deep.equal(SAMPLE_SCHAIN); + sinon.assert.called(logWarnStub); + }); + + it('should preserve existing bidder-specific schain ', function() { + const existingBidderSchain = { + ver: '3.0', + complete: 1, + nodes: [{ asi: 'existingbidder.com', sid: '88888', hp: 1 }] + }; + + const input = { + bidder: { + 'bidderA': { + source: { + schain: existingBidderSchain + } + } + } + }; + + const bidderConfigs = { + 'bidderA': { + schain: { + config: SAMPLE_SCHAIN + } + } + }; + + configGetConfigStub.returns(null); + configGetBidderConfigStub.returns(bidderConfigs); + + const result = applySchainConfig(input); + + expect(result.bidder.bidderA.source.schain).to.deep.equal(existingBidderSchain); + expect(result.bidder.bidderA.source.schain).to.not.deep.equal(SAMPLE_SCHAIN); + sinon.assert.called(logWarnStub); + }); + }); + + describe('handles edge cases', function() { + it('should handle edge cases and no-op scenarios', function() { + expect(applySchainConfig(null)).to.be.null; + expect(applySchainConfig(undefined)).to.be.undefined; + expect(applySchainConfig({})).to.deep.equal({}); + + const input = { + global: { + source: { + tid: '123' + } + } + }; + configGetConfigStub.returns(null); + configGetBidderConfigStub.returns(null); + + const result = applySchainConfig(input); + expect(result).to.deep.equal(input); + }); + }); + + describe('global schain config handling', function() { + let input; + + beforeEach(function() { + input = { + global: { + source: {} + } + }; + configGetBidderConfigStub.returns(null); + }); + + it('should correctly handle different global schain config scenarios', function() { + const validSchainConfig = { + config: SAMPLE_SCHAIN + }; + configGetConfigStub.returns(validSchainConfig); + + let result = applySchainConfig(input); + expect(result.global.source.schain).to.deep.equal(SAMPLE_SCHAIN); + + logWarnStub.reset(); + input = { global: { source: {} } }; + + const invalidSchainConfig = { + validation: 'strict' + }; + configGetConfigStub.returns(invalidSchainConfig); + + result = applySchainConfig(input); + expect(result.global.source.schain).to.be.undefined; + }); + }); + + describe('bidder-specific schain config handling', function() { + let input; + + beforeEach(function() { + input = { + global: {}, + bidder: {} + }; + configGetConfigStub.returns(null); + logWarnStub.reset(); + }); + + it('should handle various bidder-specific schain scenarios', function() { + const singleBidderConfig = { + 'bidderA': { + schain: { + config: SAMPLE_SCHAIN + } + } + }; + configGetBidderConfigStub.returns(singleBidderConfig); + + let result = applySchainConfig(input); + expect(result.bidder.bidderA.source.schain).to.deep.equal(SAMPLE_SCHAIN); + + logWarnStub.reset(); + input = { global: {}, bidder: {} }; + + const multiBidderConfig = { + 'bidderA': { + schain: { + config: SAMPLE_SCHAIN + } + }, + 'bidderB': { + schain: { + config: SAMPLE_SCHAIN_2 + } + }, + 'bidderC': { + } + }; + configGetBidderConfigStub.returns(multiBidderConfig); + + result = applySchainConfig(input); + expect(result.bidder.bidderA.source.schain).to.deep.equal(SAMPLE_SCHAIN); + expect(result.bidder.bidderB.source.schain).to.deep.equal(SAMPLE_SCHAIN_2); + expect(result.bidder.bidderC).to.be.undefined; + input = { global: {}, bidder: {} }; + + const invalidBidderConfig = { + 'bidderA': { + schain: { + validation: 'strict' + } + } + }; + configGetBidderConfigStub.returns(invalidBidderConfig); + + result = applySchainConfig(input); + expect(result.bidder.bidderA.source.schain).to.deep.equal({}); + }); + }); + + // Test case: both global and bidder-specific schain configs + it('should apply both global and bidder-specific schain configs', function() { + const input = { + global: {}, + bidder: {} + }; + const globalSchainConfig = { + config: { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'global.com', sid: '00001', hp: 1 }] + } + }; + const bidderConfigs = { + 'bidderA': { + schain: { + config: { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'bidderA.com', sid: '00001', hp: 1 }] + } + } + } + }; + configGetConfigStub.returns(globalSchainConfig); + configGetBidderConfigStub.returns(bidderConfigs); + + const result = applySchainConfig(input); + expect(result.global.source.schain).to.deep.equal(globalSchainConfig.config); + expect(result.bidder.bidderA.source.schain).to.deep.equal(bidderConfigs.bidderA.schain.config); + }); + }); +});