From e165898dfa3add25144c434adfc80348985c71a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:14:00 -0500 Subject: [PATCH 01/21] Bump axios from 1.16.0 to 1.18.1 (#15372) Bumps [axios](https://github.com/axios/axios) from 1.16.0 to 1.18.1. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.16.0...v1.18.1) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c092930e..c3eca5b8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6603,13 +6603,14 @@ } }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "dev": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -24940,13 +24941,14 @@ } }, "axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "dev": true, "requires": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" }, "dependencies": { From f960adf664101949d954ebe40df6a45daad9cbf3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:58:15 -0400 Subject: [PATCH 02/21] Bump fast-xml-parser from 5.9.3 to 5.10.1 (#15378) Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.9.3 to 5.10.1. - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.1) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.10.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 95 ++++++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3eca5b8f..75192fb82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3321,17 +3321,16 @@ } }, "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/nodable" } - ], - "license": "MIT" + ] }, "node_modules/@nolyfill/is-core-module": { "version": "1.0.39", @@ -10608,9 +10607,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "dev": true, "funding": [ { @@ -10618,19 +10617,33 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", "dependencies": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", - "xml-naming": "^0.1.0" + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "node_modules/fast-xml-parser/node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "dev": true, @@ -12988,17 +13001,16 @@ } }, "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } - ], - "license": "MIT" + ] }, "node_modules/is-valid-glob": { "version": "1.0.0", @@ -16211,9 +16223,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "dev": true, "funding": [ { @@ -16221,7 +16233,6 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -22750,9 +22761,9 @@ } }, "@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "dev": true }, "@nolyfill/is-core-module": { @@ -27560,17 +27571,25 @@ } }, "fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "dev": true, "requires": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", - "xml-naming": "^0.1.0" + "xml-naming": "^0.3.0" + }, + "dependencies": { + "xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true + } } }, "fastest-levenshtein": { @@ -29096,9 +29115,9 @@ "dev": true }, "is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", "dev": true }, "is-valid-glob": { @@ -31155,9 +31174,9 @@ "dev": true }, "path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "dev": true }, "path-is-absolute": { From ac2c5425534fe5dc969f6a91ea249463c88783ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:59:04 -0400 Subject: [PATCH 03/21] Bump fast-uri from 3.1.2 to 3.1.4 (#15376) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 75192fb82..713ed31df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10575,9 +10575,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -27556,9 +27556,9 @@ "dev": true }, "fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==" + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==" }, "fast-xml-builder": { "version": "1.2.0", From 61db77c8f291ada4f5bdac6a15dd8b8a13d30d9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:59:26 -0400 Subject: [PATCH 04/21] Bump body-parser from 1.20.5 to 1.20.6 (#15375) Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.5 to 1.20.6. - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/1.20.5...1.20.6) --- updated-dependencies: - dependency-name: body-parser dependency-version: 1.20.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 713ed31df..983ce71ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6936,9 +6936,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -25183,9 +25183,9 @@ } }, "body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "requires": { "bytes": "~3.1.2", "content-type": "~1.0.5", From 9b2b81a81894e61a448e049f0fa43bbe4a46c227 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:59:44 -0400 Subject: [PATCH 05/21] Bump tar from 7.5.16 to 7.5.21 (#15377) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.16 to 7.5.21. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.21) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.21 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 983ce71ce..be1088951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18614,9 +18614,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.21", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", + "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", "dev": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -32791,9 +32791,9 @@ "dev": true }, "tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.21", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", + "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", "dev": true, "requires": { "@isaacs/fs-minipass": "^4.0.0", From 3cab5f1f30765801e788cd07e9c9be67636a61c4 Mon Sep 17 00:00:00 2001 From: mkomorski Date: Thu, 23 Jul 2026 17:06:13 +0200 Subject: [PATCH 06/21] Core: fpd validation library (#15333) * Core: fpd validation library * tests rework * changing to ts * fpdValidation renaming * type fix * moving validation from bid interception to debugging initial start * deepClone check * moving to startAuction hook * Core: inject fpd validation utils from call sites Convert libraries/fpdUtils/validateFpd into an fpdValidator factory that takes logWarn/isNumber/isEmpty/deepAccess as injected dependencies instead of importing from src/utils. Callers wire the deps: - validationFpdModule imports them from src/utils - debugging uses logger.logWarn (debug-prefixed) with utils for the rest Co-Authored-By: Claude Opus 4.8 (1M context) * Core: guarantee startAuction fpd validation never breaks the auction Wrap validateOrtb2Fragments in a try/catch in the debugging startAuction hook so next() is always called with the original request even if reading or validating req.ortb2Fragments throws. Add tests covering that the hook never mutates req.ortb2Fragments and always forwards the request to next, including when a throwing getter forces an exception. Co-Authored-By: Claude Opus 4.8 (1M context) * too many types * Core: make fpd validation warning wording configurable The fpdValidator warnings say "Filtered ...", which is accurate for validationFpdModule (it removes invalid data) but misleading for debugging, which validates a clone and never alters the request. Add a `filtered` option (default true) that switches the wording to "Invalid" when the caller does not modify the data; debugging opts into it. Co-Authored-By: Claude Opus 4.8 (1M context) * Core: move fpd validation cloning into fpdValidator Since fpdValidator knows via the `filtered` option whether it is filtering data or only generating warnings, let it own the decision to modify the input. When `filtered` is false it now validates against an internally-made clone (via an injected deepClone dep) and returns the original untouched; when true it returns the filtered result as before. The debugging caller no longer clones or threads deepClone through validateOrtb2ForDebug. Co-Authored-By: Claude Opus 4.8 (1M context) * Core: rename fpdValidator 'filtered' option to 'filter' Co-Authored-By: Claude Opus 4.8 (1M context) * Core: throw when fpdValidator filter is false without deepClone A validator asked not to filter but given no deepClone cannot inspect data without risking mutation, so fail loudly instead of silently doing nothing. Update the library and debugging tests that expected a no-op to expect a throw. In the auction flow this surfaces through the startAuction hook's try/catch, so next is still called. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Demetrio Girardi Co-authored-by: Claude Opus 4.8 (1M context) --- .../fpdUtils/ortbMap.ts | 0 libraries/fpdUtils/pubcidOptout.ts | 10 + libraries/fpdUtils/validateFpd.ts | 205 +++++++++++++++++ modules/debugging/debugging.d.ts | 2 +- modules/debugging/debugging.js | 5 +- modules/debugging/fpdValidation.ts | 47 ++++ modules/debugging/index.js | 3 +- modules/validationFpdModule/index.ts | 208 +----------------- src/config.ts | 2 +- src/debugging.js | 11 +- .../libraries/fpdUtils/validateFpd_spec.js | 103 +++++++++ test/spec/modules/debugging_mod_spec.js | 111 ++++++++++ test/spec/modules/validationFpdModule_spec.js | 7 +- 13 files changed, 508 insertions(+), 206 deletions(-) rename modules/validationFpdModule/config.js => libraries/fpdUtils/ortbMap.ts (100%) create mode 100644 libraries/fpdUtils/pubcidOptout.ts create mode 100644 libraries/fpdUtils/validateFpd.ts create mode 100644 modules/debugging/fpdValidation.ts create mode 100644 test/spec/libraries/fpdUtils/validateFpd_spec.js diff --git a/modules/validationFpdModule/config.js b/libraries/fpdUtils/ortbMap.ts similarity index 100% rename from modules/validationFpdModule/config.js rename to libraries/fpdUtils/ortbMap.ts diff --git a/libraries/fpdUtils/pubcidOptout.ts b/libraries/fpdUtils/pubcidOptout.ts new file mode 100644 index 000000000..e7611568e --- /dev/null +++ b/libraries/fpdUtils/pubcidOptout.ts @@ -0,0 +1,10 @@ +import type { StorageManager } from '../../src/storageManager.js'; + +export const PUBCID_OPTOUT_KEY = '_pubcid_optout'; + +export function hasPubcidOptout(storage: StorageManager): boolean { + return Boolean( + (storage.cookiesAreEnabled() && storage.getCookie(PUBCID_OPTOUT_KEY)) || + (storage.hasLocalStorage() && storage.getDataFromLocalStorage(PUBCID_OPTOUT_KEY)) + ); +} diff --git a/libraries/fpdUtils/validateFpd.ts b/libraries/fpdUtils/validateFpd.ts new file mode 100644 index 000000000..9f4e38693 --- /dev/null +++ b/libraries/fpdUtils/validateFpd.ts @@ -0,0 +1,205 @@ +import { ORTB_MAP } from './ortbMap.js'; + +/** + * Utility functions the validator depends on. These are expected to be the + * corresponding exports from `src/utils.js`, injected by the caller so that + * this library stays decoupled from core. + */ +export type FpdValidatorDeps = { + logWarn: (...args: any[]) => void; + isNumber: (val: unknown) => val is number; + isEmpty: (val: unknown) => boolean; + deepAccess: (obj: any, path: string) => any; + /** + * Deep clone, used to avoid mutating the caller's data when `filter` is false. + * Required when `filter` is false; unused otherwise. + */ + deepClone?: (obj: T) => T; +}; + +export type FpdValidatorOptions = { + /** + * Whether the validator removes invalid data from its input. This only affects + * the wording of the warnings: `true` (the default) reports data as "Filtered"; + * `false` reports it as "Invalid", for callers that inspect without altering the data. + */ + filter?: boolean; +}; + +/** + * Build an ortb2 first-party-data validator. + * @param deps utility functions from `src/utils.js` + * @param deps.logWarn warning logger + * @param deps.isNumber number type guard + * @param deps.isEmpty empty-value check + * @param deps.deepAccess dotted-path accessor + * @param deps.deepClone deep clone (used only when `filter` is false) + * @param options validator options + * @param options.filter whether invalid data is removed (controls warning wording and whether the input is modified) + * @returns `validateFpd` and `filterArrayData` bound to the injected utilities + */ +export function fpdValidator({ logWarn, isNumber, isEmpty, deepAccess, deepClone }: FpdValidatorDeps, { filter = true }: FpdValidatorOptions = {}) { + const label = filter ? 'Filtered' : 'Invalid'; + function isEmptyData(data) { + let check = true; + + if (typeof data === 'object' && !isEmpty(data)) { + check = false; + } else if (typeof data !== 'object' && (isNumber(data) || data)) { + check = false; + } + + return check; + } + + function getRequiredData(obj, required, parent, i) { + let check = true; + + required.forEach(key => { + if (!obj[key] || isEmptyData(obj[key])) { + check = false; + logWarn(`${label} ${parent}[] value at index ${i} in ortb2 data: missing required property ${key}`); + } + }); + + return check; + } + + function typeValidation(data, mapping) { + let check = false; + + switch (mapping.type) { + case 'string': + if (typeof data === 'string') check = true; + break; + case 'number': + if (typeof data === 'number' && isFinite(data)) check = true; + break; + case 'object': + if (typeof data === 'object') { + if ((Array.isArray(data) && mapping.isArray) || (!Array.isArray(data) && !mapping.isArray)) check = true; + } + break; + } + + return check; + } + + function filterArrayData(arr, child, path, parent, optout = false) { + arr = arr.filter((index, i) => { + const check = typeValidation(index, { type: child.type, isArray: child.isArray }); + + if (check && Array.isArray(index) === Boolean(child.isArray)) { + return true; + } + + logWarn(`${label} ${parent}[] value at index ${i} in ortb2 data: expected type ${child.type}`); + return false; + }).filter((index, i) => { + let requiredCheck = true; + const mapping = deepAccess(ORTB_MAP, path); + + if (mapping && mapping.required) requiredCheck = getRequiredData(index, mapping.required, parent, i); + + if (requiredCheck) return true; + return false; + }).reduce((result, value, i) => { + let typeBool = false; + const mapping = deepAccess(ORTB_MAP, path); + + switch (child.type) { + case 'string': + result.push(value); + typeBool = true; + break; + case 'object': + if (mapping && mapping.children) { + const validObject = validate(value, path + '.children.', parent + '.', optout); + if (Object.keys(validObject).length) { + const requiredCheck = getRequiredData(validObject, mapping.required, parent, i); + + if (requiredCheck) { + result.push(validObject); + typeBool = true; + } + } + } else { + result.push(value); + typeBool = true; + } + break; + } + + if (!typeBool) logWarn(`${label} ${parent}[] value at index ${i} in ortb2 data: expected type ${child.type}`); + + return result; + }, []); + + return arr; + } + + function validate(fpd, path = '', parent = '', optout = false) { + if (!fpd) return {}; + + const validObject = Object.assign({}, Object.keys(fpd).filter(key => { + const mapping = deepAccess(ORTB_MAP, path + key); + + if (!mapping || !mapping.invalid) return key; + + logWarn(`${label} ${parent}${key} property in ortb2 data: invalid property`); + return false; + }).filter(key => { + const mapping = deepAccess(ORTB_MAP, path + key); + const typeBool = (mapping) ? typeValidation(fpd[key], { type: mapping.type, isArray: mapping.isArray }) : true; + + if (typeBool || !mapping) return key; + + logWarn(`${label} ${parent}${key} property in ortb2 data: expected type ${(mapping.isArray) ? 'array' : mapping.type}`); + return false; + }).reduce((result, key) => { + const mapping = deepAccess(ORTB_MAP, path + key); + + if (mapping) { + if (mapping.optoutApplies && optout) { + logWarn(`${label} ${parent}${key} data: pubcid optout found`); + return result; + } + + const modified = (mapping.type === 'object' && !mapping.isArray) + ? validate(fpd[key], path + key + '.children.', parent + key + '.', optout) + : (mapping.isArray && mapping.childType) + ? filterArrayData(fpd[key], { type: mapping.childType, isArray: mapping.childisArray }, path + key, parent + key, optout) : fpd[key]; + + (!isEmptyData(modified)) ? result[key] = modified + : logWarn(`${label} ${parent}${key} property in ortb2 data: empty data found`); + } else { + result[key] = fpd[key]; + } + + return result; + }, {})); + + return validObject; + } + + /** + * Validate ortb2 first-party data. + * When `filter` is true, returns a copy with invalid data removed. + * When `filter` is false, the input is left untouched (validation runs against a + * clone purely to emit warnings) and the original object is returned unchanged. + * @throws when `filter` is false but no `deepClone` was provided, as the input + * cannot be inspected without risking mutation. + */ + function validateFpd(fpd, path = '', parent = '', optout = false) { + if (!filter) { + if (deepClone == null) { + throw new Error('fpdValidator: a deepClone dependency is required when filter is false'); + } + validate(deepClone(fpd), path, parent, optout); + return fpd; + } + return validate(fpd, path, parent, optout); + } + + return { validateFpd, filterArrayData }; +} diff --git a/modules/debugging/debugging.d.ts b/modules/debugging/debugging.d.ts index 4af51ffad..bbe601e28 100644 --- a/modules/debugging/debugging.d.ts +++ b/modules/debugging/debugging.d.ts @@ -7,7 +7,7 @@ export type DebugModuleConfiguration = { /** * Rules are evaluated on each bid in the order they are provided: the first one that has a matching when definition takes the bid out of the normal auction flow and replaces it according to its then definition. */ - intercept?: InterceptRule[] + intercept?: InterceptRule[]; }; export type InterceptRule = { diff --git a/modules/debugging/debugging.js b/modules/debugging/debugging.js index 1ea4223a2..60d532921 100644 --- a/modules/debugging/debugging.js +++ b/modules/debugging/debugging.js @@ -1,6 +1,7 @@ import { makebidInterceptor } from './bidInterceptor.js'; import { makePbsInterceptor } from './pbsInterceptor.js'; import { addHooks, removeHooks } from './legacy.js'; +import { configureFpdValidation, startAuctionFpdValidationHook } from './fpdValidation.js'; /** * @typedef {import('./debuggingModule.d.ts').DebugModuleConfiguration} DebugModuleConfiguration @@ -126,12 +127,14 @@ export function makeBidderBidInterceptor({ utils }) { }; } -export function install({ DEBUG_KEY, config, hook, createBid, logger, utils, BANNER, NATIVE, VIDEO, Renderer }) { +export function install({ DEBUG_KEY, config, hook, createBid, logger, utils, BANNER, NATIVE, VIDEO, Renderer, getPubcidOptout = () => false }) { + configureFpdValidation({ getOptout: getPubcidOptout, utils, logger }); const BidInterceptor = makebidInterceptor({ utils, BANNER, NATIVE, VIDEO, Renderer }); bidInterceptor = new BidInterceptor({ logger }); const pbsBidInterceptor = makePbsInterceptor({ createBid, utils }); registerBidInterceptor(() => hook.get('processBidderRequests'), makeBidderBidInterceptor({ utils })); registerBidInterceptor(() => hook.get('processPBSRequest'), pbsBidInterceptor); + hook.get('startAuction').before(startAuctionFpdValidationHook); sessionLoader({ DEBUG_KEY, config, hook, logger }); config.getConfig('debugging', ({ debugging }) => getConfig(debugging, { DEBUG_KEY, config, hook, logger, utils }), { init: true }); } diff --git a/modules/debugging/fpdValidation.ts b/modules/debugging/fpdValidation.ts new file mode 100644 index 000000000..e3ab37cdb --- /dev/null +++ b/modules/debugging/fpdValidation.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line prebid/validate-imports +import { fpdValidator } from '../../libraries/fpdUtils/validateFpd.js'; +import type { Logger } from "../../src/utils/logging.ts"; + +let getPubcidOptout = () => false; +let warn: Logger['logWarn']; +let validateFpd; + +export function configureFpdValidation({ getOptout, utils, logger }: { + getOptout?: () => boolean; + utils?: any; + logger?: Logger; +} = {}) { + if (getOptout) { + getPubcidOptout = getOptout; + } + if (utils && logger) { + warn = logger.logWarn; + const { isNumber, isEmpty, deepAccess, deepClone } = utils; + // debugging inspects the data without altering it, so validate against a clone and + // report invalid data as "Invalid" rather than "Filtered" + ({ validateFpd } = fpdValidator({ logWarn: logger.logWarn, isNumber, isEmpty, deepAccess, deepClone }, { filter: false })); + } +} + +export function validateOrtb2ForDebug(ortb2) { + if (ortb2 == null || validateFpd == null) return ortb2; + return validateFpd(ortb2, '', '', getPubcidOptout()); +} + +export function validateOrtb2Fragments(ortb2Fragments) { + if (ortb2Fragments == null) return; + validateOrtb2ForDebug(ortb2Fragments.global); + Object.values(ortb2Fragments.bidder || {}).forEach((ortb2) => { + validateOrtb2ForDebug(ortb2); + }); +} + +export function startAuctionFpdValidationHook(next, req) { + // FPD validation is a debugging aid; never let it break the auction. + try { + validateOrtb2Fragments(req.ortb2Fragments); + } catch (e) { + warn('Error validating ortb2 first-party data', e); + } + next.call(this, req); +} diff --git a/modules/debugging/index.js b/modules/debugging/index.js index 87d80ddc2..ba4639f49 100644 --- a/modules/debugging/index.js +++ b/modules/debugging/index.js @@ -5,7 +5,7 @@ import { hook } from '../../src/hook.js'; import { install } from './debugging.js'; import { prefixLog } from '../../src/utils.js'; import { createBid } from '../../src/bidfactory.js'; -import { DEBUG_KEY } from '../../src/debugging.js'; +import { DEBUG_KEY, getPubcidOptout } from '../../src/debugging.js'; import * as utils from '../../src/utils.js'; import { BANNER, NATIVE, VIDEO } from '../../src/mediaTypes.js'; import { Renderer } from '../../src/Renderer.js'; @@ -21,4 +21,5 @@ install({ NATIVE, VIDEO, Renderer, + getPubcidOptout, }); diff --git a/modules/validationFpdModule/index.ts b/modules/validationFpdModule/index.ts index 324fee1e4..0696ad66b 100644 --- a/modules/validationFpdModule/index.ts +++ b/modules/validationFpdModule/index.ts @@ -3,212 +3,29 @@ * @module modules/firstPartyData */ import { deepAccess, isEmpty, isNumber, logWarn } from '../../src/utils.js'; -import { ORTB_MAP } from './config.js'; +import { hasPubcidOptout } from '../../libraries/fpdUtils/pubcidOptout.js'; +import { fpdValidator } from '../../libraries/fpdUtils/validateFpd.js'; import { submodule } from '../../src/hook.js'; import { getCoreStorageManager } from '../../src/storageManager.js'; // TODO: do FPD modules need their own namespace? const STORAGE = getCoreStorageManager('FPDValidation'); -let optout; -/** - * Check if data passed is empty - * @param {*} data to test against - * @returns {Boolean} is data empty - */ -function isEmptyData(data) { - let check = true; - - if (typeof data === 'object' && !isEmpty(data)) { - check = false; - } else if (typeof data !== 'object' && (isNumber(data) || data)) { - check = false; - } - - return check; -} - -/** - * Check if required keys exist in data object - * @param {Object} obj data object - * @param {Array} required array of required keys - * @param {String} parent object path (for printing warning) - * @param {Number} i index of object value in the data array (for printing warning) - * @returns {Boolean} is requirements fulfilled - */ -function getRequiredData(obj, required, parent, i) { - let check = true; +const { validateFpd } = fpdValidator({ logWarn, isNumber, isEmpty, deepAccess }); - required.forEach(key => { - if (!obj[key] || isEmptyData(obj[key])) { - check = false; - logWarn(`Filtered ${parent}[] value at index ${i} in ortb2 data: missing required property ${key}`); - } - }); - - return check; -} - -/** - * Check if data type is valid - * @param {*} data value to test against - * @param {Object} mapping object containing type definition and if should be array bool - * @returns {Boolean} is type fulfilled - */ -function typeValidation(data, mapping) { - let check = false; - - switch (mapping.type) { - case 'string': - if (typeof data === 'string') check = true; - break; - case 'number': - if (typeof data === 'number' && isFinite(data)) check = true; - break; - case 'object': - if (typeof data === 'object') { - if ((Array.isArray(data) && mapping.isArray) || (!Array.isArray(data) && !mapping.isArray)) check = true; - } - break; +declare module '../../src/fpd/enrichment' { + interface FirstPartyDataConfig { + skipValidations?: boolean; } - - return check; -} - -/** - * Validates ortb2 data arrays and filters out invalid data - * @param {Array} arr ortb2 data array - * @param {Object} child object defining child type and if array - * @param {String} path config path of data array - * @param {String} parent parent path for logging warnings - * @returns {Array} validated/filtered data - */ -export function filterArrayData(arr, child, path, parent) { - arr = arr.filter((index, i) => { - const check = typeValidation(index, { type: child.type, isArray: child.isArray }); - - if (check && Array.isArray(index) === Boolean(child.isArray)) { - return true; - } - - logWarn(`Filtered ${parent}[] value at index ${i} in ortb2 data: expected type ${child.type}`); - return false; - }).filter((index, i) => { - let requiredCheck = true; - const mapping = deepAccess(ORTB_MAP, path); - - if (mapping && mapping.required) requiredCheck = getRequiredData(index, mapping.required, parent, i); - - if (requiredCheck) return true; - return false; - }).reduce((result, value, i) => { - let typeBool = false; - const mapping = deepAccess(ORTB_MAP, path); - - switch (child.type) { - case 'string': - result.push(value); - typeBool = true; - break; - case 'object': - if (mapping && mapping.children) { - const validObject = validateFpd(value, path + '.children.', parent + '.'); - if (Object.keys(validObject).length) { - const requiredCheck = getRequiredData(validObject, mapping.required, parent, i); - - if (requiredCheck) { - result.push(validObject); - typeBool = true; - } - } - } else { - result.push(value); - typeBool = true; - } - break; - } - - if (!typeBool) logWarn(`Filtered ${parent}[] value at index ${i} in ortb2 data: expected type ${child.type}`); - - return result; - }, []); - - return arr; } -/** - * Validates ortb2 object and filters out invalid data - * @param {Object} fpd ortb2 object - * @param {String} path config path of data array - * @param {String} parent parent path for logging warnings - * @returns {Object} validated/filtered data - */ -export function validateFpd(fpd, path = '', parent = '') { - if (!fpd) return {}; - - // Filter out imp property if exists - const validObject = Object.assign({}, Object.keys(fpd).filter(key => { - const mapping = deepAccess(ORTB_MAP, path + key); - - if (!mapping || !mapping.invalid) return key; - - logWarn(`Filtered ${parent}${key} property in ortb2 data: invalid property`); - return false; - }).filter(key => { - const mapping = deepAccess(ORTB_MAP, path + key); - // let typeBool = false; - const typeBool = (mapping) ? typeValidation(fpd[key], { type: mapping.type, isArray: mapping.isArray }) : true; - - if (typeBool || !mapping) return key; - - logWarn(`Filtered ${parent}${key} property in ortb2 data: expected type ${(mapping.isArray) ? 'array' : mapping.type}`); - return false; - }).reduce((result, key) => { - const mapping = deepAccess(ORTB_MAP, path + key); - - if (mapping) { - if (mapping.optoutApplies && optout) { - logWarn(`Filtered ${parent}${key} data: pubcid optout found`); - return result; - } - - const modified = (mapping.type === 'object' && !mapping.isArray) - ? validateFpd(fpd[key], path + key + '.children.', parent + key + '.') - : (mapping.isArray && mapping.childType) - ? filterArrayData(fpd[key], { type: mapping.childType, isArray: mapping.childisArray }, path + key, parent + key) : fpd[key]; - - // Check if modified data has data and return - (!isEmptyData(modified)) ? result[key] = modified - : logWarn(`Filtered ${parent}${key} property in ortb2 data: empty data found`); - } else { - result[key] = fpd[key]; - } - - return result; - }, {})); - - // Return validated data - return validObject; -} - -/** - * Run validation on global and bidder config data for ortb2 - * @param {Object} data global and bidder config data - * @returns {Object} validated data - */ -function runValidations(data) { +function runValidations(data, optout) { return { - global: validateFpd(data.global), - bidder: Object.fromEntries(Object.entries(data.bidder).map(([bidder, conf]) => [bidder, validateFpd(conf)])) + global: validateFpd(data.global, '', '', optout), + bidder: Object.fromEntries(Object.entries(data.bidder).map(([bidder, conf]) => [bidder, validateFpd(conf, '', '', optout)])) }; } -declare module '../../src/fpd/enrichment' { - interface FirstPartyDataConfig { - skipValidations?: boolean; - } -} - /** * Sets default values to ortb2 if exists and adds currency and ortb2 setConfig callbacks on init * @param {Object} fpdConf configuration object @@ -216,12 +33,9 @@ declare module '../../src/fpd/enrichment' { * @returns {Object} processed data */ export function processFpd(fpdConf, data) { - // Checks for existsnece of pubcid optout cookie/storage - // if exists, filters user data out - optout = (STORAGE.cookiesAreEnabled() && STORAGE.getCookie('_pubcid_optout')) || - (STORAGE.hasLocalStorage() && STORAGE.getDataFromLocalStorage('_pubcid_optout')); + const optout = hasPubcidOptout(STORAGE); - return (!fpdConf.skipValidations) ? runValidations(data) : data; + return (!fpdConf.skipValidations) ? runValidations(data, optout) : data; } /** @type {{name: string, queue: number, processFpd: function}} */ diff --git a/src/config.ts b/src/config.ts index bfb414cbd..3c51b605d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -393,7 +393,7 @@ export function newConfig() { const conf = _getConfig(); Object.defineProperty(conf, 'ortb2', { get: function () { - throw new Error('invalid access to \'orbt2\' config - use request parameters instead'); + throw new Error('invalid access to \'ortb2\' config - use request parameters instead'); } }); return conf; diff --git a/src/debugging.js b/src/debugging.js index 86e4909ee..68798b0c9 100644 --- a/src/debugging.js +++ b/src/debugging.js @@ -9,9 +9,17 @@ import { MODULE_TYPE_PREBID } from './activities/modules.js'; import * as utils from './utils.js'; import { BANNER, NATIVE, VIDEO } from './mediaTypes.js'; import { Renderer } from './Renderer.js'; +import { getCoreStorageManager } from './storageManager.js'; +import { hasPubcidOptout } from '../libraries/fpdUtils/pubcidOptout.js'; import { getDistUrlBase, getGlobalVarName } from './buildOptions.js'; +const STORAGE = getCoreStorageManager('debugging'); + +export function getPubcidOptout() { + return hasPubcidOptout(STORAGE); +} + export const DEBUG_KEY = `__${getGlobalVarName()}_debugging__`; function isDebuggingInstalled() { @@ -48,7 +56,8 @@ export function debuggingModuleLoader({ alreadyInstalled = isDebuggingInstalled, BANNER, NATIVE, VIDEO, - Renderer + Renderer, + getPubcidOptout, }); }).then(resolve, reject); } diff --git a/test/spec/libraries/fpdUtils/validateFpd_spec.js b/test/spec/libraries/fpdUtils/validateFpd_spec.js new file mode 100644 index 000000000..78eff7b1b --- /dev/null +++ b/test/spec/libraries/fpdUtils/validateFpd_spec.js @@ -0,0 +1,103 @@ +import { expect } from 'chai'; +import * as utils from 'src/utils.js'; +import { fpdValidator } from 'libraries/fpdUtils/validateFpd.js'; + +describe('validateFpd library', () => { + const { validateFpd } = fpdValidator(utils); + + it('should filter invalid ortb2 fields', () => { + const validated = validateFpd({ + imp: { id: '1' }, + device: { w: 1920, h: 1080 }, + user: { + yob: 'not-a-number', + data: [{ + name: 'bar', + ext: 'string', + segment: [{ id: 'foo' }], + }], + }, + }); + + expect(validated).to.deep.equal({ + device: { w: 1920, h: 1080 }, + user: { + data: [{ + name: 'bar', + segment: [{ id: 'foo' }], + }], + }, + }); + }); + + it('should preserve valid ortb2', () => { + const input = { + device: { w: 1920, h: 1080 }, + site: { domain: 'example.com' }, + }; + + expect(validateFpd(input)).to.deep.equal(input); + }); + + it('should filter optout-applicable user fields when optout is true', () => { + const validated = validateFpd({ + user: { + yob: 1990, + gender: 'M', + keywords: 'sports', + }, + }, '', '', true); + + expect(validated).to.deep.equal({ + user: { + keywords: 'sports', + }, + }); + }); + + it('should log Filtered warnings through the injected logWarn', () => { + const logWarn = sinon.spy(); + const { validateFpd } = fpdValidator({ ...utils, logWarn }); + + validateFpd({ imp: { id: '1' } }); + + expect(logWarn.firstCall.args[0]).to.match(/^Filtered /); + }); + + it('should log Invalid (not Filtered) warnings when filter option is false', () => { + const logWarn = sinon.spy(); + const { validateFpd } = fpdValidator({ ...utils, logWarn }, { filter: false }); + + validateFpd({ imp: { id: '1' } }); + + expect(logWarn.firstCall.args[0]).to.match(/^Invalid /); + expect(logWarn.firstCall.args[0]).to.not.match(/^Filtered /); + }); + + it('should return the input unchanged and not mutate it when filter is false', () => { + const logWarn = sinon.spy(); + const { validateFpd } = fpdValidator({ ...utils, logWarn }, { filter: false }); + const input = { + imp: { id: '1' }, + device: { w: 1920, h: 1080 }, + user: { yob: 'not-a-number' }, + }; + const snapshot = utils.deepClone(input); + + const result = validateFpd(input); + + // validation ran (invalid fields present) but the input is untouched and returned as-is + expect(logWarn.called).to.be.true; + expect(result).to.equal(input); + expect(input).to.deep.equal(snapshot); + }); + + it('should throw when filter is false and no deepClone is provided', () => { + const logWarn = sinon.spy(); + const { validateFpd } = fpdValidator({ ...utils, logWarn, deepClone: undefined }, { filter: false }); + const input = { imp: { id: '1' } }; + + expect(() => validateFpd(input)).to.throw(); + expect(logWarn.called).to.be.false; + }); +}); diff --git a/test/spec/modules/debugging_mod_spec.js b/test/spec/modules/debugging_mod_spec.js index 51efca450..3e01978b1 100644 --- a/test/spec/modules/debugging_mod_spec.js +++ b/test/spec/modules/debugging_mod_spec.js @@ -8,6 +8,11 @@ import { } from '../../../modules/debugging/debugging.js'; import '../../../modules/debugging/index.js'; import { makePbsInterceptor } from '../../../modules/debugging/pbsInterceptor.js'; +import { + configureFpdValidation, + startAuctionFpdValidationHook, + validateOrtb2ForDebug, +} from '../../../modules/debugging/fpdValidation.js'; import { config } from '../../../src/config.js'; import { hook } from '../../../src/hook.js'; import { @@ -299,6 +304,112 @@ describe('Debugging config', () => { }); }); +describe('fpdValidation', () => { + const invalidOrtb2 = { + imp: { id: 'invalid-top-level-property' }, + user: { yob: 'not-a-number' }, + }; + + it('should validate against a clone and keep original ortb2 untouched', () => { + const logWarn = sinon.spy(); + configureFpdValidation({ utils, logger: { logWarn } }); + const result = validateOrtb2ForDebug(invalidOrtb2); + expect(result).to.equal(invalidOrtb2); + expect(result.imp).to.deep.equal({ id: 'invalid-top-level-property' }); + expect(result.user).to.deep.equal({ yob: 'not-a-number' }); + // debugging does not alter data, so warnings read "Invalid" rather than "Filtered" + expect(logWarn.firstCall.args[0]).to.match(/^Invalid /); + }); + + it('should throw when deepClone is not available', () => { + const logWarn = sinon.spy(); + configureFpdValidation({ utils: { ...utils, deepClone: undefined }, logger: { logWarn } }); + expect(() => validateOrtb2ForDebug(invalidOrtb2)).to.throw(); + expect(logWarn.called).to.be.false; + }); + + it('should validate global and bidder ortb2 on startAuction', () => { + const bidderOrtb2 = { user: { yob: 'bidder-not-a-number' } }; + const globalOrtb2 = { ...invalidOrtb2 }; + const logWarn = sinon.spy(); + configureFpdValidation({ utils, logger: { logWarn } }); + const next = sinon.stub(); + + startAuctionFpdValidationHook(next, { + ortb2Fragments: { + global: globalOrtb2, + bidder: { testBidder: bidderOrtb2 } + } + }); + + expect(next.calledOnce).to.be.true; + expect(logWarn.callCount).to.be.at.least(2); + expect(globalOrtb2.imp).to.deep.equal({ id: 'invalid-top-level-property' }); + expect(bidderOrtb2.user.yob).to.equal('bidder-not-a-number'); + }); + + it('should never mutate req.ortb2Fragments, even when validation filters data', () => { + const logWarn = sinon.spy(); + configureFpdValidation({ utils, logger: { logWarn } }); + const req = { + ortb2Fragments: { + global: { + imp: { id: 'invalid-top-level-property' }, + user: { yob: 'not-a-number' }, + device: { w: 1920, h: 1080 }, + }, + bidder: { + bidderA: { user: { yob: 'bidder-not-a-number' }, site: { domain: 'example.com' } }, + }, + }, + }; + const snapshot = utils.deepClone(req.ortb2Fragments); + + startAuctionFpdValidationHook(sinon.stub(), req); + + // validation actually ran against the invalid data... + expect(logWarn.called).to.be.true; + // ...but the request fragments are left untouched + expect(req.ortb2Fragments).to.deep.equal(snapshot); + }); + + it('should always call next passing it the original request', () => { + configureFpdValidation({ utils, logger: { logWarn: sinon.spy() } }); + const next = sinon.stub(); + const req = { ortb2Fragments: { global: { user: { yob: 'not-a-number' } }, bidder: {} } }; + + startAuctionFpdValidationHook(next, req); + + expect(next.calledOnce).to.be.true; + expect(next.firstCall.args[0]).to.equal(req); + }); + + it('should call next passing it the original request when there is nothing to validate', () => { + configureFpdValidation({ utils, logger: { logWarn: sinon.spy() } }); + const next = sinon.stub(); + const req = {}; + + startAuctionFpdValidationHook(next, req); + + expect(next.calledOnce).to.be.true; + expect(next.firstCall.args[0]).to.equal(req); + }); + + it('should call next passing it the original request even if validation throws', () => { + const logWarn = sinon.spy(); + configureFpdValidation({ utils, logger: { logWarn } }); + const next = sinon.stub(); + const req = {}; + Object.defineProperty(req, 'ortb2Fragments', { + get() { throw new Error('boom'); } + }); + + expect(() => startAuctionFpdValidationHook(next, req)).to.not.throw(); + expect(next.calledOnce).to.be.true; + expect(next.firstCall.args[0]).to.equal(req); + }); +}); + describe('bidderBidInterceptor', () => { let next, interceptBids, onCompletion, interceptResult, done, addBid, wrapCallback, wrapped, bidderBidInterceptor; diff --git a/test/spec/modules/validationFpdModule_spec.js b/test/spec/modules/validationFpdModule_spec.js index e1cde9d04..bc666199b 100644 --- a/test/spec/modules/validationFpdModule_spec.js +++ b/test/spec/modules/validationFpdModule_spec.js @@ -1,9 +1,8 @@ import { expect } from 'chai'; import * as utils from 'src/utils.js'; -import { - filterArrayData, - validateFpd -} from 'modules/validationFpdModule/index.js'; +import { fpdValidator } from '../../../libraries/fpdUtils/validateFpd.js'; + +const { filterArrayData, validateFpd } = fpdValidator(utils); describe('the first party data validation module', function () { const ortb2 = { From 5e132be86edd10a409e91659ca88d8fd3ea0d33b Mon Sep 17 00:00:00 2001 From: Patrick McCann Date: Thu, 23 Jul 2026 14:08:45 -0400 Subject: [PATCH 07/21] Core: remove Topics API handling (#15369) * Core: remove Topics API handling * Potential fix for pull request finding 'Superfluous trailing arguments' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Superfluous trailing arguments' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Superfluous trailing arguments' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Superfluous trailing arguments' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Deprecate topicsHeader in BidderSettings interface Add deprecation notice for topicsHeader option. * Update bidderSettings.ts --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Demetrio Girardi --- modules/topicsFpdModule.js | 264 ++-------- src/adapters/bidderFactory.ts | 12 +- src/ajax.ts | 20 - src/bidderSettings.ts | 2 +- test/spec/modules/topicsFpdModule_spec.js | 560 ++-------------------- test/spec/unit/core/ajax_spec.js | 28 -- test/spec/unit/core/bidderFactory_spec.js | 105 +--- 7 files changed, 63 insertions(+), 928 deletions(-) diff --git a/modules/topicsFpdModule.js b/modules/topicsFpdModule.js index 8aedd2725..6a3457ca3 100644 --- a/modules/topicsFpdModule.js +++ b/modules/topicsFpdModule.js @@ -1,263 +1,53 @@ -import { isEmpty, logError, logWarn, mergeDeep, safeJSONParse } from '../src/utils.js'; -import { getRefererInfo } from '../src/refererDetection.js'; +import { logWarn } from '../src/utils.js'; import { submodule } from '../src/hook.js'; -import { PbPromise } from '../src/utils/promise.js'; -import { config } from '../src/config.js'; -import { getCoreStorageManager } from '../src/storageManager.js'; -import { isActivityAllowed } from '../src/activities/rules.js'; -import { ACTIVITY_ENRICH_UFPD } from '../src/activities/activities.js'; -import { activityParams } from '../src/activities/activityParams.js'; -import { MODULE_TYPE_BIDDER } from '../src/activities/modules.js'; - -const MODULE_NAME = 'topicsFpd'; -const DEFAULT_EXPIRATION_DAYS = 21; -const DEFAULT_FETCH_RATE_IN_DAYS = 1; -let LOAD_TOPICS_INITIALISE = false; -let iframeLoadedURL = []; - -export function reset() { - LOAD_TOPICS_INITIALISE = false; - iframeLoadedURL = []; -} - -export const coreStorage = getCoreStorageManager(MODULE_NAME); +const WARNING = 'The Topics API has been removed from Chrome; topicsFpdModule is now a no-op.'; export const topicStorageName = 'prebid:topics'; export const lastUpdated = 'lastUpdated'; -const TAXONOMIES = { - // map from topic taxonomyVersion to IAB segment taxonomy - '1': 600, - '2': 601, - '3': 602, - '4': 603 -}; - -function partitionBy(field, items) { - return items.reduce((partitions, item) => { - const key = item[field]; - if (!partitions.hasOwnProperty(key)) partitions[key] = []; - partitions[key].push(item); - return partitions; - }, {}); -} - -/** - * function to get list of loaded Iframes calling Topics API - */ -function getLoadedIframeURL() { - return iframeLoadedURL; -} - -/** - * function to set/push iframe in the list which is loaded to called topics API. - */ -function setLoadedIframeURL(url) { - return iframeLoadedURL.push(url); -} - -export function getTopicsData(name, topics, taxonomies = TAXONOMIES) { - return Object.entries(partitionBy('taxonomyVersion', topics)) - .filter(([taxonomyVersion]) => { - if (!taxonomies.hasOwnProperty(taxonomyVersion)) { - logWarn(`Unrecognized taxonomyVersion from Topics API: "${taxonomyVersion}"; topic will be ignored`); - return false; - } - return true; - }).flatMap(([taxonomyVersion, topics]) => - Object.entries(partitionBy('modelVersion', topics)) - .map(([modelVersion, topics]) => { - const datum = { - ext: { - segtax: taxonomies[taxonomyVersion], - segclass: modelVersion - }, - segment: topics.map((topic) => ({ id: topic.topic.toString() })) - }; - if (name != null) { - datum.name = name; - } - - return datum; - }) - ); -} - -function isTopicsSupported(doc = document) { - return 'browsingTopics' in doc && doc.featurePolicy.allowsFeature('browsing-topics'); -} - -export function getTopics(doc = document) { - let topics = null; +let warned = false; - try { - if (isTopicsSupported(doc)) { - topics = PbPromise.resolve(doc.browsingTopics()); - } - } catch (e) { - logError('Could not call topics API', e); - } - if (topics == null) { - topics = PbPromise.resolve([]); +function warn() { + if (!warned) { + logWarn(WARNING); + warned = true; } - - return topics; } -const topicsData = getTopics().then((topics) => getTopicsData(getRefererInfo().domain, topics)); - -export function processFpd(config, { global }, { data = topicsData } = {}) { - if (!LOAD_TOPICS_INITIALISE) { - loadTopicsForBidders(); - LOAD_TOPICS_INITIALISE = true; - } - return data.then((data) => { - data = [].concat(data, getCachedTopics()); // Add cached data in FPD data. - if (data.length) { - mergeDeep(global, { - user: { - data - } - }); - } - return { global }; - }); +export function reset() { + warned = false; } -/** - * function to fetch the cached topic data from storage for bidders and return it - */ -export function getCachedTopics() { - const cachedTopicData = []; - const topics = config.getConfig('userSync.topics'); - const bidderList = topics?.bidders || []; - const storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName))); - storedSegments.forEach((value, cachedBidder) => { - // Check bidder exist in config for cached bidder data and then only retrieve the cached data - const bidderConfigObj = bidderList.find(({ bidder }) => cachedBidder === bidder); - if (bidderConfigObj && isActivityAllowed(ACTIVITY_ENRICH_UFPD, activityParams(MODULE_TYPE_BIDDER, cachedBidder))) { - if (!isCachedDataExpired(value[lastUpdated], bidderConfigObj?.expiry || DEFAULT_EXPIRATION_DAYS)) { - Object.keys(value).forEach((segData) => { - segData !== lastUpdated && cachedTopicData.push(value[segData]); - }); - } else { - // delete the specific bidder map from the store and store the updated maps - storedSegments.delete(cachedBidder); - coreStorage.setDataInLocalStorage(topicStorageName, JSON.stringify([...storedSegments])); - } - } - }); - return cachedTopicData; +export function getTopicsData() { + warn(); + return []; } -/** - * Receive messages from iframe loaded for bidders to fetch topic - * @param {MessageEvent} evt - */ -export function receiveMessage(evt) { - if (evt && evt.data) { - try { - const data = safeJSONParse(evt.data); - if (getLoadedIframeURL().includes(evt.origin) && data && data.segment && !isEmpty(data.segment.topics)) { - const { domain, topics, bidder } = data.segment; - const iframeTopicsData = getTopicsData(domain, topics); - iframeTopicsData && storeInLocalStorage(bidder, iframeTopicsData); - } - } catch (err) { } - } +export function getTopics() { + warn(); + return Promise.resolve([]); } -/** -Function to store Topics data received from iframe in storage(name: "prebid:topics") - * @param {string} bidder - * @param {object} topics - */ -export function storeInLocalStorage(bidder, topics) { - const storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName))); - const topicsObj = { - [lastUpdated]: new Date().getTime() - }; - - topics.forEach((topic) => { - topicsObj[topic.ext.segclass] = topic; - }); - - storedSegments.set(bidder, topicsObj); - coreStorage.setDataInLocalStorage(topicStorageName, JSON.stringify([...storedSegments])); +export function processFpd(config, { global }) { + warn(); + return Promise.resolve({ global }); } -function isCachedDataExpired(storedTime, cacheTime) { - const _MS_PER_DAY = 1000 * 60 * 60 * 24; - const currentTime = new Date().getTime(); - const daysDifference = Math.ceil((currentTime - storedTime) / _MS_PER_DAY); - return daysDifference > cacheTime; +export function getCachedTopics() { + warn(); + return []; } -/** - * Function to get random bidders based on count passed with array of bidders - */ -function getRandomAllowedConfigs(arr, count) { - const configs = []; - for (const config of [...arr].sort(() => 0.5 - Math.random())) { - if (config.bidder && isActivityAllowed(ACTIVITY_ENRICH_UFPD, activityParams(MODULE_TYPE_BIDDER, config.bidder))) { - configs.push(config); - } - if (configs.length >= count) { - break; - } - } - return configs; +export function receiveMessage() { + warn(); } -/** - * function to add listener for message receiving from IFRAME - */ -function listenMessagesFromTopicIframe() { - window.addEventListener('message', receiveMessage, false); +export function storeInLocalStorage() { + warn(); } -/** - * function to load the iframes of the bidder to load the topics data - */ -export function loadTopicsForBidders(doc = document) { - if (!isTopicsSupported(doc)) return; - const topics = config.getConfig('userSync.topics'); - - if (topics) { - listenMessagesFromTopicIframe(); - const randomBidders = getRandomAllowedConfigs(topics.bidders || [], topics.maxTopicCaller || 1); - randomBidders.forEach(({ bidder, iframeURL, fetchUrl, fetchRate }) => { - if (bidder && iframeURL) { - const ifrm = doc.createElement('iframe'); - ifrm.name = 'ifrm_'.concat(bidder); - ifrm.src = ''.concat(iframeURL, '?bidder=').concat(bidder); - ifrm.style.display = 'none'; - setLoadedIframeURL(new URL(iframeURL).origin); - doc.documentElement.appendChild(ifrm); - } - - if (bidder && fetchUrl) { - const storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName))); - const bidderLsEntry = storedSegments.get(bidder); - - if (!bidderLsEntry || (bidderLsEntry && isCachedDataExpired(bidderLsEntry[lastUpdated], fetchRate || DEFAULT_FETCH_RATE_IN_DAYS))) { - window.fetch(`${fetchUrl}?bidder=${bidder}`, { browsingTopics: true }) - .then(response => { - return response.json(); - }) - .then(data => { - if (data && data.segment && !isEmpty(data.segment.topics)) { - const { domain, topics, bidder } = data.segment; - const fetchTopicsData = getTopicsData(domain, topics); - fetchTopicsData && storeInLocalStorage(bidder, fetchTopicsData); - } - }); - } - } - }); - } else { - logWarn(`Topics config not defined under userSync Object`); - } +export function loadTopicsForBidders() { + warn(); } submodule('firstPartyData', { diff --git a/src/adapters/bidderFactory.ts b/src/adapters/bidderFactory.ts index 2900cac3e..f1d0b5c0d 100644 --- a/src/adapters/bidderFactory.ts +++ b/src/adapters/bidderFactory.ts @@ -35,7 +35,7 @@ import { useMetrics } from '../utils/perfMetrics.js'; import { isActivityAllowed } from '../activities/rules.js'; import { activityParams } from '../activities/activityParams.js'; import { MODULE_TYPE_BIDDER } from '../activities/modules.js'; -import { ACTIVITY_TRANSMIT_TID, ACTIVITY_TRANSMIT_UFPD } from '../activities/activities.js'; +import { ACTIVITY_TRANSMIT_TID } from '../activities/activities.js'; import type { AnyFunction, Wraps } from "../types/functions.d.ts"; import type { BidderCode, StorageDisclosure } from "../types/common.d.ts"; import type { Ajax, AjaxOptions, XHR } from "../ajax.ts"; @@ -514,15 +514,7 @@ export const processBidderRequests = hook('async', function { - // the Request constructor will throw an exception if the browser supports topics - // but we're not in a secure context - if (options[opt]) { - rqOpts[opt] = true; - } - }); - if (options.suppressTopicsEnrollmentWarning != null) { - rqOpts.suppressTopicsEnrollmentWarning = options.suppressTopicsEnrollmentWarning; - } - } const request = dep.makeRequest(url, rqOpts); if (options.keepalive) { // do not set the "real" keepalive flag as Safari won't allow us to change it diff --git a/src/bidderSettings.ts b/src/bidderSettings.ts index a266b57f5..23ecad587 100644 --- a/src/bidderSettings.ts +++ b/src/bidderSettings.ts @@ -40,7 +40,7 @@ export interface BidderSettings { */ allowedAlternateBidderCodes?: ['*'] | BidderCode[]; /** - * If true (the default), allow the `Sec-Browsing-Topics` header in requests to their exchange. + * @deprecated Prebid no longer supports browsing topics and this option has no effect. */ topicsHeader?: boolean; } diff --git a/test/spec/modules/topicsFpdModule_spec.js b/test/spec/modules/topicsFpdModule_spec.js index 73b95d878..ba185e35c 100644 --- a/test/spec/modules/topicsFpdModule_spec.js +++ b/test/spec/modules/topicsFpdModule_spec.js @@ -6,555 +6,59 @@ import { processFpd, receiveMessage, reset, + storeInLocalStorage, topicStorageName } from '../../../modules/topicsFpdModule.js'; -import { config } from 'src/config.js'; -import { deepClone, safeJSONParse } from '../../../src/utils.js'; -import { getCoreStorageManager } from 'src/storageManager.js'; -import { registerActivityControl } from '../../../src/activities/rules.js'; -import { ACTIVITY_ENRICH_UFPD } from '../../../src/activities/activities.js'; +import * as utils from '../../../src/utils.js'; describe('topics', () => { - let unregister, enrichUfpdRule; - before(() => { - unregister = registerActivityControl(ACTIVITY_ENRICH_UFPD, 'test', (params) => enrichUfpdRule(params), 0); - }); - after(() => { - unregister(); - }); + let sandbox; beforeEach(() => { - enrichUfpdRule = () => ({ allow: true }); + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'logWarn'); reset(); }); - describe('getTopicsData', () => { - function makeTopic(topic, modelv, taxv = '1') { - return { - topic, - taxonomyVersion: taxv, - modelVersion: modelv - }; - } - - function byTaxClass(segments) { - return segments.reduce((memo, segment) => { - memo[`${segment.ext.segtax}:${segment.ext.segclass}`] = segment; - return memo; - }, {}); - } - - [ - { - t: 'no topics', - topics: [], - expected: [] - }, - { - t: 'single topic', - topics: [makeTopic(123, 'm1')], - expected: [ - { - ext: { - segtax: 600, - segclass: 'm1' - }, - segment: [ - { id: '123' } - ] - } - ] - }, - { - t: 'multiple topics with the same model version', - topics: [makeTopic(123, 'm1'), makeTopic(321, 'm1')], - expected: [ - { - ext: { - segtax: 600, - segclass: 'm1' - }, - segment: [ - { id: '123' }, - { id: '321' } - ] - } - ] - }, - { - t: 'multiple topics with different model versions', - topics: [makeTopic(1, 'm1'), makeTopic(2, 'm1'), makeTopic(3, 'm2')], - expected: [ - { - ext: { - segtax: 600, - segclass: 'm1' - }, - segment: [ - { id: '1' }, - { id: '2' } - ] - }, - { - ext: { - segtax: 600, - segclass: 'm2' - }, - segment: [ - { id: '3' } - ] - } - ] - }, - { - t: 'multiple topics, some with a taxonomy version other than "1"', - topics: [makeTopic(123, 'm1'), makeTopic(321, 'm1', 'other')], - expected: [ - { - ext: { - segtax: 600, - segclass: 'm1' - }, - segment: [ - { id: '123' } - ] - } - ] - }, - { - t: 'multiple topics in multiple taxonomies', - taxonomies: { - '1': 600, - '2': 601 - }, - topics: [ - makeTopic(123, 'm1', '1'), - makeTopic(321, 'm1', '2'), - makeTopic(213, 'm2', '1'), - ], - expected: [ - { - ext: { - segtax: 600, - segclass: 'm1' - }, - segment: [ - { id: '123' } - ] - }, - { - ext: { - segtax: 601, - segclass: 'm1', - }, - segment: [ - { id: '321' } - ] - }, - { - ext: { - segtax: 600, - segclass: 'm2' - }, - segment: [ - { id: '213' } - ] - } - ] - } - ].forEach(({ t, topics, expected, taxonomies }) => { - describe(`on ${t}`, () => { - it('should convert topics to user.data segments correctly', () => { - const actual = getTopicsData('mockName', topics, taxonomies); - expect(actual.length).to.eql(expected.length); - expected = byTaxClass(expected); - Object.entries(byTaxClass(actual)).forEach(([key, datum]) => { - sinon.assert.match(datum, expected[key]); - expect(datum.name).to.equal('mockName'); - }); - }); - - it('should not set name if null', () => { - getTopicsData(null, topics).forEach((data) => { - expect(data.hasOwnProperty('name')).to.be.false; - }); - }); - }); - }); + afterEach(() => { + sandbox.restore(); }); - describe('getTopics', () => { - Object.entries({ - 'document with no browsingTopics': {}, - 'document that disallows topics': { - featurePolicy: { - allowsFeature: sinon.stub().returns(false) - } - }, - 'document that throws on featurePolicy': { - browsingTopics: sinon.stub(), - get featurePolicy() { - throw new Error(); - } - }, - 'document that throws on browsingTopics': { - browsingTopics: sinon.stub().callsFake(() => { - throw new Error(); - }), - featurePolicy: { - allowsFeature: sinon.stub().returns(true) - } - }, - }).forEach(([t, doc]) => { - it(`should resolve to an empty list on ${t}`, () => { - return getTopics(doc).then((topics) => { - expect(topics).to.eql([]); - }); - }); - }); + function expectWarning() { + sinon.assert.calledOnce(utils.logWarn); + sinon.assert.calledWithMatch(utils.logWarn, 'Topics API has been removed from Chrome'); + } - it('should call `document.browsingTopics` when allowed', () => { - const topics = ['t1', 't2']; - return getTopics({ - browsingTopics: sinon.stub().returns(Promise.resolve(topics)), - featurePolicy: { - allowsFeature: sinon.stub().returns(true) - } - }).then((actual) => { - expect(actual).to.eql(topics); - }); - }); + it('exports the legacy storage name', () => { + expect(topicStorageName).to.equal('prebid:topics'); }); - describe('processFpd', () => { - const mockData = [ - { - name: 'domain', - segment: [{ id: 123 }] - }, - { - name: 'domain', - segment: [{ id: 321 }] - } - ]; - - it('should add topics data', () => { - return processFpd({}, { global: {} }, { data: Promise.resolve(mockData) }) - .then(({ global }) => { - expect(global.user.data).to.eql(mockData); - }); - }); - - it('should apppend to existing user.data', () => { - const global = { - user: { - data: [ - { name: 'preexisting' }, - ] - } - }; - return processFpd({}, { global: deepClone(global) }, { data: Promise.resolve(mockData) }) - .then((data) => { - expect(data.global.user.data).to.eql(global.user.data.concat(mockData)); - }); - }); - - it('should not modify fpd when there is no data', () => { - return processFpd({}, { global: {} }, { data: Promise.resolve([]) }) - .then((data) => { - expect(data.global).to.eql({}); - }); - }); + it('does not return Topics API data', () => { + expect(getTopicsData()).to.eql([]); + expectWarning(); }); - describe('loadTopicsForBidders', () => { - beforeEach(() => { - config.setConfig({ - userSync: { - topics: { - bidders: [{ - bidder: 'mockBidder', - iframeURL: 'https://mock.iframe' - }] - } - } - }); - }); - afterEach(() => { - config.resetConfig(); - }); - - Object.entries({ - 'support': {}, - 'allow': { - browsingTopics: true, - featurePolicy: { - allowsFeature(feature) { - return feature !== 'browsing-topics'; - } - } - }, - }).forEach(([t, doc]) => { - it(`does not attempt to load frames if browser does not ${t} topics`, () => { - doc.createElement = sinon.stub(); - loadTopicsForBidders(doc); - sinon.assert.notCalled(doc.createElement); - }); - }); - - it('does not load frames when accessDevice is not allowed', () => { - enrichUfpdRule = ({ component }) => { - if (component === 'bidder.mockBidder') { - return { allow: false }; - } - }; - const doc = { - createElement: sinon.stub(), - browsingTopics: true, - featurePolicy: { - allowsFeature: () => true - } - }; - doc.createElement = sinon.stub(); - loadTopicsForBidders(doc); - sinon.assert.notCalled(doc.createElement); + it('resolves getTopics with an empty result', () => { + return getTopics().then((topics) => { + expect(topics).to.eql([]); + expectWarning(); }); }); - describe('getCachedTopics()', () => { - const storage = getCoreStorageManager('topicsFpd'); - const expected = [{ - ext: { - segtax: 600, - segclass: '2206021246' - }, - segment: [{ - 'id': '243' - }, { - 'id': '265' - }], - name: 'ads.pubmatic.com' - }]; - - const evt = { - data: '{"segment":{"domain":"ads.pubmatic.com","topics":[{"configVersion":"chrome.1","modelVersion":"2206021246","taxonomyVersion":"1","topic":165,"version":"chrome.1:1:2206021246"}],"bidder":"pubmatic"},"date":1669743901858}', - origin: 'https://ads.pubmatic.com' - }; - - afterEach(() => { - storage.removeDataFromLocalStorage(topicStorageName); - }); - - describe('caching', () => { - let sandbox; - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - sandbox.restore(); - config.resetConfig(); - }); - - it('should return no segments when not configured', () => { - config.setConfig({ userSync: {} }); - expect(getCachedTopics()).to.eql([]); - }); - - describe('when cached data is available and not expired', () => { - beforeEach(() => { - const storedSegments = JSON.stringify( - [['pubmatic', { - '2206021246': { - 'ext': { 'segtax': 600, 'segclass': '2206021246' }, - 'segment': [{ 'id': '243' }, { 'id': '265' }], - 'name': 'ads.pubmatic.com' - }, - 'lastUpdated': new Date().getTime() - }]] - ); - storage.setDataInLocalStorage(topicStorageName, storedSegments); - config.setConfig({ - userSync: { - topics: { - maxTopicCaller: 4, - bidders: [{ - bidder: 'pubmatic', - iframeURL: 'https://ads.pubmatic.com/AdServer/js/topics/topics_frame.html' - }] - } - } - }); - }); - - it('should return segments for bidder if transmitUfpd is allowed', () => { - assert.deepEqual(getCachedTopics(), expected); - }); - - it('should NOT return segments for bidder if enrichUfpd is NOT allowed', () => { - enrichUfpdRule = (params) => ({ allow: params.component !== 'bidder.pubmatic' }); - expect(getCachedTopics()).to.eql([]); - }); - }); - }); - - it('should return empty segments for bidder if there is cached segments stored which is expired', () => { - const storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":10}]]'; - storage.setDataInLocalStorage(topicStorageName, storedSegments); - assert.deepEqual(getCachedTopics(), []); - }); - - describe('cross-frame messages', () => { - before(() => { - config.setConfig({ - userSync: { - topics: { - maxTopicCaller: 3, - bidders: [ - { - bidder: 'pubmatic', - iframeURL: 'https://ads.pubmatic.com/AdServer/js/topics/topics_frame.html' - } - ], - }, - } - }); - }); - - beforeEach(() => { - // init iframe logic so that the receiveMessage origin check passes - loadTopicsForBidders({ - browsingTopics: true, - featurePolicy: { - allowsFeature() { return true; } - }, - createElement: sinon.stub().callsFake(() => ({ style: {} })), - documentElement: { - appendChild() {} - } - }); - }); - - after(() => { - config.resetConfig(); - }); - - it('should store segments if receiveMessage event is triggered with segment data', () => { - receiveMessage(evt); - const segments = new Map(safeJSONParse(storage.getDataFromLocalStorage(topicStorageName))); - expect(segments.has('pubmatic')).to.equal(true); - }); - - it('should update stored segments if receiveMessage event is triggerred with segment data', () => { - const storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1669719242027}]]'; - storage.setDataInLocalStorage(topicStorageName, storedSegments); - receiveMessage(evt); - const segments = new Map(safeJSONParse(storage.getDataFromLocalStorage(topicStorageName))); - expect(segments.get('pubmatic')[2206021246].segment.length).to.equal(1); - }); + it('leaves first party data unchanged', () => { + const global = { user: { data: [{ name: 'existing' }] } }; + return processFpd({}, { global }).then((result) => { + expect(result).to.eql({ global }); + expect(global.user.data).to.eql([{ name: 'existing' }]); + expectWarning(); }); }); - describe('handles fetch request for topics api headers', () => { - let stubbedFetch; - const storage = getCoreStorageManager('topicsFpd'); - beforeEach(() => { - stubbedFetch = sinon.stub(window, 'fetch'); - reset(); - }); - - afterEach(() => { - stubbedFetch.restore(); - storage.removeDataFromLocalStorage(topicStorageName); - config.resetConfig(); - }); - - it('should make a fetch call when a fetchUrl is present for a selected bidder', () => { - config.setConfig({ - userSync: { - topics: { - maxTopicCaller: 3, - bidders: [ - { - bidder: 'pubmatic', - fetchUrl: 'http://localhost:3000/topics-server.js' - } - ], - }, - } - }); - - stubbedFetch.returns(Promise.resolve(true)); - - loadTopicsForBidders({ - browsingTopics: true, - featurePolicy: { - allowsFeature() { return true; } - } - }); - sinon.assert.calledOnce(stubbedFetch); - stubbedFetch.calledWith('http://localhost:3000/topics-server.js'); - }); - - it('should not make a fetch call when a fetchUrl is not present for a selected bidder', () => { - config.setConfig({ - userSync: { - topics: { - maxTopicCaller: 3, - bidders: [ - { - bidder: 'pubmatic' - } - ], - }, - } - }); - - loadTopicsForBidders({ - browsingTopics: true, - featurePolicy: { - allowsFeature() { return true; } - } - }); - sinon.assert.notCalled(stubbedFetch); - }); - - it('a fetch request should not be made if the configured fetch rate duration has not yet passed', () => { - const storedSegments = JSON.stringify( - [['pubmatic', { - '2206021246': { - 'ext': { 'segtax': 600, 'segclass': '2206021246' }, - 'segment': [{ 'id': '243' }, { 'id': '265' }], - 'name': 'ads.pubmatic.com' - }, - 'lastUpdated': new Date().getTime() - }]] - ); - - storage.setDataInLocalStorage(topicStorageName, storedSegments); - - config.setConfig({ - userSync: { - topics: { - maxTopicCaller: 3, - bidders: [ - { - bidder: 'pubmatic', - fetchUrl: 'http://localhost:3000/topics-server.js', - fetchRate: 1 // in days. 1 fetch per day - } - ], - }, - } - }); - - loadTopicsForBidders({ - browsingTopics: true, - featurePolicy: { - allowsFeature() { return true; } - } - }); - sinon.assert.notCalled(stubbedFetch); - }); + it('stubs bidder side effects', () => { + expect(getCachedTopics()).to.eql([]); + receiveMessage(); + storeInLocalStorage('bidder', []); + loadTopicsForBidders(); + sinon.assert.calledOnce(utils.logWarn); }); }); diff --git a/test/spec/unit/core/ajax_spec.js b/test/spec/unit/core/ajax_spec.js index 072ae5574..2866fccd7 100644 --- a/test/spec/unit/core/ajax_spec.js +++ b/test/spec/unit/core/ajax_spec.js @@ -231,34 +231,6 @@ describe('ajax', () => { }); }); }); - - describe('chrome options', () => { - ['browsingTopics'].forEach(option => { - Object.entries({ - [`${option} = true`]: [{ [option]: true }, true], - [`${option} = false`]: [{ [option]: false }, false], - [`${option} undef`]: [{}, false] - }).forEach(([t, [opts, shouldBeSet]]) => { - describe(`when options has ${t}`, () => { - const sandbox = sinon.createSandbox(); - afterEach(() => { - sandbox.restore(); - }); - - it(`should ${!shouldBeSet ? 'not ' : ''}be set when in a secure context`, () => { - sandbox.stub(window, 'isSecureContext').get(() => true); - toFetchRequest(EXAMPLE_URL, null, opts); - sinon.assert.calledWithMatch(dep.makeRequest, sinon.match.any, { [option]: shouldBeSet ? true : undefined }); - }); - it(`should not be set when not in a secure context`, () => { - sandbox.stub(window, 'isSecureContext').get(() => false); - toFetchRequest(EXAMPLE_URL, null, opts); - sinon.assert.calledWithMatch(dep.makeRequest, sinon.match.any, { [option]: undefined }); - }); - }); - }); - }); - }); }); describe('credentials', () => { diff --git a/test/spec/unit/core/bidderFactory_spec.js b/test/spec/unit/core/bidderFactory_spec.js index 1a2a2495a..9ea6a3163 100644 --- a/test/spec/unit/core/bidderFactory_spec.js +++ b/test/spec/unit/core/bidderFactory_spec.js @@ -14,7 +14,7 @@ import { bidderSettings } from '../../../../src/bidderSettings.js'; import { decorateAdUnitsWithNativeParams } from '../../../../src/native.js'; import * as activityRules from 'src/activities/rules.js'; import { MODULE_TYPE_BIDDER } from '../../../../src/activities/modules.js'; -import { ACTIVITY_TRANSMIT_TID, ACTIVITY_TRANSMIT_UFPD } from '../../../../src/activities/activities.js'; +import { ACTIVITY_TRANSMIT_TID } from '../../../../src/activities/activities.js'; import { getGlobal } from '../../../../src/prebidGlobal.js'; const CODE = 'sampleBidder'; @@ -520,109 +520,6 @@ describe('bidderFactory', () => { expect(ajaxStub.calledTwice).to.equal(true); }); - describe('browsingTopics ajax option', () => { - let transmitUfpdAllowed, bidder, origBS; - before(() => { - origBS = getGlobal().bidderSettings; - }); - - after(() => { - getGlobal().bidderSettings = origBS; - }); - - beforeEach(() => { - activityRules.isActivityAllowed.resetHistory(); - activityRules.isActivityAllowed.callsFake((activity) => activity === ACTIVITY_TRANSMIT_UFPD ? transmitUfpdAllowed : true); - bidder = newBidder(spec); - spec.isBidRequestValid.returns(true); - }); - - it(`should be set to false when adapter sets browsingTopics = false`, () => { - transmitUfpdAllowed = true; - spec.buildRequests.returns([ - { - method: 'GET', - url: 'url', - options: { - browsingTopics: false - } - } - ]); - bidder.callBids(MOCK_BIDS_REQUEST, addBidResponseStub, doneStub, ajaxStub, onTimelyResponseStub, wrappedCallback); - sinon.assert.calledWith(ajaxStub, 'url', sinon.match.any, sinon.match.any, sinon.match({ - browsingTopics: false, - suppressTopicsEnrollmentWarning: true - })); - }); - - Object.entries({ - 'omitted': [undefined, true], - 'enabled': [true, true], - 'disabled': [false, false] - }).forEach(([t, [topicsHeader, enabled]]) => { - describe(`when bidderSettings.topicsHeader is ${t}`, () => { - beforeEach(() => { - getGlobal().bidderSettings = { - [CODE]: { - topicsHeader: topicsHeader - } - }; - }); - - afterEach(() => { - delete getGlobal().bidderSettings[CODE]; - }); - - Object.entries({ - 'allowed': true, - 'not allowed': false - }).forEach(([t, allow]) => { - const shouldBeSet = allow && enabled; - - it(`should be set to ${shouldBeSet} when transmitUfpd is ${t}`, () => { - transmitUfpdAllowed = allow; - spec.buildRequests.returns([ - { - method: 'GET', - url: '1', - }, - { - method: 'POST', - url: '2', - data: {} - }, - { - method: 'GET', - url: '3', - options: { - browsingTopics: true - } - }, - { - method: 'POST', - url: '4', - data: {}, - options: { - browsingTopics: true - } - } - ]); - bidder.callBids(MOCK_BIDS_REQUEST, addBidResponseStub, doneStub, ajaxStub, onTimelyResponseStub, wrappedCallback); - ['1', '2', '3', '4'].forEach(url => { - sinon.assert.calledWith( - ajaxStub, - url, - sinon.match.any, - sinon.match.any, - sinon.match({ browsingTopics: shouldBeSet, suppressTopicsEnrollmentWarning: true }) - ); - }); - }); - }); - }); - }); - }); - it('should not add bids for each placement code if no requests are given', function () { const bidder = newBidder(spec); From a99eb55dd78103d00c5a64ea8b25ba1c47d22d97 Mon Sep 17 00:00:00 2001 From: robin-crazygames Date: Sat, 25 Jul 2026 14:23:03 +0200 Subject: [PATCH 08/21] gamAdServerVideo: Add GPP consent information to the DFP video url (#13761) * Add GPP consent information to the DFP video url * Adjust bidViewaiblity for GPP * Replaced gpp_string with gpp now that the parameter is officially documented * Added test which checks for presence of gpp * Fix linting problem * Remove unused formatQS import from gamAdServerVideo.js --------- Co-authored-by: Patrick McCann Co-authored-by: Patrick McCann --- libraries/dfpUtils/dfpUtils.js | 12 +- libraries/gamUtils/gamUtils.js | 2 +- modules/gamAdServerVideo.js | 5 +- test/spec/modules/gamAdServerVideo_spec.js | 539 +++++++++++---------- 4 files changed, 302 insertions(+), 256 deletions(-) diff --git a/libraries/dfpUtils/dfpUtils.js b/libraries/dfpUtils/dfpUtils.js index 92c9441ea..870fb1aeb 100644 --- a/libraries/dfpUtils/dfpUtils.js +++ b/libraries/dfpUtils/dfpUtils.js @@ -1,4 +1,4 @@ -import { gdprDataHandler } from '../../src/consentHandler.js'; +import { gdprDataHandler, gppDataHandler } from '../../src/consentHandler.js'; /** Safe defaults which work on pretty much all video calls. */ export const DEFAULT_DFP_PARAMS = { @@ -24,3 +24,13 @@ export function gdprParams() { } return params; } + +export function gppParams() { + const gppConsent = gppDataHandler.getConsentData(); + const params = {}; + if (gppConsent) { + if (gppConsent.gppString) { params.gpp = gppConsent.gppString; } + if (gppConsent.applicableSections) { params.gpp_sid = gppConsent.applicableSections.join(','); } + } + return params; +} diff --git a/libraries/gamUtils/gamUtils.js b/libraries/gamUtils/gamUtils.js index 3c2590271..43a9e4427 100644 --- a/libraries/gamUtils/gamUtils.js +++ b/libraries/gamUtils/gamUtils.js @@ -1 +1 @@ -export { DEFAULT_DFP_PARAMS as DEFAULT_GAM_PARAMS, DFP_ENDPOINT as GAM_ENDPOINT, gdprParams } from '../dfpUtils/dfpUtils.js'; +export { DEFAULT_DFP_PARAMS as DEFAULT_GAM_PARAMS, DFP_ENDPOINT as GAM_ENDPOINT, gdprParams, gppParams } from '../dfpUtils/dfpUtils.js'; diff --git a/modules/gamAdServerVideo.js b/modules/gamAdServerVideo.js index fca0b790a..f504fd0c3 100644 --- a/modules/gamAdServerVideo.js +++ b/modules/gamAdServerVideo.js @@ -11,8 +11,8 @@ import { EVENTS } from '../src/constants.js'; import * as events from '../src/events.js'; import { getRefererInfo } from '../src/refererDetection.js'; import { targeting } from '../src/targeting.js'; +import { DEFAULT_GAM_PARAMS, GAM_ENDPOINT, gdprParams, gppParams } from '../libraries/gamUtils/gamUtils.js'; import { buildUrl, isEmpty, isNumber, logError, logWarn, parseSizesInput, parseUrl } from '../src/utils.js'; -import { DEFAULT_GAM_PARAMS, GAM_ENDPOINT, gdprParams } from '../libraries/gamUtils/gamUtils.js'; import { vastLocalCache } from '../src/videoCache.js'; import { noCredsFetch as fetch } from '../src/ajax.js'; import XMLUtil from '../libraries/xmlUtils/xmlUtils.js'; @@ -82,7 +82,8 @@ export function buildGamVideoUrl(options) { derivedParams, options.params, { cust_params: encodedCustomParams }, - gdprParams() + gdprParams(), + gppParams() ); // The IMA player adds usp info, but not gpp info diff --git a/test/spec/modules/gamAdServerVideo_spec.js b/test/spec/modules/gamAdServerVideo_spec.js index 9588bc725..2a0dc5d4b 100644 --- a/test/spec/modules/gamAdServerVideo_spec.js +++ b/test/spec/modules/gamAdServerVideo_spec.js @@ -878,21 +878,8 @@ describe('The DFP video support module', function () { server.respond(); }); - describe('Retrieve US Privacy string from GPP when using the IMA player', () => { - beforeEach(() => { - config.setConfig({ cache: { useLocal: true } }); - // Install a fake IMA object, because the us_privacy is only set when IMA is available - window.google = { - ima: { - VERSION: '2.3.37' - } - }; - }); - afterEach(() => { - config.resetConfig(); - }); - - async function obtainUsPrivacyInVastXmlRequest() { + describe('UsPrivacy/GPP', () => { + async function getSearchParamInVastXmlRequest(searchParam) { const url = 'https://pubads.g.doubleclick.net/gampad/ads'; const bidCacheUrl = 'https://prebid-test-cache-server.org/cache?uuid=4536229c-eddb-45b3-a919-89d889e925aa'; const gamWrapper = ( @@ -910,18 +897,30 @@ describe('The DFP video support module', function () { const result = getVastXml({ url, adUnit: {}, bid: {}, params: { iu: '/19968336/prebid_cache_video_adunit' } }, []).then(() => { const request = server.requests[0]; const url = new URL(request.url); - return url.searchParams.get('us_privacy'); + return url.searchParams.get(searchParam); }); server.respond(); return result; } + async function obtainUsPrivacyInVastXmlRequest() { + return getSearchParamInVastXmlRequest('us_privacy'); + } + + async function obtainGPPInVastXmlRequest() { + return getSearchParamInVastXmlRequest('gpp'); + } function obtainUsPrivacyInGamVideoUrl() { const url = 'https://pubads.g.doubleclick.net/gampad/ads'; return new URLSearchParams(buildDfpVideoUrl({ url, adUnit: {}, bid: {}, params: { iu: '/19968336/prebid_cache_video_adunit' } })).get('us_privacy'); } + function obtainGppInGamVideoUrl() { + const url = 'https://pubads.g.doubleclick.net/gampad/ads'; + return new URLSearchParams(buildDfpVideoUrl({ url, adUnit: {}, bid: {}, params: { iu: '/19968336/prebid_cache_video_adunit' } })).get('gpp'); + } + function mockGpp(gpp) { sandbox.stub(gppDataHandler, 'getConsentData').returns(gpp); } @@ -934,275 +933,311 @@ describe('The DFP video support module', function () { }; } - it('should use usp when available, even when gpp is available', async () => { - const usPrivacy = '1YYY'; - sandbox.stub(uspDataHandler, 'getConsentData').returns(usPrivacy); - mockGpp(wrapParsedSectionsIntoGPPData({ - "uspv1": { - "Version": 1, - "Notice": "Y", - "OptOutSale": "N", - "LspaCovered": "Y" - } - })); + describe('GPP information is included when available', () => { + it('gpp string is included in the URL when present', async () => { + const gppConsentData = { gppString: 'DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', applicableSections: [7, 8] }; + mockGpp(gppConsentData); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.equal(usPrivacy); + const gppFromRequest = await obtainGPPInVastXmlRequest(); + expect(gppFromRequest).to.equal(gppConsentData.gppString); - // In this case, the IMA player will add the us_privacy string - // It is not included in the URL returned by Prebid - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.be.null; - }); + const gppFromUrl = await obtainGppInGamVideoUrl(); + expect(gppFromUrl).to.equal(gppConsentData.gppString); + }); - it('no us_privacy when neither usp nor gpp is present', async () => { - const usPrivacyFromRequqest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequqest).to.be.null; + it('gpp property is not present when there is no gpp info available', async () => { + const gppFromRequest = await obtainGPPInVastXmlRequest(); + expect(gppFromRequest).to.be.null; - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.be.null; + const gppFromUrl = await obtainGppInGamVideoUrl(); + expect(gppFromUrl).to.be.null; + }); }); - it('can retrieve from usp section in gpp', async () => { - mockGpp(wrapParsedSectionsIntoGPPData({ - "uspv1": { - "Version": 1, - "Notice": "Y", - "OptOutSale": "N", - "LspaCovered": "Y" - } - })); + describe('Retrieve US Privacy string from GPP when using the IMA player', () => { + beforeEach(() => { + config.setConfig({ cache: { useLocal: true } }); + // Install a fake IMA object, because the us_privacy is only set when IMA is available + window.google = { + ima: { + VERSION: '2.3.37' + } + }; + }); + afterEach(() => { + config.resetConfig(); + }); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.equal('1YNY'); + it('should use usp when available, even when gpp is available', async () => { + const usPrivacy = '1YYY'; + sandbox.stub(uspDataHandler, 'getConsentData').returns(usPrivacy); + mockGpp(wrapParsedSectionsIntoGPPData({ + "uspv1": { + "Version": 1, + "Notice": "Y", + "OptOutSale": "N", + "LspaCovered": "Y" + } + })); - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.equal('1YNY'); - }); - it('can retrieve from usnat section in gpp', async () => { - mockGpp(wrapParsedSectionsIntoGPPData({ - "usnat": { - "Version": 1, - "SharingNotice": 2, - "SaleOptOutNotice": 1, - "SharingOptOutNotice": 0, - "TargetedAdvertisingOptOutNotice": 2, - "SensitiveDataProcessingOptOutNotice": 1, - "SensitiveDataLimitUseNotice": 1, - "SaleOptOut": 1, - "SharingOptOut": 2, - "TargetedAdvertisingOptOut": 2, - "SensitiveDataProcessing": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "KnownChildSensitiveDataConsents": [ - 0, - 0, - 0 - ], - "PersonalDataConsents": 0, - "MspaCoveredTransaction": 1, - "MspaOptOutOptionMode": 0, - "MspaServiceProviderMode": 0, - "GpcSegmentType": 1, - "Gpc": false - } - })); + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal(usPrivacy); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.equal('1YYY'); + // In this case, the IMA player will add the us_privacy string + // It is not included in the URL returned by Prebid + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.be.null; + }); - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.equal('1YYY'); - }); - it('can retrieve from usnat section in gpp when usnat is an array', async() => { - mockGpp(wrapParsedSectionsIntoGPPData({ - "usnat": [ - { + it('no us_privacy when neither usp nor gpp is present', async () => { + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.be.null; + + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.be.null; + }); + + it('can retrieve from usp section in gpp', async () => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "uspv1": { + "Version": 1, + "Notice": "Y", + "OptOutSale": "N", + "LspaCovered": "Y" + } + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YNY'); + + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.equal('1YNY'); + }); + it('can retrieve from usnat section in gpp', async () => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": { "Version": 1, "SharingNotice": 2, "SaleOptOutNotice": 1, - "SharingOptOutNotice": 1, - "TargetedAdvertisingOptOutNotice": 1, + "SharingOptOutNotice": 0, + "TargetedAdvertisingOptOutNotice": 2, "SensitiveDataProcessingOptOutNotice": 1, - "SensitiveDataLimitUseNotice": 0, - "SaleOptOut": 2, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": 1, "SharingOptOut": 2, "TargetedAdvertisingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0, + 0 + ], "PersonalDataConsents": 0, - "MspaCoveredTransaction": 0, + "MspaCoveredTransaction": 1, "MspaOptOutOptionMode": 0, "MspaServiceProviderMode": 0, - }, { "GpcSegmentType": 1, "Gpc": false } - ] - })); + })); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.equal('1YNY'); + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YYY'); - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.equal('1YNY'); - }); - it('no us_privacy when either SaleOptOutNotice or SaleOptOut is missing', async () => { + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.equal('1YYY'); + }); + it('can retrieve from usnat section in gpp when usnat is an array', async() => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": [ + { + "Version": 1, + "SharingNotice": 2, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 1, + "TargetedAdvertisingOptOutNotice": 1, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 0, + "SaleOptOut": 2, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 0, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + }, { + "GpcSegmentType": 1, + "Gpc": false + } + ] + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YNY'); + + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.equal('1YNY'); + }); + it('no us_privacy when either SaleOptOutNotice or SaleOptOut is missing', async () => { // Missing SaleOptOutNotice - mockGpp(wrapParsedSectionsIntoGPPData({ - "usnat": { - "Version": 1, - "SharingNotice": 2, - "SharingOptOutNotice": 0, - "TargetedAdvertisingOptOutNotice": 2, - "SensitiveDataProcessingOptOutNotice": 1, - "SensitiveDataLimitUseNotice": 1, - "SaleOptOut": 1, - "SharingOptOut": 2, - "TargetedAdvertisingOptOut": 2, - "SensitiveDataProcessing": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "KnownChildSensitiveDataConsents": [ - 0, - 0, - 0 - ], - "PersonalDataConsents": 0, - "MspaCoveredTransaction": 1, - "MspaOptOutOptionMode": 0, - "MspaServiceProviderMode": 0, - "GpcSegmentType": 1, - "Gpc": false - } - })); + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": { + "Version": 1, + "SharingNotice": 2, + "SharingOptOutNotice": 0, + "TargetedAdvertisingOptOutNotice": 2, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": 1, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 1, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + } + })); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.be.null; + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.be.null; - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.be.null; - }); - it('no us_privacy when either SaleOptOutNotice or SaleOptOut is null', async () => { + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.be.null; + }); + it('no us_privacy when either SaleOptOutNotice or SaleOptOut is null', async () => { // null SaleOptOut - mockGpp(wrapParsedSectionsIntoGPPData({ - "usnat": { - "Version": 1, - "SharingNotice": 2, - "SaleOptOutNotice": 1, - "SharingOptOutNotice": 0, - "TargetedAdvertisingOptOutNotice": 2, - "SensitiveDataProcessingOptOutNotice": 1, - "SensitiveDataLimitUseNotice": 1, - "SaleOptOut": null, - "SharingOptOut": 2, - "TargetedAdvertisingOptOut": 2, - "SensitiveDataProcessing": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "KnownChildSensitiveDataConsents": [ - 0, - 0, - 0 - ], - "PersonalDataConsents": 0, - "MspaCoveredTransaction": 1, - "MspaOptOutOptionMode": 0, - "MspaServiceProviderMode": 0, - "GpcSegmentType": 1, - "Gpc": false - } - })); + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": { + "Version": 1, + "SharingNotice": 2, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 0, + "TargetedAdvertisingOptOutNotice": 2, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": null, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 1, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + } + })); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.be.null; + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.be.null; - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.be.null; - }); + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.be.null; + }); - it('can retrieve from usca section in gpp', async () => { - mockGpp(wrapParsedSectionsIntoGPPData({ - "usca": { - "Version": 1, - "SaleOptOutNotice": 1, - "SharingOptOutNotice": 1, - "SensitiveDataLimitUseNotice": 1, - "SaleOptOut": 2, - "SharingOptOut": 2, - "SensitiveDataProcessing": [ - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "KnownChildSensitiveDataConsents": [ - 0, - 0 - ], - "PersonalDataConsents": 0, - "MspaCoveredTransaction": 2, - "MspaOptOutOptionMode": 0, - "MspaServiceProviderMode": 0, - "GpcSegmentType": 1, - "Gpc": false - } - })); + it('can retrieve from usca section in gpp', async () => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "usca": { + "Version": 1, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": 2, + "SharingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 2, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + } + })); - const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); - expect(usPrivacyFromRequest).to.equal('1YNY'); + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YNY'); - const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); - expect(usPrivacyFromUrl).to.equal('1YNY'); + const usPrivacyFromUrl = obtainUsPrivacyInGamVideoUrl(); + expect(usPrivacyFromUrl).to.equal('1YNY'); + }); }); }); }); From b12a85fd25d1dec8c41b6bc90200825e81d94876 Mon Sep 17 00:00:00 2001 From: Ratko Vidakovic <47907491+rvidakovic@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:02:02 -0400 Subject: [PATCH 09/21] New Module: DAA AdChoices Signal (#15138) * New Module: DAA AdChoices Signal Adds the adChoices module (modules/adChoices.ts), which reads the DAA AdChoices Signal in the browser via the Protect My Choices (PMC) extension postMessage protocol (ExtensionLoaded -> GetAdPreferences -> AdPreferences) and conveys it in the OpenRTB bid stream as the community extension regs.ext.adchoices, per Appendix 5 of the DAA AdChoices Signal Specification. - Standalone `adChoices` config namespace; supports a static `signal` override (takes precedence) and an opt-in `timeout`. Non-blocking by default; when a positive timeout is set, the first auction is delayed up to that many ms while waiting for the signal (the window starts when an auction begins waiting and applies once, so later auctions are not re-delayed). - Injects the value via the FPD enrichment hook, matching the consent modules. - TypeScript public types (AdChoicesConfig); 20 unit tests. * AdChoices Signal Module: add repo-side module docs Adds modules/adChoices.md with an overview, example `adChoices` config (`signal` and `timeout`), and the resulting `regs.ext.adchoices` bid-request output, per reviewer request. * AdChoices Signal Module: address review feedback - Remove the requestBids hook (and clear the installed flag) in resetAdChoicesData so the hook does not leak onto the global requestBids across test runs, and can be reinstalled after a reset. Adds a test that sets a timeout, resets, and asserts the hook is gone. - Reformat modules/adChoices.md to the repo-side `# Overview` convention (Module Name / Module Type / Maintainer); the page_v2 frontmatter remains in the docs PR. --------- Co-authored-by: Patrick McCann --- modules/adChoices.md | 87 ++++++++++ modules/adChoices.ts | 244 ++++++++++++++++++++++++++++ test/spec/modules/adChoices_spec.js | 220 +++++++++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 modules/adChoices.md create mode 100644 modules/adChoices.ts create mode 100644 test/spec/modules/adChoices_spec.js diff --git a/modules/adChoices.md b/modules/adChoices.md new file mode 100644 index 000000000..d660604a3 --- /dev/null +++ b/modules/adChoices.md @@ -0,0 +1,87 @@ +# Overview + +Module Name: AdChoices Signal Module +Module Type: Consent Module +Maintainer: prebid@aboutads.info + +# Description + +This module reads the [DAA (Digital Advertising Alliance) AdChoices Signal](https://github.com/Digital-Advertising-Alliance/DAA-Choice-Tools/blob/main/AdChoices%20Signal/AdChoices%20Signal%20Specification.md) +and conveys it in the OpenRTB bid stream as the community extension +`regs.ext.adchoices`, as described in Appendix 5 of the specification. + +The AdChoices Signal is a base64url-encoded string that expresses a user's +interest-based advertising preferences. In the browser it can be read from the +DAA's [Protect My Choices (PMC)](https://github.com/Digital-Advertising-Alliance/DAA-Choice-Tools/blob/main/Protect%20My%20Choices/PMC2%20Overview.md) +extension, which exposes the signal via a `window.postMessage` protocol. When a +user does not have the extension installed, no signal is read and nothing is +added to the bid stream. + +Publishers who obtain the signal by other means (for example, reading the +`X-AdChoices` request header on their server) can supply it directly through the +module's `signal` configuration option. + +# Integration + +Build the module into your Prebid.js package: + +```bash +gulp build --modules=adChoices +``` + +# Configuration + +The module works with no configuration. To supply a static signal or to opt into +delaying auctions while the signal is read, use the `adChoices` config namespace: + +```javascript +pbjs.setConfig({ + adChoices: { + // Optional: a statically supplied AdChoices Signal. Takes precedence over a + // value read from the browser extension. + signal: 'AAEAA... (base64url signal)', + + // Optional: max milliseconds to delay the first auction while waiting for the + // signal from the extension. Default 0 (non-blocking). + timeout: 0 + } +}); +``` + +| Param | Scope | Type | Description | +|---|---|---|---| +| `signal` | optional | string | A statically supplied AdChoices Signal. When set, it is used as-is and takes precedence over any value read from the Protect My Choices extension. | +| `timeout` | optional | integer | Max milliseconds to delay auctions while waiting for the signal from the extension. Defaults to `0` (non-blocking) so that users without the extension are not delayed. When set to a positive value, the first auction is delayed up to this many ms; the delay window starts when an auction begins waiting and applies once, so later auctions are not re-delayed. | + +# What changes in the bid request + +When a signal is available it is added to every outgoing bid request at +`regs.ext.adchoices`: + +```json +{ + "regs": { + "ext": { + "adchoices": "" + } + } +} +``` + +# How the signal is read + +When included, the module automatically begins listening for the signal from the +Protect My Choices extension using the documented message protocol: + +1. The extension posts an `ExtensionLoaded` message when it is ready. +2. The module requests the preferences by posting `{ type: "GetAdPreferences" }`. +3. The extension responds with an `AdPreferences` message whose `data` field + contains the AdChoices Signal string. + +The module also proactively sends a `GetAdPreferences` request on startup in case +the `ExtensionLoaded` message fired before the listener was attached. + +Note: page JavaScript cannot read the `X-AdChoices` (Chrome) / `Cookie2` (Safari) +headers that the extension injects into outbound requests — those are intended for +server-side consumption. In the browser, the postMessage protocol (or the `signal` +config option) is the supported way to obtain the value. diff --git a/modules/adChoices.ts b/modules/adChoices.ts new file mode 100644 index 000000000..178a9ba18 --- /dev/null +++ b/modules/adChoices.ts @@ -0,0 +1,244 @@ +/** + * This module reads the DAA (Digital Advertising Alliance) AdChoices Signal and + * conveys it in the OpenRTB bid stream as the community extension `regs.ext.adchoices`, + * as described in Appendix 5 of the DAA's AdChoices Signal Specification. + * + * The signal is a base64url-encoded string describing a user's interest-based + * advertising preferences. In a browser environment it can be read from the DAA's + * "Protect My Choices" (PMC) extension via the window `postMessage` protocol: + * the extension emits an `ExtensionLoaded` message when ready, responds to a + * `GetAdPreferences` request, and delivers the signal in an `AdPreferences` message. + * + * Publishers that obtain the signal by other means (for example, reading the + * `X-AdChoices` request header server-side) can instead supply it directly through + * the module's `signal` config option. + * + * @see https://github.com/Digital-Advertising-Alliance/DAA-Choice-Tools/blob/main/AdChoices%20Signal/AdChoices%20Signal%20Specification.md + */ +import { deepSetValue, isNumber, isStr, logInfo, logWarn } from '../src/utils.js'; +import { config } from '../src/config.js'; +import { getHook } from '../src/hook.js'; +import { enrichFPD } from '../src/fpd/enrichment.js'; + +const MODULE_NAME = 'adChoices'; + +// postMessage protocol message types used by the Protect My Choices extension. +export const PMC_EXTENSION_LOADED = 'ExtensionLoaded'; +export const PMC_GET_AD_PREFERENCES = 'GetAdPreferences'; +export const PMC_AD_PREFERENCES = 'AdPreferences'; + +export interface AdChoicesConfig { + /** + * A statically supplied AdChoices Signal. When set, it is used as-is and takes + * precedence over any value read from the Protect My Choices browser extension. + * Useful for publishers who read the signal server-side (e.g. from the + * `X-AdChoices` header) and pass it into Prebid. + */ + signal?: string; + /** + * Length of time (in milliseconds) to delay auctions while waiting for the + * AdChoices Signal to arrive from the browser extension. Defaults to 0 + * (non-blocking): auctions are not delayed and whatever signal is available at + * auction time is used. Set a positive value to opt into delaying the first + * auction until the signal is read or the timeout elapses. + */ + timeout?: number; +} + +declare module '../src/config' { + interface Config { + adChoices?: AdChoicesConfig; + } +} + +// Module state. +let enabled = false; +let listenerAttached = false; +let staticSignal: string | undefined; +let extensionSignal: string | undefined; +let auctionTimeout = 0; + +// Promise (and its resolver) used by the optional auction-delay hook to wait for +// the signal from the extension. It is resolved once, when either the signal +// arrives or the configured timeout elapses; `signalSettled` records that so that +// subsequent auctions proceed immediately rather than waiting again. +let signalReady: Promise | undefined; +let resolveSignalReady: (() => void) | undefined; +let signalSettled = false; +let timeoutTimer: ReturnType | undefined; + +/** + * Returns the AdChoices Signal that should be written to the bid stream, preferring + * an explicitly configured static value over one read from the extension. + */ +export function getAdChoicesSignal(): string | undefined { + return staticSignal != null ? staticSignal : extensionSignal; +} + +function isValidSignal(value: unknown): value is string { + // Keep validation lenient (non-empty string) to remain forward-compatible with + // future versions of the signal; do not attempt to parse the base64url payload. + return isStr(value) && (value as string).length > 0; +} + +function markSignalReady() { + signalSettled = true; + if (timeoutTimer != null) { + clearTimeout(timeoutTimer); + timeoutTimer = undefined; + } + if (resolveSignalReady != null) { + resolveSignalReady(); + resolveSignalReady = undefined; + } +} + +function handleMessage(event: MessageEvent) { + // Only trust messages posted to this same window (the PMC extension injects into + // the page context and posts to `window`). + if (event.source !== window || event.data == null || typeof event.data !== 'object') { + return; + } + const { type, data } = event.data as { type?: string; data?: unknown }; + if (type === PMC_EXTENSION_LOADED) { + logInfo('adChoices: Protect My Choices extension detected, requesting ad preferences'); + requestAdPreferences(); + } else if (type === PMC_AD_PREFERENCES) { + if (isValidSignal(data)) { + extensionSignal = data; + logInfo('adChoices: received AdChoices Signal from Protect My Choices extension'); + markSignalReady(); + } else { + logWarn('adChoices: ignoring malformed AdPreferences message', data); + } + } +} + +function requestAdPreferences() { + window.postMessage({ type: PMC_GET_AD_PREFERENCES }, '*'); +} + +function attachListener() { + if (listenerAttached) return; + window.addEventListener('message', handleMessage); + listenerAttached = true; + // Proactively request preferences in case `ExtensionLoaded` fired before this + // listener was attached; if the extension isn't installed this is a no-op. + requestAdPreferences(); +} + +/** + * Enrich the global ortb2 object with the AdChoices Signal at `regs.ext.adchoices`. + * Runs before every auction via the FPD enrichment hook. + */ +export function enrichFPDHook(next, fpd) { + return next(fpd.then(ortb2 => { + const signal = getAdChoicesSignal(); + if (isValidSignal(signal)) { + deepSetValue(ortb2, 'regs.ext.adchoices', signal); + } + return ortb2; + })); +} + +/** + * Optional auction-delay hook. Only delays the first auction(s) while waiting for + * the signal, and only when a positive `timeout` is configured and the signal has + * not yet settled. Once the signal arrives or the timeout elapses, readiness is + * settled once and every later auction proceeds immediately, so users without the + * extension are not repeatedly penalized with added latency. + */ +export function requestBidsHook(next, reqBidsConfigObj) { + if (auctionTimeout > 0 && !signalSettled && getAdChoicesSignal() == null && signalReady != null) { + // Start the timeout window now, when an auction is actually waiting, so the + // first auction gets the full configured delay regardless of how long ago the + // module was configured. + startTimeoutTimer(); + signalReady.then(() => next(reqBidsConfigObj)); + } else { + next(reqBidsConfigObj); + } +} + +let requestBidsHookInstalled = false; +function ensureRequestBidsHook() { + if (!requestBidsHookInstalled) { + getHook('requestBids').before(requestBidsHook, 49); + requestBidsHookInstalled = true; + } +} + +/** + * Start a single timer that settles readiness when the configured timeout elapses, + * so the auction-delay hook never waits longer than `timeout` and only waits once. + */ +function startTimeoutTimer() { + if (signalSettled || timeoutTimer != null || auctionTimeout <= 0) return; + timeoutTimer = setTimeout(() => { + timeoutTimer = undefined; + logWarn(`adChoices: timed out after ${auctionTimeout}ms waiting for the AdChoices Signal`); + markSignalReady(); + }, auctionTimeout); +} + +/** + * Activate the module: begin listening for the AdChoices Signal from the browser + * extension. Runs automatically when the module is included, so the signal is read + * without requiring any configuration; calling it again is a no-op. + */ +export function activate() { + if (signalReady == null) { + signalReady = new Promise((resolve) => { resolveSignalReady = resolve; }); + } + if (!enabled) { + enabled = true; + logInfo('adChoices: module enabled'); + } + attachListener(); +} + +export function setAdChoicesConfig(cfg?: AdChoicesConfig) { + activate(); + staticSignal = cfg != null && isValidSignal(cfg.signal) ? cfg.signal : undefined; + auctionTimeout = cfg != null && isNumber(cfg.timeout) && cfg.timeout > 0 ? cfg.timeout : 0; + + if (getAdChoicesSignal() != null) markSignalReady(); + if (auctionTimeout > 0) { + // Install the hook now, but defer starting the timeout window until an auction + // is actually waiting (see requestBidsHook). + ensureRequestBidsHook(); + } +} + +/** + * Reset module state. Intended for tests. + */ +export function resetAdChoicesData() { + staticSignal = undefined; + extensionSignal = undefined; + auctionTimeout = 0; + enabled = false; + signalReady = undefined; + resolveSignalReady = undefined; + signalSettled = false; + if (timeoutTimer != null) { + clearTimeout(timeoutTimer); + timeoutTimer = undefined; + } + if (listenerAttached) { + window.removeEventListener('message', handleMessage); + listenerAttached = false; + } + if (requestBidsHookInstalled) { + getHook('requestBids').getHooks({ hook: requestBidsHook }).remove(); + requestBidsHookInstalled = false; + } +} + +config.getConfig(MODULE_NAME, (cfg) => setAdChoicesConfig(cfg?.[MODULE_NAME])); + +enrichFPD.before(enrichFPDHook); + +// Start reading the signal as soon as the module is included, without requiring +// any configuration. +activate(); diff --git a/test/spec/modules/adChoices_spec.js b/test/spec/modules/adChoices_spec.js new file mode 100644 index 000000000..f93a36c64 --- /dev/null +++ b/test/spec/modules/adChoices_spec.js @@ -0,0 +1,220 @@ +import { + setAdChoicesConfig, + resetAdChoicesData, + getAdChoicesSignal, + enrichFPDHook, + requestBidsHook, + PMC_EXTENSION_LOADED, + PMC_GET_AD_PREFERENCES, + PMC_AD_PREFERENCES, +} from 'modules/adChoices.js'; +import * as utils from 'src/utils.js'; +import { getHook } from 'src/hook.js'; + +const expect = require('chai').expect; + +const SIGNAL = 'ADCHOICES_SIGNAL_STRING'; +const STATIC_SIGNAL = 'STATIC_SIGNAL_STRING'; + +function dispatchPMC(type, data, source = window) { + // dispatchEvent delivers synchronously, so handlers run before we assert. + window.dispatchEvent(new MessageEvent('message', { data: { type, data }, source })); +} + +function callEnrichHook() { + let result; + enrichFPDHook((res) => { result = res; }, Promise.resolve({})); + return result; +} + +describe('adChoices', function () { + let sandbox; + + beforeEach(function () { + resetAdChoicesData(); + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'logInfo'); + sandbox.stub(utils, 'logWarn'); + }); + + afterEach(function () { + sandbox.restore(); + resetAdChoicesData(); + }); + + describe('setAdChoicesConfig', function () { + it('enables with no signal when given empty config', function () { + setAdChoicesConfig({}); + expect(getAdChoicesSignal()).to.equal(undefined); + }); + + it('stores a statically supplied signal', function () { + setAdChoicesConfig({ signal: STATIC_SIGNAL }); + expect(getAdChoicesSignal()).to.equal(STATIC_SIGNAL); + }); + + it('ignores a non-string / empty static signal', function () { + setAdChoicesConfig({ signal: '' }); + expect(getAdChoicesSignal()).to.equal(undefined); + setAdChoicesConfig({ signal: 123 }); + expect(getAdChoicesSignal()).to.equal(undefined); + }); + + it('tolerates being called with undefined', function () { + expect(() => setAdChoicesConfig(undefined)).to.not.throw(); + expect(getAdChoicesSignal()).to.equal(undefined); + }); + }); + + describe('Protect My Choices extension protocol', function () { + it('requests ad preferences proactively when enabled', function () { + const postSpy = sandbox.spy(window, 'postMessage'); + setAdChoicesConfig({}); + expect(postSpy.calledWithMatch({ type: PMC_GET_AD_PREFERENCES })).to.equal(true); + }); + + it('requests ad preferences when ExtensionLoaded is received', function () { + setAdChoicesConfig({}); + const postSpy = sandbox.spy(window, 'postMessage'); + dispatchPMC(PMC_EXTENSION_LOADED); + expect(postSpy.calledWithMatch({ type: PMC_GET_AD_PREFERENCES })).to.equal(true); + }); + + it('stores a valid signal delivered via AdPreferences', function () { + setAdChoicesConfig({}); + dispatchPMC(PMC_AD_PREFERENCES, SIGNAL); + expect(getAdChoicesSignal()).to.equal(SIGNAL); + }); + + it('ignores a malformed AdPreferences payload', function () { + setAdChoicesConfig({}); + dispatchPMC(PMC_AD_PREFERENCES, { not: 'a string' }); + expect(getAdChoicesSignal()).to.equal(undefined); + }); + + it('ignores messages that did not originate from this window', function () { + setAdChoicesConfig({}); + dispatchPMC(PMC_AD_PREFERENCES, SIGNAL, null); + expect(getAdChoicesSignal()).to.equal(undefined); + }); + + it('ignores messages with no usable data', function () { + setAdChoicesConfig({}); + window.dispatchEvent(new MessageEvent('message', { data: null, source: window })); + window.dispatchEvent(new MessageEvent('message', { data: 'a string', source: window })); + expect(getAdChoicesSignal()).to.equal(undefined); + }); + + it('prefers a static signal over the extension-read value', function () { + setAdChoicesConfig({ signal: STATIC_SIGNAL }); + dispatchPMC(PMC_AD_PREFERENCES, SIGNAL); + expect(getAdChoicesSignal()).to.equal(STATIC_SIGNAL); + }); + }); + + describe('FPD enrichment (regs.ext.adchoices)', function () { + it('sets regs.ext.adchoices from the extension signal', function () { + setAdChoicesConfig({}); + dispatchPMC(PMC_AD_PREFERENCES, SIGNAL); + return callEnrichHook().then(ortb2 => { + expect(ortb2.regs.ext.adchoices).to.equal(SIGNAL); + }); + }); + + it('sets regs.ext.adchoices from a static signal', function () { + setAdChoicesConfig({ signal: STATIC_SIGNAL }); + return callEnrichHook().then(ortb2 => { + expect(ortb2.regs.ext.adchoices).to.equal(STATIC_SIGNAL); + }); + }); + + it('does not set anything when no signal is available', function () { + setAdChoicesConfig({}); + return callEnrichHook().then(ortb2 => { + expect(ortb2).to.eql({}); + }); + }); + }); + + describe('requestBidsHook (auction delay)', function () { + it('proceeds synchronously when no timeout is configured (non-blocking)', function () { + setAdChoicesConfig({}); + let proceeded = false; + requestBidsHook(() => { proceeded = true; }, {}); + expect(proceeded).to.equal(true); + }); + + it('proceeds immediately when a signal is already available, even with a timeout', function () { + setAdChoicesConfig({ signal: STATIC_SIGNAL, timeout: 1000 }); + let proceeded = false; + requestBidsHook(() => { proceeded = true; }, {}); + expect(proceeded).to.equal(true); + }); + + it('waits for the signal then proceeds when a timeout is configured', function (done) { + setAdChoicesConfig({ timeout: 1000 }); + let proceeded = false; + requestBidsHook(() => { proceeded = true; }, {}); + expect(proceeded).to.equal(false); + dispatchPMC(PMC_AD_PREFERENCES, SIGNAL); + setTimeout(() => { + expect(proceeded).to.equal(true); + done(); + }, 0); + }); + + it('proceeds after the timeout elapses when no signal arrives', function (done) { + setAdChoicesConfig({ timeout: 20 }); + let proceeded = false; + requestBidsHook(() => { proceeded = true; }, {}); + expect(proceeded).to.equal(false); + setTimeout(() => { + expect(proceeded).to.equal(true); + done(); + }, 60); + }); + + it('starts the timeout window when an auction waits, not at config time', function (done) { + setAdChoicesConfig({ timeout: 30 }); + // Let more than the timeout pass before any auction runs. + setTimeout(() => { + let proceeded = false; + requestBidsHook(() => { proceeded = true; }, {}); + // The window should start now, so the auction is still waiting (not expired). + expect(proceeded).to.equal(false); + dispatchPMC(PMC_AD_PREFERENCES, SIGNAL); + setTimeout(() => { + expect(proceeded).to.equal(true); + done(); + }, 0); + }, 60); + }); + + it('does not re-delay later auctions once the timeout has elapsed', function (done) { + setAdChoicesConfig({ timeout: 20 }); + let first = false; + requestBidsHook(() => { first = true; }, {}); + setTimeout(() => { + expect(first).to.equal(true); + // A subsequent auction must proceed immediately, not wait the timeout again. + let second = false; + requestBidsHook(() => { second = true; }, {}); + expect(second).to.equal(true); + done(); + }, 60); + }); + + it('removes the requestBids hook on reset so it does not leak globally', function () { + function installedCount() { + return getHook('requestBids').getHooks({ hook: requestBidsHook }).length; + } + setAdChoicesConfig({ timeout: 100 }); + expect(installedCount()).to.equal(1); + resetAdChoicesData(); + expect(installedCount()).to.equal(0); + // It can be reinstalled cleanly after a reset. + setAdChoicesConfig({ timeout: 100 }); + expect(installedCount()).to.equal(1); + }); + }); +}); From 4d7a4fe7e3fda822ede4339a418be55b9c6847e8 Mon Sep 17 00:00:00 2001 From: ad8pod Date: Sat, 25 Jul 2026 23:11:20 +1000 Subject: [PATCH 10/21] Module EightPod Adapter: update bid and analytics adapters (#15253) * Update eightpod bid and analytics adapters * Address burl review comment and fix nurl for adunit rendering * Skip blank tracker URLs before emitting pixels --- modules/eightPodAnalyticsAdapter.js | 205 ----- modules/eightPodAnalyticsAdapter.md | 19 - modules/eightpodAnalyticsAdapter.js | 289 +++++++ modules/eightpodAnalyticsAdapter.md | 19 + modules/eightpodBidAdapter.js | 450 ++++++++++ modules/eightpodBidAdapter.md | 59 ++ .../modules/eightpodAnalyticsAdapter_spec.js | 296 +++++++ test/spec/modules/eightpodBidAdapter_spec.js | 775 ++++++++++++++++++ 8 files changed, 1888 insertions(+), 224 deletions(-) delete mode 100644 modules/eightPodAnalyticsAdapter.js delete mode 100644 modules/eightPodAnalyticsAdapter.md create mode 100644 modules/eightpodAnalyticsAdapter.js create mode 100644 modules/eightpodAnalyticsAdapter.md create mode 100644 modules/eightpodBidAdapter.js create mode 100644 modules/eightpodBidAdapter.md create mode 100644 test/spec/modules/eightpodAnalyticsAdapter_spec.js create mode 100644 test/spec/modules/eightpodBidAdapter_spec.js diff --git a/modules/eightPodAnalyticsAdapter.js b/modules/eightPodAnalyticsAdapter.js deleted file mode 100644 index a5da31924..000000000 --- a/modules/eightPodAnalyticsAdapter.js +++ /dev/null @@ -1,205 +0,0 @@ -import { logError, logInfo, logMessage } from '../src/utils.js'; -import { ajax } from '../src/ajax.js'; -import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; -import { EVENTS } from '../src/constants.js'; -import adapterManager from '../src/adapterManager.js'; -import { MODULE_TYPE_ANALYTICS } from '../src/activities/modules.js'; -import { getStorageManager } from '../src/storageManager.js'; - -const analyticsType = 'endpoint'; -const MODULE_NAME = `eightPod`; -const MODULE = `${MODULE_NAME}AnalyticProvider`; - -/** - * Custom tracking server that gets internal events from EightPod's ad unit - */ -const trackerUrl = 'https://demo.8pod.com/tracker/track'; -export const storage = getStorageManager({ moduleType: MODULE_TYPE_ANALYTICS, moduleName: MODULE_NAME }); - -const { - BID_WON -} = EVENTS; - -export let queue = []; -let context = {}; - -/** - * Create eightPod Analytic adapter - */ -const eightPodAnalytics = Object.assign(adapter({ url: trackerUrl, analyticsType }), { - /** - * Execute on bid won - setup basic settings, save context about EightPod's bid. We will send it with our events later - */ - track({ eventType, args }) { - switch (eventType) { - case BID_WON: - if (args.bidder === 'eightPod') { - context[args.adUnitCode] = makeContext(args); - - eightPodAnalytics.setupPage(args); - break; - } - } - }, - - /** - * Execute on bid won upload events from local storage - */ - setupPage() { - queue = this.getEventFromLocalStorage(); - }, - - /** - * Subscribe on internal ad unit tracking events - */ - eventSubscribe() { - window.addEventListener('message', async (event) => { - const data = event.data; - - const frameElement = event.source?.frameElement; - const parentElement = frameElement?.parentElement; - const adUnitCode = parentElement?.id; - - trackEvent(data, adUnitCode); - }); - - if (!this._interval) { - this._interval = setInterval(sendEvents, 10_000); - } - }, - resetQueue() { - queue = []; - }, - getContext() { - return context; - }, - resetContext() { - context = {}; - }, - getEventFromLocalStorage, -}); - -/** - * Create context of event, who emits it - */ -function makeContext(args) { - const params = args?.params?.[0]; - return { - bidId: args.seatBidId, - variantId: args.creativeId || '', - campaignId: args.cid || '', - publisherId: params.publisherId, - placementId: params.placementId, - }; -} - -/** - * Create event, add context and push it to queue - */ -export function trackEvent(event, adUnitCode) { - if (!event.detail) { - return; - } - - const fullEvent = { - context: eightPodAnalytics.getContext()[adUnitCode], - eventType: event.detail.type, - eventClass: 'adunit', - timestamp: new Date().getTime(), - eventName: event.detail.name, - payload: event.detail.payload - }; - - logMessage(fullEvent); - addEvent(fullEvent); -} - -/** - * Push event to queue, save event list in local storage - */ -function addEvent(eventPayload) { - queue.push(eventPayload); - storage.setDataInLocalStorage(`EIGHT_POD_EVENTS`, JSON.stringify(queue), null); -} - -/** - * Gets previously saved event that has not been sent - */ -function getEventFromLocalStorage() { - const storedEvents = storage.localStorageIsEnabled() ? storage.getDataFromLocalStorage('EIGHT_POD_EVENTS') : null; - - if (storedEvents) { - return JSON.parse(storedEvents); - } else { - return []; - } -} - -/** - * Send event to our custom tracking server and reset queue - */ -function sendEvents() { - eightPodAnalytics.eventsStorage = queue; - - if (queue.length) { - try { - sendEventsApi(queue, { - success: () => { - resetLocalStorage(); - eightPodAnalytics.resetQueue(); - }, - error: (e) => { - logError(MODULE, 'Cant send events', e); - } - }); - } catch (e) { - logError(MODULE, 'Cant send events', e); - } - } -} - -/** - * Send event to our custom tracking server - */ -function sendEventsApi(eventList, callbacks) { - ajax(trackerUrl, callbacks, JSON.stringify(eventList), { keepalive: true }); -} - -/** - * Remove saved events in success scenario - */ -const resetLocalStorage = () => { - storage.setDataInLocalStorage(`EIGHT_POD_EVENTS`, JSON.stringify([]), null); -}; - -// save the base class function -eightPodAnalytics.originEnableAnalytics = eightPodAnalytics.enableAnalytics; -eightPodAnalytics.eventsStorage = []; - -// override enableAnalytics so we can get access to the config passed in from the page -// Subscribe on events from adUnit -eightPodAnalytics.enableAnalytics = function (config) { - eightPodAnalytics.originEnableAnalytics(config); - logInfo(MODULE, 'init', config); - eightPodAnalytics.eventSubscribe(); -}; - -eightPodAnalytics.disableAnalytics = ((orig) => { - return function () { - if (this._interval) { - clearInterval(this._interval); - this._interval = null; - } - return orig.apply(this, arguments); - }; -})(eightPodAnalytics.disableAnalytics); - -/** - * Register Analytics Adapter - */ -adapterManager.registerAnalyticsAdapter({ - adapter: eightPodAnalytics, - code: MODULE_NAME -}); - -export default eightPodAnalytics; diff --git a/modules/eightPodAnalyticsAdapter.md b/modules/eightPodAnalyticsAdapter.md deleted file mode 100644 index fe37bf344..000000000 --- a/modules/eightPodAnalyticsAdapter.md +++ /dev/null @@ -1,19 +0,0 @@ -# Overview -Module Name: 8pod Analytics by 8Pod - -Module Type: Analytics Adapter - -Maintainer: bianca@8pod.com - -# Description - -Analytics adapter for prebid provided by 8pod. It gets events from eightPod's ad unit and send it to our tracking server to improve user experience. -Please, use it ONLY with eightPodBidAdapter. - -# Analytics Adapter configuration example - -``` -{ - provider: 'eightPod' -} -``` diff --git a/modules/eightpodAnalyticsAdapter.js b/modules/eightpodAnalyticsAdapter.js new file mode 100644 index 000000000..4da4795fd --- /dev/null +++ b/modules/eightpodAnalyticsAdapter.js @@ -0,0 +1,289 @@ +import { logInfo } from '../src/utils.js'; +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import { EVENTS } from '../src/constants.js'; +import adapterManager from '../src/adapterManager.js'; + +const analyticsType = 'bundle'; +const MODULE_NAME = 'eightpod'; +const MODULE = `${MODULE_NAME}AnalyticProvider`; + +const tealiumnTrackTypes = { + pod_impression: 'view', + thumbstopper_view: 'view', + thumbstopper_click: 'link', + carousel_view: 'view', + carousel_swipe: 'link', + change_view: 'link', + pod_enter: 'link', + seconds_stay: 'link', + story_view: 'view', + pod_exit: 'link', + see_more_stories_click: 'link', + story_card_navigation_click: 'link', + scroll_tracking: 'link', + video_start: 'link', + video_pause: 'link', + video_play: 'link', + video_progress: 'link', + video_complete: 'link' +}; + +const eventsWithIabs = ['carousel_swipe', 'thumbstopper_click', 'pod_exit', 'story_view', 'see_more_stories_click', 'story_card_navigation_click', 'scroll_tracking']; + +const { + BID_WON +} = EVENTS; + +let context = {}; +const adFrameRegistry = new Map(); +/** + * Create eightPod Analytic adapter + */ +let eightPodAnalytics = Object.assign(adapter({ analyticsType }), { + /** + * Execute on bid won - setup basic settings, save context about EightPod's bid. We will send it with our events later + */ + track({ eventType, args }) { + switch (eventType) { + case BID_WON: + if (args.bidder === 'eightpod') { + context[args.adUnitCode] = makeContext(args); + registerAdFrames(args.adUnitCode); + setTimeout(() => registerAdFrames(args.adUnitCode), 0); + break; + } + } + }, + + /** + * Subscribe on internal ad unit tracking events + */ + eventSubscribe() { + if (this._messageHandler) { + window.removeEventListener('message', this._messageHandler); + } + this._messageHandler = async (event) => { + const data = event.data; + + if (!data?.detail) { + return; + } + + const adFrame = resolveAdFrameFromEvent(event); + if (!adFrame) { + return; + } + + const { adUnitCode, frameElement } = adFrame; + const currentAdUnitContext = eightPodAnalytics.getContext()[adUnitCode]; + + // send tealium events + if (data?.detail?.name && tealiumnTrackTypes[data?.detail?.name?.trim()]) { + let eventData = { + tealium_event: data?.detail.name, + ...data?.detail, + ...data?.detail?.payload, + rights_holder_id: currentAdUnitContext?.ext?.dataLayerLogistic?.organisationId ?? "", + rights_holder_name: currentAdUnitContext?.ext?.dataLayerLogistic?.organisationName ?? "", + sponsor_id: currentAdUnitContext?.ext?.dataLayerLogistic?.accountId ?? "", + sponsor_name: currentAdUnitContext?.ext?.dataLayerLogistic?.accountName ?? "", + variant_id: currentAdUnitContext?.ext?.variantId ?? "", + publisher_id: currentAdUnitContext?.ext?.publisherId ?? "", + publisher_name: currentAdUnitContext?.ext?.dataLayerLogistic?.publisherName ?? "", + publisher_iab_category_list_name: currentAdUnitContext?.ext?.dataLayerLogistic?.publisherIabCategoryNames ?? [], + publisher_iab_category_list_id: (currentAdUnitContext?.ext?.dataLayerLogistic?.publisherIabCategoryIds ?? []).map(String), + publisher_iab_sub_category_list_name: currentAdUnitContext?.ext?.dataLayerLogistic?.publisherIabSubCategoryNames ?? [], + publisher_iab_sub_category_list_id: (currentAdUnitContext?.ext?.dataLayerLogistic?.publisherIabSubCategoryIds ?? []).map(String), + publisher_user_id: currentAdUnitContext?.ext?.dataLayerLogistic?.userId ?? "", + user_email: getSafeUserIdentifier(currentAdUnitContext), + user_age: currentAdUnitContext?.ext?.dataLayerLogistic?.userAge ? currentAdUnitContext.ext.dataLayerLogistic.userAge.toString() : "", + user_gender: currentAdUnitContext?.ext?.dataLayerLogistic?.userGender ?? "", + user_city: currentAdUnitContext?.ext?.dataLayerLogistic?.userCity ?? "", + user_state: currentAdUnitContext?.ext?.dataLayerLogistic?.userState ?? "", + user_country: currentAdUnitContext?.ext?.dataLayerLogistic?.userCountry ?? "", + pod_id: currentAdUnitContext?.ext?.dataLayerLogistic?.podId ?? "", + pod_title: currentAdUnitContext?.ext?.dataLayerLogistic?.podName ?? "", + pod_language_code: currentAdUnitContext?.ext?.podLanguageCodes ?? [], + pod_country_code: currentAdUnitContext?.ext?.dataLayerLogistic?.podCountryCode ?? "", + campaign_id: currentAdUnitContext?.campaignId ?? "", + placement_id: currentAdUnitContext?.placementId ?? "", + bid_id: currentAdUnitContext?.bidId ?? "" + }; + const isWithIab = eventsWithIabs.includes(data?.detail?.name); + if (isWithIab) { + const storyInfo = currentAdUnitContext?.ext?.dataLayerLogistic?.slideInfos?.find(slide => slide.storyId === data?.detail?.storyId); + eventData = { + ...eventData, + Iab_category_name: storyInfo?.categoryNames, + Iab_category_id: storyInfo?.categoryIds + }; + } + try { + const trackType = tealiumnTrackTypes[data?.detail?.name?.trim()]; + frameElement?.contentWindow?.utag?.[trackType]?.(eventData); + } catch (e) { + // cross-origin frame access can throw SecurityError + } + } + }; + + window.addEventListener('message', this._messageHandler); + }, + getContext() { + return context; + }, + resetContext() { + context = {}; + resetAdFrameRegistry(); + }, +}); + +/** + * Create context of event, who emits it + */ +function makeContext(args) { + const params = args?.params; + return { + bidId: args?.seatBidId, + variantId: args?.creativeId || '', + campaignId: args?.cid || '', + publisherId: params?.publisherId, + placementId: params?.placementId, + crid: params?.crid, + ext: args?.ext, + advertiserDomains: args?.meta?.advertiserDomains || [], + }; +} + +function getSafeUserIdentifier(adUnitContext) { + const eids = adUnitContext?.ext?.eids; + if (!Array.isArray(eids)) { + return ""; + } + + for (const eid of eids) { + const uid = eid?.uids?.find(uid => uid?.id); + if (uid) { + return uid.id; + } + } + + return ""; +} + +function getAllowedOrigins(adUnitCode) { + const origins = new Set([window.location.origin]); + const adUnitContext = eightPodAnalytics.getContext()[adUnitCode]; + + (adUnitContext?.advertiserDomains || []).forEach((domain) => { + try { + const url = domain.startsWith('http') ? domain : `https://${domain}`; + origins.add(new URL(url).origin); + } catch (e) { + // ignore invalid advertiser domains + } + }); + + return origins; +} + +export function registerAdFrames(adUnitCode) { + if (!adUnitCode || !eightPodAnalytics.getContext()[adUnitCode]) { + return; + } + + const container = document.getElementById(adUnitCode); + if (!container) { + return; + } + + const allowedOrigins = getAllowedOrigins(adUnitCode); + for (const [contentWindow, frame] of adFrameRegistry.entries()) { + if (frame.adUnitCode === adUnitCode) { + adFrameRegistry.delete(contentWindow); + } + } + container.querySelectorAll('iframe').forEach((frameElement) => { + const contentWindow = frameElement.contentWindow; + if (contentWindow) { + adFrameRegistry.set(contentWindow, { adUnitCode, frameElement, allowedOrigins }); + } + }); +} + +function isAllowedOrigin(origin, frame) { + return !!origin && frame.allowedOrigins.has(origin); +} + +function resolveAdFrameFromEvent(event) { + const registered = adFrameRegistry.get(event.source); + if (registered) { + return isAllowedOrigin(event.origin, registered) ? registered : null; + } + + let frameElement; + try { + frameElement = event.source?.frameElement; + } catch (e) { + return null; + } + + if (!frameElement) { + return null; + } + + const adUnitCode = frameElement.parentElement?.id; + if (!adUnitCode || !eightPodAnalytics.getContext()[adUnitCode]) { + return null; + } + + const frame = { + adUnitCode, + frameElement, + allowedOrigins: getAllowedOrigins(adUnitCode), + }; + + if (!isAllowedOrigin(event.origin, frame)) { + return null; + } + + adFrameRegistry.set(event.source, frame); + return frame; +} + +export function resetAdFrameRegistry() { + adFrameRegistry.clear(); +} + +// save the base class function +eightPodAnalytics.originEnableAnalytics = eightPodAnalytics.enableAnalytics; + +// override enableAnalytics so we can get access to the config passed in from the page +// Subscribe on events from adUnit +eightPodAnalytics.enableAnalytics = function (config) { + eightPodAnalytics.originEnableAnalytics(config); + logInfo(MODULE, 'init', config); + eightPodAnalytics.eventSubscribe(); +}; + +// override disableAnalytics to release the message listener +eightPodAnalytics.disableAnalytics = ((orig) => { + return function () { + if (eightPodAnalytics._messageHandler) { + window.removeEventListener('message', eightPodAnalytics._messageHandler); + eightPodAnalytics._messageHandler = null; + } + resetAdFrameRegistry(); + return orig.apply(this, arguments); + }; +})(eightPodAnalytics.disableAnalytics); + +/** + * Register Analytics Adapter + */ +adapterManager.registerAnalyticsAdapter({ + adapter: eightPodAnalytics, + code: MODULE_NAME +}); + +export default eightPodAnalytics; diff --git a/modules/eightpodAnalyticsAdapter.md b/modules/eightpodAnalyticsAdapter.md new file mode 100644 index 000000000..64039ead1 --- /dev/null +++ b/modules/eightpodAnalyticsAdapter.md @@ -0,0 +1,19 @@ +# Overview +Module Name: 8pod Analytics by 8Pod + +Module Type: Analytics Adapter + +Maintainer: devs@8pod.com + +# Description + +Analytics adapter for Prebid provided by 8pod. It gets events from 8pod's ad unit and forwards supported events to Tealium. +Please, use it ONLY with eightpodBidAdapter. + +# Analytics Adapter configuration example + +```javascript +{ + provider: 'eightpod' +} +``` diff --git a/modules/eightpodBidAdapter.js b/modules/eightpodBidAdapter.js new file mode 100644 index 000000000..d06142861 --- /dev/null +++ b/modules/eightpodBidAdapter.js @@ -0,0 +1,450 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { EVENT_TYPE_IMPRESSION, TRACKER_METHOD_IMG } from '../src/eventTrackers.js'; +import { BANNER } from '../src/mediaTypes.js'; +import * as utils from '../src/utils.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { MODULE_TYPE_BIDDER } from '../src/activities/modules.js'; +import { config } from '../src/config.js'; + +export const BIDDER_CODE = 'eightpod'; +export const GVLID = 1497; +const EIGHTPOD_EID_SOURCE = '8podx.com'; +const storage = getStorageManager({ moduleType: MODULE_TYPE_BIDDER, bidderCode: BIDDER_CODE }); +const url = 'https://wild.8podx.com/bidder/rtb/eightpod_exchange/bid'; +const tealiumUrl = 'https://lib-cdn.8pod.com/main/prod/utag.js'; + +/** + * @typedef {Object} EightPodBidParams + * @property {string} [placementId] - Optional placement ID, sent as OpenRTB imp.tagid. + * @property {string} [publisherId] - Legacy override for ortb2.site.publisher.id. + * @property {string} [dealId] - Optional PMP deal ID override. + * @property {string} [userId] - Legacy override for ortb2.user.id; prefer user.ext.eids. + * @property {string} [eightPodVisitorId] - Optional EightPod/Tealium visitor ID for user.ext.eids. + * @property {boolean|string} [trace] - Enables trace mode for debugging. + * @property {string} [country] - Legacy override for ortb2.device.geo.country and ortb2.user.geo.country. + * @property {string} [language] - Legacy override for ortb2.device.language. + * @property {string} [publishercat] - Legacy comma-separated override for ortb2.site.publisher.cat. + * @property {string} [sitecat] - Legacy comma-separated override for ortb2.site.cat. + * @property {string} [pagecat] - Legacy comma-separated override for ortb2.site.pagecat. + * @property {string} [sectioncat] - Legacy comma-separated override for ortb2.site.sectioncat. + * @property {number|string} [yob] - Legacy override for ortb2.user.yob. + * @property {string} [gender] - Legacy override for ortb2.user.gender. + * @property {string} [city] - Legacy override for ortb2.user.geo.city. + * @property {string} [region] - Legacy override for ortb2.user.geo.region. + */ + +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER], + isBidRequestValid, + buildRequests, + interpretResponse, + isBannerBid, +}; + +registerBidder(spec); + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 300, + }, + request(buildRequest, imps, bidderRequest, context) { + const req = buildRequest(imps, bidderRequest, context); + return req; + }, + response(buildResponse, bidResponses, ortbResponse, context) { + const response = buildResponse(bidResponses, ortbResponse, context); + return response.bids; + }, + imp(buildImp, bidRequest, context) { + return buildImp(bidRequest, context); + }, + bidResponse +}); + +function isBidRequestValid(bidRequest) { + return !!bidRequest; +} + +function buildRequests(bids, bidderRequest) { + let bannerBids = bids.filter((bid) => isBannerBid(bid)); + let requests = bannerBids.length + ? createRequest(bannerBids, bidderRequest, BANNER) + : []; + + return requests; +} + +function bidResponse(buildBidResponse, bid, context) { + const nurl = replacePriceInUrl(bid.nurl, bid.price); + const bidWithoutNurl = { + ...bid, + nurl: undefined, + }; + + const bidResponse = buildBidResponse(bidWithoutNurl, context); + + bidResponse.height = context?.imp?.banner?.format?.[0].h; + bidResponse.width = context?.imp?.banner?.format?.[0].w; + bidResponse.cid = bid.cid; + bidResponse.ext = bid.ext; + bidResponse.crid = bid.crid; + bidResponse.burl = replacePriceInUrl(bid.burl, bidResponse.originalCpm || bidResponse.cpm); + bidResponse.ad = addWinNoticeTracker(bidResponse.ad, nurl); + addBillingEventTracker(bidResponse, bidResponse.burl); + + bidResponse.meta = { + advertiserDomains: bid.adomain || [], + mediaType: BANNER, + }; + + return bidResponse; +} + +function addBillingEventTracker(bidResponse, burl) { + if (typeof burl !== 'string' || burl.trim() === '') { + return; + } + + bidResponse.eventtrackers = [ + ...(Array.isArray(bidResponse.eventtrackers) ? bidResponse.eventtrackers : []), + { + event: EVENT_TYPE_IMPRESSION, + method: TRACKER_METHOD_IMG, + url: burl, + }, + ]; +} + +function addWinNoticeTracker(ad, nurl) { + if (typeof ad !== 'string' || typeof nurl !== 'string' || nurl.trim() === '') { + return ad; + } + + const trackingPixel = `
`; + const bodyMatch = /]*)?>/i.exec(ad); + + if (bodyMatch) { + const insertAt = bodyMatch.index + bodyMatch[0].length; + return `${ad.slice(0, insertAt)}${trackingPixel}${ad.slice(insertAt)}`; + } + + return `${trackingPixel}${ad}`; +} + +function escapeAttribute(value) { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function replacePriceInUrl(url, price) { + if (typeof url !== 'string') { + return url; + } + return url.replace(/\${AUCTION_PRICE}/, price); +} + +function isValidBidResponse(bid) { + const hasAd = typeof bid?.ad === 'string' && bid.ad.length > 0; + const hasValidNurl = bid.nurl === undefined || typeof bid.nurl === 'string'; + const hasValidBurl = bid.burl === undefined || typeof bid.burl === 'string'; + return hasAd && hasValidNurl && hasValidBurl; +} + +export function parseUserAgent() { + const ua = navigator.userAgent.toLowerCase(); + + // Check if it's iOS + if (/iphone|ipad|ipod/.test(ua)) { + // Extract iOS version and device type + const iosInfo = /\b(iphone|ipad|ipod)\b.*?\bos\s+(\d+(?:[._\s]\d+)*)/.exec(ua); + const iosVersion = iosInfo?.[2] || ''; + return { + platform: 'ios', + device: iosInfo?.[1] || '', + version: iosVersion.replace(/[._\s]+/g, '.') + }; + } else if (/android/.test(ua)) { + // Check if it's Android + // Extract Android version + const androidVersion = /android (\d+([._]\d+)?)/.exec(ua); + return { + platform: 'android', + version: androidVersion ? androidVersion[1].replace('_', '.') : '', + device: '' + }; + } else { + // If neither iOS nor Android, return unknown + return { + platform: 'Unknown', + version: '', + device: '' + }; + } +} + +export function getPageKeywords(win = window) { + let element; + + try { + element = win.top.document.querySelector('meta[name="keywords"]'); + } catch (e) { + element = document.querySelector('meta[name="keywords"]'); + } + + return ((element && element.content) || '').replaceAll(' ', ''); +} + +function getCookie(name) { + return storage.cookiesAreEnabled() ? storage.getCookie(name) : undefined; +} + +function appendEightPodEid(eids, eightPodVisitorId) { + if (!eightPodVisitorId) { + return eids; + } + + const existingEids = Array.isArray(eids) ? eids : []; + const eightPodEid = existingEids.find(eid => eid.source === EIGHTPOD_EID_SOURCE); + const eightPodUid = { id: eightPodVisitorId, atype: 1 }; + + if (eightPodEid) { + const existingUids = Array.isArray(eightPodEid.uids) ? eightPodEid.uids : []; + if (existingUids.some(uid => uid.id === eightPodVisitorId)) { + return existingEids; + } + + return existingEids.map(eid => eid === eightPodEid + ? { ...eid, uids: [...existingUids, eightPodUid] } + : eid + ); + } + + return [ + ...existingEids, + { + source: EIGHTPOD_EID_SOURCE, + uids: [eightPodUid], + } + ]; +} + +function getExistingEids(data, bidRequest, bidderRequest) { + return [ + data.user?.ext?.eids, + bidRequest.userIdAsEids, + utils.deepAccess(bidRequest, 'ortb2.user.ext.eids'), + utils.deepAccess(bidderRequest, 'ortb2.user.ext.eids'), + ].reduce((eids, value) => Array.isArray(value) ? eids.concat(value) : eids, []); +} + +function hasParam(params, key) { + return params?.[key] !== undefined && params[key] !== null && params[key] !== ''; +} + +function parseCategoryParam(value) { + return String(value).split(',').map(s => s.trim()).filter(Boolean); +} + +function setOverride(target, path, value) { + if (value !== undefined) { + utils.deepSetValue(target, path, value); + } +} + +function getLegacyDeviceOverrides(params) { + const device = {}; + + setOverride(device, 'geo.country', hasParam(params, 'country') ? params.country : undefined); + setOverride(device, 'language', hasParam(params, 'language') ? params.language : undefined); + + return device; +} + +function getLegacySiteOverrides(params) { + const site = {}; + + setOverride(site, 'publisher.id', hasParam(params, 'publisherId') ? params.publisherId : undefined); + setOverride(site, 'publisher.cat', hasParam(params, 'publishercat') ? parseCategoryParam(params.publishercat) : undefined); + setOverride(site, 'cat', hasParam(params, 'sitecat') ? parseCategoryParam(params.sitecat) : undefined); + setOverride(site, 'pagecat', hasParam(params, 'pagecat') ? parseCategoryParam(params.pagecat) : undefined); + setOverride(site, 'sectioncat', hasParam(params, 'sectioncat') ? parseCategoryParam(params.sectioncat) : undefined); + + return site; +} + +function getLegacyUserOverrides(params) { + const user = {}; + + setOverride(user, 'id', hasParam(params, 'userId') ? params.userId : undefined); + setOverride(user, 'yob', hasParam(params, 'yob') ? params.yob : undefined); + setOverride(user, 'gender', hasParam(params, 'gender') ? params.gender : undefined); + setOverride(user, 'geo.city', hasParam(params, 'city') ? params.city : undefined); + setOverride(user, 'geo.region', hasParam(params, 'region') ? params.region : undefined); + setOverride(user, 'geo.country', hasParam(params, 'country') ? params.country : undefined); + + return user; +} + +export function applyPrivacyConsent(data, bidderRequest) { + const { gdprConsent, uspConsent, gppConsent } = bidderRequest || {}; + + if (gdprConsent) { + if (typeof gdprConsent.gdprApplies === 'boolean') { + utils.deepSetValue(data, 'regs.ext.gdpr', gdprConsent.gdprApplies ? 1 : 0); + } + if (gdprConsent.consentString) { + utils.deepSetValue(data, 'user.ext.consent', gdprConsent.consentString); + } + } + + if (uspConsent) { + utils.deepSetValue(data, 'regs.ext.us_privacy', uspConsent); + } + + if (gppConsent) { + if (gppConsent.gppString) { + utils.deepSetValue(data, 'regs.gpp', gppConsent.gppString); + utils.deepSetValue(data, 'regs.ext.gpp', gppConsent.gppString); + } + if (Array.isArray(gppConsent.applicableSections)) { + utils.deepSetValue(data, 'regs.gpp_sid', gppConsent.applicableSections); + utils.deepSetValue(data, 'regs.ext.gpp_sid', gppConsent.applicableSections); + } + } else if (utils.deepAccess(bidderRequest, 'ortb2.regs.gpp')) { + utils.deepSetValue(data, 'regs.gpp', bidderRequest.ortb2.regs.gpp); + utils.deepSetValue(data, 'regs.ext.gpp', bidderRequest.ortb2.regs.gpp); + if (utils.deepAccess(bidderRequest, 'ortb2.regs.gpp_sid')) { + utils.deepSetValue(data, 'regs.gpp_sid', bidderRequest.ortb2.regs.gpp_sid); + utils.deepSetValue(data, 'regs.ext.gpp_sid', bidderRequest.ortb2.regs.gpp_sid); + } + } + + if (config.getConfig('coppa') === true) { + utils.deepSetValue(data, 'regs.coppa', 1); + } +} + +export function createRequest(bidRequests, bidderRequest, mediaType) { + const requests = bidRequests.map((bidRequest) => { + const data = converter.toORTB({ + bidRequests: [bidRequest], + bidderRequest, + context: { mediaType }, + }); + + data.at = 1; + + const params = getBidderParams(bidRequest); + data.device = utils.mergeDeep({}, data.device, getLegacyDeviceOverrides(params)); + data.site = utils.mergeDeep({}, data.site, getLegacySiteOverrides(params)); + if (hasParam(params, 'publishercat')) { + data.site.publisher = data.site.publisher || {}; + data.site.publisher.cat = parseCategoryParam(params.publishercat); + } + if (hasParam(params, 'sitecat')) { + data.site.cat = parseCategoryParam(params.sitecat); + } + if (hasParam(params, 'pagecat')) { + data.site.pagecat = parseCategoryParam(params.pagecat); + } + if (hasParam(params, 'sectioncat')) { + data.site.sectioncat = parseCategoryParam(params.sectioncat); + } + data.ext = utils.mergeDeep({}, data.ext, { + adSlotPositionOnScreen: '1', + ...(hasParam(params, 'placementId') ? { adSlotPlacementId: params.placementId } : {}), + }); + const existingPmp = data.imp?.[0]?.pmp; + data.imp = [ + { + ...data.imp?.[0], + secure: 1, + ...(hasParam(params, 'placementId') ? { tagid: params.placementId } : {}), + pmp: params.dealId + ? { + ...(existingPmp || {}), + deals: [ + { + id: params.dealId, + }, + ], + private_auction: 1, + } + : existingPmp, + } + ]; + + const eightPodVisitorId = params.eightPodVisitorId || getCookie('utag_main_v_id'); + const eids = getExistingEids(data, bidRequest, bidderRequest); + + data.user = utils.mergeDeep({}, data.user, getLegacyUserOverrides(params), { + ext: { + eightPodVisitorId, + eids: appendEightPodEid(eids, eightPodVisitorId), + } + }); + + applyPrivacyConsent(data, bidderRequest); + + const req = { + method: 'POST', + url: url && params.trace ? url + '?trace=true' : url, + options: { withCredentials: false }, + data + }; + return req; + }); + + return requests; +} + +function getBidderParams(bid) { + return bid?.params || {}; +} + +function isBannerBid(bid) { + return utils.deepAccess(bid, 'mediaTypes.banner'); +} + +function interpretResponse(resp, req) { + if (!resp?.body) { + return []; + } + + const bidResponses = converter.fromORTB({ request: req.data, response: resp.body }); + + if (!Array.isArray(bidResponses) || bidResponses.length === 0) { + return []; + } + + const validBids = bidResponses.filter(isValidBidResponse); + if (validBids.length === 0) { + return []; + } + + const trackingTag = ` + + + `; + + validBids.forEach((bid) => { + bid.ad = bid.ad.replace('', trackingTag + ''); + }); + return validBids; +} diff --git a/modules/eightpodBidAdapter.md b/modules/eightpodBidAdapter.md new file mode 100644 index 000000000..87e95c195 --- /dev/null +++ b/modules/eightpodBidAdapter.md @@ -0,0 +1,59 @@ +# Overview +Module Name: 8pod Bidder Adapter + +Module Type: Bidder Adapter + +Maintainer: devs@8pod.com + +# Description + +Connect to 8pod for bids. + +This adapter requires setup and approval from the 8pod team. + +Please add eightpodAnalyticsAdapter to collect user behavior and improve user experience as well. + +# Bid Params + +OpenRTB first-party data should be supplied through `ortb2` / `ortb2Imp`. The adapter preserves Prebid User ID module identifiers in `user.ext.eids`. + +| Name | Scope | Description | Example | Type | +|------|-------|-------------|---------|------| +| `placementId` | optional | The unique identifier of the ad placement. When provided, sent as OpenRTB `imp.tagid`; also sent as legacy `ext.adSlotPlacementId` for compatibility. | "placementId-438753744289" | `string` | +| `publisherId` | optional, legacy override | Overrides OpenRTB `site.publisher.id` when provided. Prefer `ortb2.site.publisher.id` for publisher-facing integrations. | "publisherId-438753744289" | `string` | +| `dealId` | optional | PMP deal ID sent as `imp.pmp.deals[].id` when provided. | "deal-123" | `string` | +| `trace` | optional | Enables trace mode by adding `?trace=true` to the bidder endpoint for debugging. | true | `boolean` or `string` | +| `userId` | optional, legacy override | Overrides OpenRTB `user.id` when provided. Prefer User ID modules or `ortb2.user.ext.eids`. | "user-123" | `string` | +| `eightPodVisitorId` | optional | Publisher-provided EightPod/Tealium visitor reference sent as OpenRTB `user.ext.eids` and legacy `user.ext.eightPodVisitorId`. Overrides the `utag_main_v_id` cookie value when provided. | "visitor-123" | `string` | +| `country` | optional, legacy override | Overrides OpenRTB `device.geo.country` and `user.geo.country` when provided. Prefer `ortb2.device.geo.country` / `ortb2.user.geo.country`. | "AUS" | `string` | +| `language` | optional, legacy override | Overrides OpenRTB `device.language` when provided. Prefer `ortb2.device.language`. | "en" | `string` | +| `publishercat` | optional, legacy override | Comma-separated override for OpenRTB `site.publisher.cat`. Prefer `ortb2.site.publisher.cat`. | "IAB1,IAB2" | `string` | +| `sitecat` | optional, legacy override | Comma-separated override for OpenRTB `site.cat`. Prefer `ortb2.site.cat`. | "IAB3" | `string` | +| `pagecat` | optional, legacy override | Comma-separated override for OpenRTB `site.pagecat`. Prefer `ortb2.site.pagecat`. | "IAB4" | `string` | +| `sectioncat` | optional, legacy override | Comma-separated override for OpenRTB `site.sectioncat`. Prefer `ortb2.site.sectioncat`. | "IAB5" | `string` | +| `yob` | optional, legacy override | Overrides OpenRTB `user.yob` when provided. Prefer `ortb2.user.yob`. | 1990 | `number` or `string` | +| `gender` | optional, legacy override | Overrides OpenRTB `user.gender` when provided. Prefer `ortb2.user.gender`. | "M" | `string` | +| `city` | optional, legacy override | Overrides OpenRTB `user.geo.city` when provided. Prefer `ortb2.user.geo.city`. | "Sydney" | `string` | +| `region` | optional, legacy override | Overrides OpenRTB `user.geo.region` when provided. Prefer `ortb2.user.geo.region`. | "NSW" | `string` | + +# Test Parameters + +```javascript +var adUnits = [{ + code: 'something', + mediaTypes: { + banner: { + sizes: [[350, 550]], + }, + }, + bids: [ + { + bidder: 'eightpod', + params: { + placementId: '13144370', + publisherId: 'publisherID-488864646', + }, + }, + ], + }]; +``` diff --git a/test/spec/modules/eightpodAnalyticsAdapter_spec.js b/test/spec/modules/eightpodAnalyticsAdapter_spec.js new file mode 100644 index 000000000..f90a90814 --- /dev/null +++ b/test/spec/modules/eightpodAnalyticsAdapter_spec.js @@ -0,0 +1,296 @@ +import eightPodAnalytics, { resetAdFrameRegistry, registerAdFrames } from 'modules/eightpodAnalyticsAdapter.js'; +import { expect } from 'chai'; +import adapterManager from 'src/adapterManager.js'; +import { EVENTS } from '../../../src/constants.js'; + +const { + BID_WON +} = EVENTS; + +describe('eightPodAnalyticAdapter', function() { + let sandbox; + + beforeEach(function() { + sandbox = sinon.createSandbox(); + adapterManager.enableAnalytics({ + provider: 'eightpod' + }); + }); + + afterEach(function() { + sandbox.restore(); + eightPodAnalytics.disableAnalytics(); + }); + + describe('track event', function() { + beforeEach(function() { + eightPodAnalytics.resetContext(); + }); + + it('should not create context for non-bidWon events', function() { + eightPodAnalytics.track({ + eventType: 'wrong_event_type', + }); + + expect(eightPodAnalytics.getContext()).to.deep.equal({}); + }); + + it('should save context for eightpod bidWon events', function() { + const ext = { dataLayerLogistic: { podId: 'podId' } }; + + eightPodAnalytics.track({ + eventType: BID_WON, + args: { + adUnitCode: 'adUnitCode', + bidder: 'eightpod', + creativeId: 'creativeId', + seatBidId: 'seatBidId', + cid: 'campaignId', + ext, + params: { + publisherId: 'publisherId', + placementId: 'placementId', + crid: 'crid', + } + } + }); + + expect(eightPodAnalytics.getContext()).to.deep.equal({ + adUnitCode: { + bidId: 'seatBidId', + campaignId: 'campaignId', + placementId: 'placementId', + publisherId: 'publisherId', + variantId: 'creativeId', + crid: 'crid', + ext, + advertiserDomains: [], + } + }); + }); + }); + + describe('eventSubscribe', function() { + let addEventListenerStub; + let getContextStub; + let messageHandler; + let utag; + + beforeEach(function() { + addEventListenerStub = sandbox.stub(window, 'addEventListener'); + getContextStub = sandbox.stub(eightPodAnalytics, 'getContext').returns({ adUnitCode: {} }); + resetAdFrameRegistry(); + eightPodAnalytics.eventSubscribe(); + const messageCalls = addEventListenerStub.getCalls().filter(c => c.args[0] === 'message'); + messageHandler = messageCalls[messageCalls.length - 1].args[1]; + utag = { view: sandbox.spy(), link: sandbox.spy() }; + }); + + function makeEvent(detail, options = {}) { + return { + data: { detail }, + origin: options.origin ?? window.location.origin, + source: options.source ?? { + frameElement: { + parentElement: { id: 'adUnitCode' }, + contentWindow: { utag } + } + } + }; + } + + it('returns early when the message has no detail', async function() { + await messageHandler({ data: {} }); + expect(utag.view.callCount).to.equal(0); + expect(utag.link.callCount).to.equal(0); + }); + + it('does not call utag for events outside of tealiumTrackTypes', async function() { + await messageHandler(makeEvent({ type: 'Counter', name: 'next_slide', payload: { v: 1 } })); + expect(utag.view.callCount).to.equal(0); + expect(utag.link.callCount).to.equal(0); + }); + + it('calls utag.view for pod_impression with full context payload', async function() { + const ctx = { + ext: { + variantId: 'v-1', + publisherId: 'pub-id', + podLanguageCodes: ['en'], + eids: [{ + source: '8podx.com', + uids: [{ id: 'safe-user-id' }], + }], + dataLayerLogistic: { + organisationId: 'org-1', + organisationName: 'Org', + accountId: 'acc-1', + accountName: 'Acc', + publisherName: 'Pub', + publisherIabCategoryNames: ['IAB1'], + publisherIabCategoryIds: [1, 2], + publisherIabSubCategoryNames: ['Sub1'], + publisherIabSubCategoryIds: [3], + userId: 'u-1', + userEmail: 'u@e.com', + userAge: 30, + userGender: 'M', + userCity: 'NY', + userState: 'NY', + userCountry: 'US', + podId: 'pod-1', + podName: 'Pod', + podCountryCode: 'US', + }, + }, + campaignId: 'c-1', + placementId: 'p-1', + bidId: 'b-1', + }; + getContextStub.returns({ adUnitCode: ctx }); + + await messageHandler(makeEvent({ type: 'View', name: 'pod_impression', payload: { foo: 'bar' } })); + + expect(utag.view.callCount).to.equal(1); + const eventData = utag.view.getCall(0).args[0]; + expect(eventData.tealium_event).to.equal('pod_impression'); + expect(eventData.rights_holder_id).to.equal('org-1'); + expect(eventData.publisher_iab_category_list_id).to.deep.equal(['1', '2']); + expect(eventData.publisher_user_id).to.equal('u-1'); + expect(eventData.user_email).to.equal('safe-user-id'); + expect(eventData.user_age).to.equal('30'); + expect(eventData.pod_id).to.equal('pod-1'); + expect(eventData.bid_id).to.equal('b-1'); + expect(eventData.foo).to.equal('bar'); + expect(eventData.user_email).to.not.equal('u@e.com'); + }); + + it('adds Iab_category fields for events listed in eventsWithIabs', async function() { + const ctx = { + ext: { + dataLayerLogistic: { + slideInfos: [ + { storyId: 'story-1', categoryNames: ['Sport'], categoryIds: [11] } + ], + }, + }, + }; + getContextStub.returns({ adUnitCode: ctx }); + + await messageHandler(makeEvent({ + type: 'Link', + name: 'carousel_swipe', + storyId: 'story-1', + payload: {}, + })); + + expect(utag.link.callCount).to.equal(1); + const eventData = utag.link.getCall(0).args[0]; + expect(eventData.Iab_category_name).to.deep.equal(['Sport']); + expect(eventData.Iab_category_id).to.deep.equal([11]); + }); + + it('falls back to empty defaults when context fields are absent', async function() { + getContextStub.returns({ adUnitCode: {} }); + + await messageHandler(makeEvent({ + type: 'View', + name: 'pod_impression', + payload: {}, + })); + + expect(utag.view.callCount).to.equal(1); + const eventData = utag.view.getCall(0).args[0]; + expect(eventData.rights_holder_id).to.equal(''); + expect(eventData.publisher_iab_category_list_id).to.deep.equal([]); + expect(eventData.user_age).to.equal(''); + expect(eventData.bid_id).to.equal(''); + }); + + it('ignores messages from unrecognized ad units', async function() { + getContextStub.returns({}); + + await messageHandler(makeEvent({ + type: 'View', + name: 'pod_impression', + payload: {}, + })); + + expect(utag.view.callCount).to.equal(0); + }); + + it('ignores messages from disallowed origins', async function() { + await messageHandler(makeEvent({ + type: 'View', + name: 'pod_impression', + payload: {}, + }, { origin: 'https://evil.example' })); + + expect(utag.view.callCount).to.equal(0); + }); + + it('ignores messages when frameElement access throws', async function() { + const blockedSource = {}; + Object.defineProperty(blockedSource, 'frameElement', { + get() { + throw new DOMException('Blocked a frame with origin from accessing a cross-origin frame.'); + } + }); + + await messageHandler({ + data: { detail: { type: 'View', name: 'pod_impression', payload: {} } }, + origin: 'https://evil.example', + source: blockedSource, + }); + + expect(utag.view.callCount).to.equal(0); + }); + + it('does not reject the handler when utag access throws', async function() { + const contentWindow = {}; + Object.defineProperty(contentWindow, 'utag', { + get() { + throw new DOMException('Blocked a frame with origin from accessing a cross-origin frame.'); + } + }); + + await messageHandler(makeEvent({ + type: 'View', + name: 'pod_impression', + payload: {}, + }, { + source: { + frameElement: { + parentElement: { id: 'adUnitCode' }, + contentWindow, + } + } + })); + + expect(utag.view.callCount).to.equal(0); + }); + + it('accepts messages from registered ad iframe content windows', async function() { + const container = document.createElement('div'); + container.id = 'adUnitCode'; + const iframe = document.createElement('iframe'); + container.appendChild(iframe); + document.body.appendChild(container); + iframe.contentWindow.utag = utag; + + try { + registerAdFrames('adUnitCode'); + + await messageHandler({ + data: { detail: { type: 'View', name: 'pod_impression', payload: {} } }, + origin: window.location.origin, + source: iframe.contentWindow, + }); + + expect(utag.view.callCount).to.equal(1); + } finally { + document.body.removeChild(container); + } + }); + }); +}); diff --git a/test/spec/modules/eightpodBidAdapter_spec.js b/test/spec/modules/eightpodBidAdapter_spec.js new file mode 100644 index 000000000..4edad3319 --- /dev/null +++ b/test/spec/modules/eightpodBidAdapter_spec.js @@ -0,0 +1,775 @@ +import { expect } from 'chai'; +import { spec, getPageKeywords, parseUserAgent, createRequest, applyPrivacyConsent, GVLID } from 'modules/eightpodBidAdapter'; +import 'modules/priceFloors.js'; +import { config } from 'src/config.js'; +import { EVENT_TYPE_IMPRESSION, TRACKER_METHOD_IMG } from 'src/eventTrackers.js'; +import { newBidder } from 'src/adapters/bidderFactory'; +import sinon from 'sinon'; + +describe('eightpodBidAdapter', function () { + const adapter = newBidder(spec); + describe('inherited functions', function () { + it('exists and is a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + + it('registers IAB GVL ID 1497', function () { + expect(spec.gvlid).to.equal(GVLID); + }); + }); + + describe('isBidRequestValid', function () { + const bidWithPlacementId = { + bidder: 'eightpod', + adUnitCode: '/adunit-code/test-path', + bidId: 'test-bid-id-1', + bidderRequestId: 'test-bid-request-1', + auctionId: 'test-auction-1', + transactionId: 'test-transactionId-1', + params: { + placementId: 'placementId1', + }, + }; + const bidWithoutParams = { + bidder: 'eightpod', + adUnitCode: '/adunit-code/test-path', + bidId: 'test-bid-id-1', + bidderRequestId: 'test-bid-request-1', + auctionId: 'test-auction-1', + transactionId: 'test-transactionId-1', + }; + + beforeEach(() => { + config.resetConfig(); + }); + + it('should return true when placementId is provided', function () { + expect(spec.isBidRequestValid(bidWithPlacementId)).to.equal(true); + }); + + it('should return true when placementId is omitted', function () { + expect(spec.isBidRequestValid(bidWithoutParams)).to.equal(true); + }); + + it('should return false when the bid request is missing', function () { + expect(spec.isBidRequestValid()).to.equal(false); + }); + }); + + describe('buildRequests', function () { + let bidRequests, bidderRequest; + beforeEach(function () { + bidRequests = [ + { + bidder: 'eightpod', + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 600], + ], + }, + }, + adUnitCode: '/adunit-code/test-path', + bidId: 'test-bid-id-1', + bidderRequestId: 'test-bid-request-1', + auctionId: 'test-auction-1', + transactionId: 'test-transactionId-1', + params: { + placementId: 'placementId1', + } + } + ]; + bidderRequest = { + refererInfo: {}, + ortb2: { + device: { + ua: 'ua', + language: 'en', + dnt: 1, + js: 1, + } + } + }; + }); + + it('should return an empty array when no bid requests', function () { + const bidRequest = spec.buildRequests([], bidderRequest); + expect(bidRequest).to.be.an('array'); + expect(bidRequest.length).to.equal(0); + }); + + it('should return a valid bid request object', function () { + const request = spec.buildRequests(bidRequests, bidderRequest); + + expect(request).to.be.an('array'); + expect(request[0].data).to.be.an('object'); + expect(request[0].method).to.equal('POST'); + expect(request[0]).to.have.property('url'); + }); + }); + + describe('getPageKeywords function', function() { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return the top document keywords if available', function() { + const keywordsContent = 'keyword1,keyword2,keyword3'; + const fakeTopDocument = { + querySelector: sandbox.stub() + .withArgs('meta[name="keywords"]').returns({ content: keywordsContent }) + }; + const fakeTopWindow = { document: fakeTopDocument }; + + const result = getPageKeywords({ top: fakeTopWindow }); + expect(result).to.equal(keywordsContent); + }); + + it('should return the current document keywords if top document is not accessible', function() { + const keywordsContent = 'keyword1,keyword2,keyword3'; + sandbox.stub(document, 'querySelector') + .withArgs('meta[name="keywords"]').returns({ content: keywordsContent }); + + const fakeWindow = { + get top() { + throw new Error('Access denied'); + } + }; + + const result = getPageKeywords(fakeWindow); + expect(result).to.equal(keywordsContent); + }); + + it('should return an empty string if no keywords meta tag is found', function() { + sandbox.stub(document, 'querySelector').withArgs('meta[name="keywords"]').returns(null); + + const result = getPageKeywords(); + expect(result).to.equal(''); + }); + }); + + describe('parseUserAgent function', function() { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return the platform and version IOS', function() { + const uaStub = sandbox.stub(window.navigator, 'userAgent'); + uaStub.value('Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'); + + const result = parseUserAgent(); + expect(result.platform).to.equal('ios'); + expect(result.device).to.equal('iphone'); + expect(result.version).to.equal('16.6'); + }); + + it('should return the platform and version android', function() { + const uaStub = sandbox.stub(window.navigator, 'userAgent'); + uaStub.value('Mozilla/5.0 (Linux; Android 5.0.1; SM-G920V Build/LRX22C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.137 Mobile Safari/537.36'); + + const result = parseUserAgent(); + expect(result.platform).to.equal('android'); + expect(result.version).to.equal('5.0'); + expect(result.device).to.equal(''); + }); + }); + + describe('interpretResponse', function() { + let request; + + beforeEach(function() { + const bidRequests = [ + { + bidder: 'eightpod', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + adUnitCode: '/adunit-code/test-path', + bidId: 'test-bid-id-1', + bidderRequestId: 'test-bid-request-1', + auctionId: 'test-auction-1', + transactionId: 'test-transactionId-1', + params: { placementId: 'placementId1' }, + } + ]; + [request] = spec.buildRequests(bidRequests, { refererInfo: {}, ortb2: { device: {} } }); + }); + + it('should return the bid response with the tracking tag injected and pricing macros replaced', function() { + const serverResponse = { + body: { + id: 'response-id', + seatbid: [ + { + bid: [ + { + impid: 'test-bid-id-1', + price: 1.23, + cid: 'campaign-1', + crid: 'creative-1', + ext: { foo: 'bar' }, + adm: 'ad', + nurl: 'https://example.com/nurl?p=${AUCTION_PRICE}', + burl: 'https://example.com/burl?p=${AUCTION_PRICE}', + } + ] + } + ], + cur: 'USD', + } + }; + + const responses = spec.interpretResponse(serverResponse, request); + + expect(responses).to.be.an('array').with.length(1); + const [bid] = responses; + expect(bid.cid).to.equal('campaign-1'); + expect(bid.crid).to.equal('creative-1'); + expect(bid.ext).to.deep.equal({ foo: 'bar' }); + expect(bid.ad).to.exist; + expect(bid.ad).to.match(/^/); + expect(bid.ad).to.include('
ad'); + expect(bid.burl).to.equal('https://example.com/burl?p=1.23'); + expect(bid.eventtrackers).to.deep.include({ + event: EVENT_TYPE_IMPRESSION, + method: TRACKER_METHOD_IMG, + url: 'https://example.com/burl?p=1.23', + }); + }); + + it('should not add trackers for blank nurl or burl values', function() { + const serverResponse = { + body: { + id: 'response-id', + seatbid: [ + { + bid: [ + { + impid: 'test-bid-id-1', + price: 1.23, + adm: 'ad', + nurl: ' ', + burl: '', + } + ] + } + ], + cur: 'USD', + } + }; + + const responses = spec.interpretResponse(serverResponse, request); + + expect(responses).to.be.an('array').with.length(1); + const [bid] = responses; + expect(bid.ad).to.include('ad'); + expect(bid.ad).to.not.include('visibility:hidden'); + expect(bid.ad).to.not.include('ad', + nurl: 'https://example.com/nurl?p=${AUCTION_PRICE}', + burl: 'https://example.com/burl?p=${AUCTION_PRICE}', + adomain: ['example.com', 'advertiser.org'], + } + ] + } + ], + cur: 'USD', + } + }; + + const [bid] = spec.interpretResponse(serverResponse, request); + + expect(bid.meta.advertiserDomains).to.deep.equal(['example.com', 'advertiser.org']); + expect(bid.meta.mediaType).to.equal('banner'); + }); + + it('should return an empty array for empty or malformed responses', function() { + expect(spec.interpretResponse({}, request)).to.deep.equal([]); + expect(spec.interpretResponse({ body: null }, request)).to.deep.equal([]); + expect(spec.interpretResponse({ body: { id: 'no-bid', seatbid: [] } }, request)).to.deep.equal([]); + expect(spec.interpretResponse({ + body: { + id: 'response-id', + seatbid: [{ bid: [{ impid: 'test-bid-id-1', price: 1.23 }] }], + cur: 'USD', + } + }, request)).to.deep.equal([]); + }); + }); + + describe('createRequest function', function() { + afterEach(function() { + config.resetConfig(); + }); + + it('should create a payload with all required fields from params', function() { + const bidRequests = [{ + params: { + placementId: '123', + publisherId: 'pub-1', + publishercat: 'IAB1,IAB2', + sitecat: 'IAB3', + pagecat: 'IAB4', + sectioncat: 'IAB5', + country: 'US', + language: 'en', + dealId: 'deal-1', + userId: 'user-1', + yob: 1990, + gender: 'M', + city: 'New York', + region: 'NY', + eightPodVisitorId: 'visitor-1', + trace: true, + }, + userId: {} + }]; + const bidderRequest = {}; + const mediaType = 'banner'; + + const [request] = createRequest(bidRequests, bidderRequest, mediaType); + + // All the values in the initValue (public\index.html) should be covered here + expect(request.data.site.publisher.cat).to.deep.equal(['IAB1', 'IAB2']); + expect(request.data.site.cat).to.deep.equal(['IAB3']); + expect(request.data.site.pagecat).to.deep.equal(['IAB4']); + expect(request.data.site.sectioncat).to.deep.equal(['IAB5']); + expect(request.data.user.id).to.equal('user-1'); + expect(request.data.user.yob).to.equal(1990); + expect(request.data.user.gender).to.equal('M'); + expect(request.data.user.geo.city).to.equal('New York'); + expect(request.data.user.geo.region).to.equal('NY'); + expect(request.data.user.geo.country).to.equal('US'); + expect(request.data.user.ext.eightPodVisitorId).to.equal('visitor-1'); + expect(request.data.user.ext.eids).to.deep.include({ + source: '8podx.com', + uids: [{ id: 'visitor-1', atype: 1 }], + }); + expect(request.data.ext.adSlotPlacementId).to.equal('123'); + expect(request.data.imp[0].tagid).to.equal('123'); + expect(request.data.site.publisher.id).to.equal('pub-1'); + expect(request.data.device.language).to.equal('en'); + expect(request.data.imp[0].pmp.deals[0].id).to.equal('deal-1'); + if (request.url) { + expect(request.url).to.include('?trace=true'); + } + }); + + it('preserves ortb2Imp PMP when dealId is omitted', function() { + const bidRequests = [{ + params: { placementId: '123' }, + userId: {}, + ortb2Imp: { + pmp: { + deals: [{ id: 'ortb-deal' }], + private_auction: 0, + }, + }, + }]; + + const [request] = createRequest(bidRequests, {}, 'banner'); + + expect(request.data.imp[0].pmp).to.deep.equal({ + deals: [{ id: 'ortb-deal' }], + private_auction: 0, + }); + }); + + it('preserves ortb2Imp PMP fields when applying dealId override', function() { + const bidRequests = [{ + params: { + placementId: '123', + dealId: 'param-deal', + }, + userId: {}, + ortb2Imp: { + pmp: { + deals: [{ id: 'ortb-deal' }], + ext: { 'package': 'sports' }, + }, + }, + }]; + + const [request] = createRequest(bidRequests, {}, 'banner'); + + expect(request.data.imp[0].pmp).to.deep.equal({ + deals: [{ id: 'param-deal' }], + ext: { 'package': 'sports' }, + private_auction: 1, + }); + }); + + it('preserves OpenRTB first-party data when legacy override params are omitted', function() { + const bidRequests = [{ + params: { placementId: '123' }, + userId: {}, + }]; + const bidderRequest = { + ortb2: { + device: { + language: 'en', + geo: { country: 'USA' }, + }, + site: { + keywords: 'sports,news', + publisher: { + id: 'ortb-pub', + cat: ['IAB1'], + }, + cat: ['IAB2'], + pagecat: ['IAB3'], + sectioncat: ['IAB4'], + }, + user: { + yob: 1985, + gender: 'F', + geo: { + city: 'Sydney', + region: 'NSW', + country: 'AUS', + }, + }, + }, + }; + + const [request] = createRequest(bidRequests, bidderRequest, 'banner'); + + expect(request.data.device.language).to.equal('en'); + expect(request.data.device.geo.country).to.equal('USA'); + expect(request.data.site.keywords).to.equal('sports,news'); + expect(request.data.site.publisher.id).to.equal('ortb-pub'); + expect(request.data.site.publisher.cat).to.deep.equal(['IAB1']); + expect(request.data.site.cat).to.deep.equal(['IAB2']); + expect(request.data.site.pagecat).to.deep.equal(['IAB3']); + expect(request.data.site.sectioncat).to.deep.equal(['IAB4']); + expect(request.data.user.yob).to.equal(1985); + expect(request.data.user.gender).to.equal('F'); + expect(request.data.user.geo.city).to.equal('Sydney'); + expect(request.data.user.geo.region).to.equal('NSW'); + expect(request.data.user.geo.country).to.equal('AUS'); + }); + + it('applies legacy params as explicit overrides for OpenRTB first-party data', function() { + const bidRequests = [{ + params: { + placementId: '123', + publisherId: 'param-pub', + publishercat: 'IAB5', + sitecat: 'IAB6', + pagecat: 'IAB7', + sectioncat: 'IAB8', + country: 'NZL', + language: 'mi', + userId: 'param-user', + yob: 1991, + gender: 'O', + city: 'Auckland', + region: 'AUK', + }, + userId: {}, + }]; + const bidderRequest = { + ortb2: { + device: { + language: 'en', + geo: { country: 'USA' }, + }, + site: { + publisher: { + id: 'ortb-pub', + cat: ['IAB1'], + }, + cat: ['IAB2'], + pagecat: ['IAB3'], + sectioncat: ['IAB4'], + }, + user: { + id: 'ortb-user', + yob: 1985, + gender: 'F', + geo: { + city: 'Sydney', + region: 'NSW', + country: 'AUS', + }, + }, + }, + }; + + const [request] = createRequest(bidRequests, bidderRequest, 'banner'); + + expect(request.data.device.language).to.equal('mi'); + expect(request.data.device.geo.country).to.equal('NZL'); + expect(request.data.site.publisher.id).to.equal('param-pub'); + expect(request.data.site.publisher.cat).to.deep.equal(['IAB5']); + expect(request.data.site.cat).to.deep.equal(['IAB6']); + expect(request.data.site.pagecat).to.deep.equal(['IAB7']); + expect(request.data.site.sectioncat).to.deep.equal(['IAB8']); + expect(request.data.user.id).to.equal('param-user'); + expect(request.data.user.yob).to.equal(1991); + expect(request.data.user.gender).to.equal('O'); + expect(request.data.user.geo.city).to.equal('Auckland'); + expect(request.data.user.geo.region).to.equal('AUK'); + expect(request.data.user.geo.country).to.equal('NZL'); + }); + + it('preserves existing eids when adding the EightPod visitor id', function() { + const bidRequests = [{ + params: { + placementId: '123', + eightPodVisitorId: 'visitor-1', + }, + userId: {}, + ortb2: { + user: { + ext: { + eids: [{ + source: 'sharedid.org', + uids: [{ id: 'shared-1', atype: 1 }], + }], + }, + }, + }, + }]; + + const [request] = createRequest(bidRequests, {}, 'banner'); + + expect(request.data.user.ext.eids).to.deep.include({ + source: 'sharedid.org', + uids: [{ id: 'shared-1', atype: 1 }], + }); + expect(request.data.user.ext.eids).to.deep.include({ + source: '8podx.com', + uids: [{ id: 'visitor-1', atype: 1 }], + }); + }); + + it('does not map User ID module values to user.id', function() { + const bidRequests = [{ + params: { placementId: '123' }, + userId: { + unifiedId: { id: 'unified-1' }, + id5id: { uid: 'id5-1' }, + idl_env: 'idl-1', + }, + userIdAsEids: [{ + source: 'adserver.org', + uids: [{ id: 'unified-1', atype: 1 }], + }], + }]; + + const [request] = createRequest(bidRequests, {}, 'banner'); + + expect(request.data.user).not.to.have.property('id'); + expect(request.data.user.ext.eids).to.deep.include({ + source: 'adserver.org', + uids: [{ id: 'unified-1', atype: 1 }], + }); + }); + + it('preserves UID2 eids emitted by User ID modules', function() { + const bidRequests = [{ + params: { + placementId: '123', + eightPodVisitorId: 'visitor-1', + }, + userId: {}, + userIdAsEids: [{ + source: 'uidapi.com', + uids: [{ + id: 'uid2-token-1', + atype: 3, + ext: { rtiPartner: 'UID2' }, + }], + }], + }]; + + const [request] = createRequest(bidRequests, {}, 'banner'); + + expect(request.data.user.ext.eids).to.deep.include({ + source: 'uidapi.com', + uids: [{ + id: 'uid2-token-1', + atype: 3, + ext: { rtiPartner: 'UID2' }, + }], + }); + expect(request.data.user.ext.eids).to.deep.include({ + source: '8podx.com', + uids: [{ id: 'visitor-1', atype: 1 }], + }); + }); + + it('does not fabricate device type or country defaults', function() { + const bidRequests = [{ + userId: {}, + }]; + + const [request] = createRequest(bidRequests, {}, 'banner'); + + expect(request.data.device).not.to.have.property('devicetype'); + expect(request.data.device).not.to.have.nested.property('geo.country'); + expect(request.data.ext).not.to.have.property('adSlotPlacementId'); + expect(request.data.imp[0]).not.to.have.property('tagid'); + expect(request.data.user).not.to.have.property('yob'); + expect(request.data.user).not.to.have.property('gender'); + expect(request.data.user).not.to.have.nested.property('geo.city'); + }); + + it('should pass through GDPR, USP, GPP, and COPPA consent without overwriting ortb2 values', function() { + config.setConfig({ coppa: true }); + + const bidRequests = [{ + params: { + placementId: '123', + publisherId: 'pub-1', + }, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bidId: 'test-bid-id-1', + userId: {}, + ortb2: { + user: { + ext: { + consent: 'pre-existing-consent', + }, + }, + regs: { + ext: { + gdpr: 1, + us_privacy: '1YNN', + }, + gpp: 'DBAA', + gpp_sid: [7], + }, + }, + }]; + const bidderRequest = { + gdprConsent: { + gdprApplies: true, + consentString: 'CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + }, + uspConsent: '1YNN', + gppConsent: { + gppString: 'DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + applicableSections: [7, 8], + }, + ortb2: { + user: { + ext: { + consent: 'pre-existing-consent', + }, + }, + }, + }; + + const [request] = createRequest(bidRequests, bidderRequest, 'banner'); + + expect(request.data.user.ext.consent).to.equal('CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + expect(request.data.regs.ext.gdpr).to.equal(1); + expect(request.data.regs.ext.us_privacy).to.equal('1YNN'); + expect(request.data.regs.gpp).to.equal('DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + expect(request.data.regs.gpp_sid).to.deep.equal([7, 8]); + expect(request.data.regs.ext.gpp).to.equal('DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + expect(request.data.regs.ext.gpp_sid).to.deep.equal([7, 8]); + expect(request.data.regs.coppa).to.equal(1); + expect(request.data.ext.adSlotPlacementId).to.equal('123'); + expect(request.data.imp[0].tagid).to.equal('123'); + }); + + it('should preserve ortb2 consent when only bidderRequest.ortb2 is provided', function() { + const bidRequests = [{ + params: { placementId: '123' }, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bidId: 'test-bid-id-1', + userId: {}, + }]; + const bidderRequest = { + ortb2: { + user: { + ext: { + consent: 'ortb2-only-consent', + }, + }, + regs: { + ext: { + gdpr: 1, + us_privacy: '1YYN', + }, + gpp: 'DBAA', + gpp_sid: [6], + coppa: 1, + }, + }, + }; + + const [request] = createRequest(bidRequests, bidderRequest, 'banner'); + + expect(request.data.user.ext.consent).to.equal('ortb2-only-consent'); + expect(request.data.regs.ext.gdpr).to.equal(1); + expect(request.data.regs.ext.us_privacy).to.equal('1YYN'); + expect(request.data.regs.gpp).to.equal('DBAA'); + expect(request.data.regs.gpp_sid).to.deep.equal([6]); + expect(request.data.regs.coppa).to.equal(1); + }); + }); + + describe('applyPrivacyConsent function', function() { + afterEach(function() { + config.resetConfig(); + }); + + it('should set GDPR, USP, GPP, and COPPA fields on the request payload', function() { + config.setConfig({ coppa: true }); + + const data = {}; + applyPrivacyConsent(data, { + gdprConsent: { + gdprApplies: false, + consentString: 'consent-string', + }, + uspConsent: '1---', + gppConsent: { + gppString: 'GPP_STRING', + applicableSections: [7], + }, + }); + + expect(data.user.ext.consent).to.equal('consent-string'); + expect(data.regs.ext.gdpr).to.equal(0); + expect(data.regs.ext.us_privacy).to.equal('1---'); + expect(data.regs.gpp).to.equal('GPP_STRING'); + expect(data.regs.gpp_sid).to.deep.equal([7]); + expect(data.regs.coppa).to.equal(1); + }); + }); +}); From 50e18584adf05f3715b9bc28451118ab9fc863af Mon Sep 17 00:00:00 2001 From: Luca Corbo Date: Mon, 27 Jul 2026 16:56:21 +0200 Subject: [PATCH 11/21] WURFL RTD: report configurable caps in beacon via wurfl_caps (#15293) * WURFL RTD: report configurable caps in beacon via wurfl_caps Report a configurable set of WURFL capabilities in the analytics beacon under a new wurfl_caps object, instead of only the hard-coded wurfl_id * WURFL RTD: keep wurfl_id in beacon when caps omit it Inject wurfl_id into wurfl_caps when beacon.cap_indices does not include it, so the beacon always carries a WURFL identifier regardless of backend configuration. Add a spec for a beacon cap set without wurfl_id. * WURFL RTD: cover defensive and LCE branches in tests Add unit tests for previously untested branches: storage/async-load fallbacks, FPD enrichment edge cases, empty numeric coercion, missing caps array, additional LCE user-agent parsing (iOS 26, iPadOS 26, versionless Android, Android tablets, ChromeOS, Tizen, Roku, PlayStation), sampling, consent-class edges, the statsHost override, invalid URL handling, and the fetch fallback. * WURFL RTD: reset per-auction beacon state to prevent stale wurfl_caps --- modules/wurflRtdProvider.js | 42 +++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/modules/wurflRtdProvider.js b/modules/wurflRtdProvider.js index a45e72db1..08cefdf35 100644 --- a/modules/wurflRtdProvider.js +++ b/modules/wurflRtdProvider.js @@ -18,7 +18,7 @@ export const dep = { // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = 'wurfl'; -const MODULE_VERSION = '2.9.0'; +const MODULE_VERSION = '2.10.0'; // WURFL_JS_HOST is the host for the WURFL service endpoints const WURFL_JS_HOST = 'https://prebid.wurflcloud.com'; @@ -98,9 +98,15 @@ let bidderEnrichment; // enrichmentType tracks the overall enrichment type used in the current auction let enrichmentType; -// wurflId stores the WURFL ID from device data +// wurflId stores the WURFL ID from device data. Reported top-level in the beacon (legacy format) +// only when wurfl_caps is absent, for backward compatibility. let wurflId; +// wurflCaps stores the capabilities to report in the analytics beacon (read from window.WURFL +// via the cache). The set is declared in wurfl_pbjs.beacon.cap_indices and carries wurfl_id (new +// format); null when no WURFL data is available (e.g. LCE path). +let wurflCaps; + // samplingRate tracks the beacon sampling rate (0-100) let samplingRate; @@ -633,6 +639,14 @@ const WurflJSDevice = { return this._filterCaps(bidderCaps); }, + // Private method - gets the capabilities to report in the analytics beacon. + // The set is declared in wurfl_pbjs.beacon.cap_indices and is independent of + // quota/enrichment; values are read from window.WURFL like any other cap. + _getBeaconCaps() { + const beaconCaps = this._pbjsData.beacon?.cap_indices || []; + return this._filterCaps(beaconCaps); + }, + // Private method - checks if bidder is authorized _isAuthorized(bidderCode) { return !!(this._pbjsData.bidders && bidderCode in this._pbjsData.bidders); @@ -1123,6 +1137,7 @@ const init = (config, userConsent) => { bidderEnrichment = new Map(); enrichmentType = ENRICHMENT_TYPE.UNKNOWN; wurflId = ''; + wurflCaps = null; samplingRate = DEFAULT_SAMPLING_RATE; tier = ''; overQuota = DEFAULT_OVER_QUOTA; @@ -1148,6 +1163,12 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // Start module execution timing WurflDebugger.moduleExecutionStart(); + // Reset per-auction beacon state so a later cache-less (LCE) auction on the same page + // cannot report the WURFL id/caps captured by an earlier cached auction. Each branch below + // repopulates these only when it actually has WURFL data, matching what bidders received. + wurflId = ''; + wurflCaps = null; + // Extract bidders from request configuration and set default enrichment const bidders = new Set(); reqBidsConfigObj.adUnits.forEach(adUnit => { @@ -1182,6 +1203,7 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { // Read cache metadata for beacon reporting (without enriching bid request) if (cachedWurflData) { wurflId = cachedWurflData.WURFL?.wurfl_id || ''; + wurflCaps = WurflJSDevice.fromCache(cachedWurflData)._getBeaconCaps(); samplingRate = cachedWurflData.wurfl_pbjs?.sampling_rate ?? DEFAULT_SAMPLING_RATE; tier = cachedWurflData.wurfl_pbjs?.tier ?? ''; overQuota = cachedWurflData.wurfl_pbjs?.over_quota ?? DEFAULT_OVER_QUOTA; @@ -1209,8 +1231,9 @@ const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { } enrichDeviceBidder(reqBidsConfigObj, bidders, wjsDevice); - // Store WURFL ID for analytics + // Store WURFL ID and beacon capabilities for analytics wurflId = cachedWurflData.WURFL?.wurfl_id || ''; + wurflCaps = wjsDevice._getBeaconCaps(); // Store sampling rate for beacon samplingRate = cachedWurflData.wurfl_pbjs?.sampling_rate ?? DEFAULT_SAMPLING_RATE; @@ -1398,7 +1421,6 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { path: typeof window !== 'undefined' ? window.location.pathname : '', sampling_rate: samplingRate, enrichment: enrichmentType, - wurfl_id: wurflId, tier: tier, over_quota: overQuota, consent_class: consentClass, @@ -1406,6 +1428,18 @@ function onAuctionEndEvent(auctionDetails, config, userConsent) { sua: resolvedSUA }; + // Report the configured WURFL capabilities in the new wurfl_caps object (it carries wurfl_id); + // otherwise fall back to the legacy top-level wurfl_id so the field is always present. + if (wurflCaps && Object.keys(wurflCaps).length) { + // Guarantee the WURFL identifier is present even if beacon.cap_indices omits it. + if (!('wurfl_id' in wurflCaps) && wurflId) { + wurflCaps.wurfl_id = wurflId; + } + payloadData.wurfl_caps = wurflCaps; + } else { + payloadData.wurfl_id = wurflId; + } + // Add A/B test fields if enabled const abPayload = ABTestManager.getBeaconPayload(); if (abPayload) { From fa6edc9b3f1dcc15d93e89afb8c3e1d9f96e46d9 Mon Sep 17 00:00:00 2001 From: olafbuitelaar Date: Mon, 27 Jul 2026 23:04:11 +0200 Subject: [PATCH 12/21] realTimeData module: Allow post-install (#15116) * Allow post-install for realTimeData module could we do the same (https://github.com/prebid/Prebid.js/issues/11214) for rtdModules? * RTD Module: initialize providers registered after configuration `postInstallAllowed` lets provider bundles register through `submodule('realTimeData', ...)` after Prebid has booted, but `attachRealTimeDataProvider` only added them to the registry - it never rebuilt the active submodule list. A provider registering after `setConfig({realTimeData})` was therefore never initialized and never ran, which is the case the flag is meant to support. Registration now runs `initSubModules`. `init` results are memoized per provider, so a late registration does not re-initialize the ones already running - providers that do not expect a second `init` are unaffected. Registering a name that is already taken is ignored. Providers enabled after configuration announce themselves individually; the startup message still lists those available at configuration time. Co-Authored-By: Claude Opus 5 (1M context) * RTD Module: make provider de-registration a standalone export `attachRealTimeDataProvider` returned a closure that unregisters the provider. Nothing in production ever calls it - only tests do - but as an escaping value no bundler could prove it unreachable, so the code shipped in every build. Replace it with an exported `detachRealTimeDataProvider(submodule)`. Being a named export that no other module references, it is dropped by tree shaking: `splice`, `indexOf` and `Map.delete` disappear from the minified rtdModule chunk, which goes from 3749 to 3656 bytes. Note this changes the contract of `attachRealTimeDataProvider`, which no longer returns anything. The returned closure was only ever reachable for providers registered after startup, since `submodule()` returns the installer's value only on the post-install path. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Demetrio Girardi Co-authored-by: Claude Opus 5 (1M context) --- modules/rtdModule/index.ts | 75 ++++-- test/spec/modules/realTimeDataModule_spec.js | 241 +++++++++++++++++-- 2 files changed, 284 insertions(+), 32 deletions(-) diff --git a/modules/rtdModule/index.ts b/modules/rtdModule/index.ts index 1e7e4d99b..1773d0ae6 100644 --- a/modules/rtdModule/index.ts +++ b/modules/rtdModule/index.ts @@ -20,6 +20,12 @@ const activityParams = activityParamsBuilder((al) => adapterManager.resolveAlias /** @type {string} */ const MODULE_NAME = 'realTimeData'; const registeredSubModules = []; +/** + * `init()` result, by registered submodule. Providers are initialized at most once - some of them do + * not expect to be initialized again - so this keeps track of the ones that were already given a + * chance to run, and of whether they accepted it. + */ +const initializedSubModules = new Map(); export let subModules = []; let _moduleConfig: RealTimeDataConfig; let _dataProviders = []; @@ -31,18 +37,35 @@ let _userConsent; * @param {Object} submodule The RTD submodule to register. * @param {string} submodule.name The name of the RTD submodule. * @param {number} [submodule.gvlid] The Global Vendor List ID (GVLID) of the RTD submodule. - * @returns {function(): void} A de-registration function that will unregister the module when called. */ export function attachRealTimeDataProvider(submodule) { + if (registeredSubModules.some((sm) => sm.name === submodule.name)) { + logWarn(`RTD provider '${submodule.name}' is already registered, ignoring duplicate registration`); + return; + } registeredSubModules.push(submodule); GDPR_GVLIDS.register(MODULE_TYPE_RTD, submodule.name, submodule.gvlid); - return function detach() { - const idx = registeredSubModules.indexOf(submodule); - if (idx >= 0) { - registeredSubModules.splice(idx, 1); - initSubModules(); - } - }; + // providers may be loaded after the module was configured (and therefore already initialized); + // pick them up now, since `realTimeData` configuration is accepted only once. + initSubModules(); +} + +/** + * Unregister a Real-Time Data (RTD) submodule, disabling it for subsequent auctions. + * + * FOR TESTS ONLY. Nothing in production unregisters a provider; this exists so that test suites can + * undo their registrations. Keeping it a standalone export - rather than something handed out by + * `attachRealTimeDataProvider` - means builds that never reference it can leave it out. + * + * @param {Object} submodule the submodule to unregister, as passed to `attachRealTimeDataProvider`. + */ +export function detachRealTimeDataProvider(submodule) { + const idx = registeredSubModules.indexOf(submodule); + if (idx >= 0) { + registeredSubModules.splice(idx, 1); + initializedSubModules.delete(submodule); + initSubModules(); + } } /** @@ -98,10 +121,13 @@ export function init(config) { confListener(); // unsubscribe config listener _moduleConfig = realTimeData; _dataProviders = realTimeData.dataProviders; + initializedSubModules.clear(); setEventsListeners(); getHook('startAuction').before(setBidRequestsData, 20); // RTD should run before FPD adapterManager.callDataDeletionRequest.before(onDataDeletionRequest); - initSubModules(); + // the providers available at this point are logged as a list below, rather than one by one + initSubModules(false); + logInfo(`Real time data module enabled, using submodules: ${subModules.map((m) => m.name).join(', ')}`); }); } @@ -117,19 +143,38 @@ function getConsentData() { /** * call each sub module init function by config order * if no init function / init return failure / module not configured - remove it from submodules list + * + * this runs every time a provider is registered or unregistered; providers that were already + * initialized keep their previous `init` result instead of running again. + * + * @param logNewlyEnabled log a message for each provider that is enabled by this pass. Providers can + * be registered at any time and independently from one another, so each one is reported as it + * becomes available. */ -function initSubModules() { +function initSubModules(logNewlyEnabled = true) { + if (!_dataProviders.length) { + subModules = []; + return; + } _userConsent = getConsentData(); const subModulesByOrder = []; _dataProviders.forEach(provider => { const sm = ((registeredSubModules) || []).find(s => s.name === provider.name); - const initResponse = sm && sm.init && sm.init(provider, _userConsent); - if (initResponse) { + if (!sm) { + return; + } + if (!initializedSubModules.has(sm)) { + const enabled = !!(sm.init && sm.init(provider, _userConsent)); + initializedSubModules.set(sm, enabled); + if (enabled && logNewlyEnabled) { + logInfo(`Real time data module: enabling submodule ${sm.name}`); + } + } + if (initializedSubModules.get(sm)) { subModulesByOrder.push(Object.assign(sm, { config: provider })); } }); subModules = subModulesByOrder; - logInfo(`Real time data module enabled, using submodules: ${subModules.map((m) => m.name).join(', ')}`); } /** @@ -268,5 +313,7 @@ export function onDataDeletionRequest(next, ...args) { next.apply(this, args); } -module('realTimeData', attachRealTimeDataProvider); +// `postInstallAllowed` lets provider bundles register through `submodule('realTimeData', ...)` after +// Prebid has booted; `attachRealTimeDataProvider` initializes them on the spot when that happens. +module('realTimeData', attachRealTimeDataProvider, { postInstallAllowed: true }); init(config); diff --git a/test/spec/modules/realTimeDataModule_spec.js b/test/spec/modules/realTimeDataModule_spec.js index 5028593d2..e035c1d11 100644 --- a/test/spec/modules/realTimeDataModule_spec.js +++ b/test/spec/modules/realTimeDataModule_spec.js @@ -4,7 +4,9 @@ import * as sinon from 'sinon'; import { EVENTS } from '../../../src/constants.js'; import * as events from '../../../src/events.js'; import 'src/prebid.js'; -import { attachRealTimeDataProvider, onDataDeletionRequest } from 'modules/rtdModule/index.js'; +import { attachRealTimeDataProvider, detachRealTimeDataProvider, onDataDeletionRequest } from 'modules/rtdModule/index.js'; +import { submodule } from 'src/hook.js'; +import * as utils from 'src/utils.js'; import { GDPR_GVLIDS } from '../../../src/consentHandler.js'; import { MODULE_TYPE_RTD } from '../../../src/activities/modules.js'; import { registerActivityControl } from '../../../src/activities/rules.js'; @@ -102,24 +104,22 @@ describe('Real time module', function () { }); it('are registered when RTD module is registered', () => { - let mod; + const mod = { name: 'mockRtd', gvlid: 123 }; try { - mod = attachRealTimeDataProvider({ name: 'mockRtd', gvlid: 123 }); + attachRealTimeDataProvider(mod); sinon.assert.calledWith(GDPR_GVLIDS.register, MODULE_TYPE_RTD, 'mockRtd', 123); } finally { - if (mod) { - mod(); - } + detachRealTimeDataProvider(mod); } }); }); describe('', () => { - let PROVIDERS, _detachers, rules; + let PROVIDERS, rules; beforeEach(function () { PROVIDERS = [validSM, invalidSM, failureSM, nonConfSM, validSMWait]; - _detachers = PROVIDERS.map(rtdModule.attachRealTimeDataProvider); + PROVIDERS.forEach((provider) => rtdModule.attachRealTimeDataProvider(provider)); rtdModule.init(config); config.setConfig(conf); rules = [ @@ -133,7 +133,7 @@ describe('Real time module', function () { }); afterEach(function () { - _detachers.forEach((f) => f()); + PROVIDERS.forEach((provider) => rtdModule.detachRealTimeDataProvider(provider)); config.resetConfig(); rules.forEach(rule => rule()); }); @@ -321,7 +321,6 @@ describe('Real time module', function () { } }; let providers; - let _detachers; function eventHandlingProvider(name) { const provider = { @@ -334,13 +333,13 @@ describe('Real time module', function () { beforeEach(() => { providers = [eventHandlingProvider('tp1'), eventHandlingProvider('tp2')]; - _detachers = providers.map(rtdModule.attachRealTimeDataProvider); + providers.forEach((provider) => rtdModule.attachRealTimeDataProvider(provider)); rtdModule.init(config); config.setConfig(conf); }); afterEach(() => { - _detachers.forEach((d) => d()); + providers.forEach((provider) => rtdModule.detachRealTimeDataProvider(provider)); config.resetConfig(); }); @@ -385,12 +384,10 @@ describe('Real time module', function () { init: () => true, onDataDeletionRequest: sinon.stub() }; - detach = ((orig) => { - const smDetach = attachRealTimeDataProvider(mod); - return function () { - orig(); - smDetach(); - }; + attachRealTimeDataProvider(mod); + detach = ((orig) => function () { + orig(); + detachRealTimeDataProvider(mod); })(detach); return mod; } @@ -439,4 +436,212 @@ describe('Real time module', function () { }); }); }); + + describe('provider registration', () => { + let attached; + + function mockProvider(name, initResponse = true) { + return { + name, + init: sinon.stub().returns(initResponse), + getBidRequestData: sinon.stub().callsFake((req, done) => done()) + }; + } + + function attach(provider) { + rtdModule.attachRealTimeDataProvider(provider); + attached.push(provider); + return provider; + } + + function configure(...providerConfigs) { + rtdModule.init(config); + config.setConfig({ + realTimeData: { + dataProviders: providerConfigs.map((cfg) => typeof cfg === 'string' ? { name: cfg } : cfg) + } + }); + } + + beforeEach(() => { + attached = []; + }); + + afterEach(() => { + attached.forEach((provider) => rtdModule.detachRealTimeDataProvider(provider)); + config.resetConfig(); + // `dataProviders` is kept in module scope and is not cleared by resetConfig; blank it out + // so that it does not leak into unrelated tests + configure(); + config.resetConfig(); + }); + + describe('activates a provider that registers', () => { + it('before configuration', () => { + const provider = attach(mockProvider('beforeConfig')); + configure('beforeConfig'); + sinon.assert.called(provider.init); + expect(rtdModule.subModules).to.include(provider); + }); + + it('after configuration', () => { + configure('afterConfig'); + const provider = attach(mockProvider('afterConfig')); + sinon.assert.called(provider.init); + expect(rtdModule.subModules).to.include(provider); + }); + }); + + it('passes the provider its configuration when it registers after configuration', () => { + const cfg = { name: 'lateWithConfig', params: { key: 'value' } }; + configure(cfg); + const provider = attach(mockProvider('lateWithConfig')); + sinon.assert.calledWith(provider.init, cfg); + expect(provider.config).to.eql(cfg); + }); + + it('runs a provider that registered after configuration on the next auction', (done) => { + configure('lateForAuction'); + const provider = attach(mockProvider('lateForAuction')); + rtdModule.setBidRequestsData(() => { + sinon.assert.called(provider.getBidRequestData); + done(); + }, { bidRequest: {} }); + }); + + it('keeps providers in configuration order when one registers late', () => { + const first = attach(mockProvider('first')); + configure('first', 'second'); + const second = attach(mockProvider('second')); + expect(rtdModule.subModules).to.eql([first, second]); + }); + + it('does not re-initialize providers when another one registers late', () => { + const early = attach(mockProvider('early')); + configure('early', 'late'); + sinon.assert.calledOnce(early.init); + attach(mockProvider('late')); + sinon.assert.calledOnce(early.init); + }); + + it('does not activate or retry a provider whose init fails', () => { + configure('failing', 'other'); + const failing = attach(mockProvider('failing', false)); + expect(rtdModule.subModules).to.not.include(failing); + sinon.assert.calledOnce(failing.init); + attach(mockProvider('other')); + sinon.assert.calledOnce(failing.init); + }); + + it('does not activate a provider that is registered but not configured', () => { + configure('configured'); + const unconfigured = attach(mockProvider('unconfigured')); + sinon.assert.notCalled(unconfigured.init); + expect(rtdModule.subModules).to.not.include(unconfigured); + }); + + it('ignores a second registration under the same name', () => { + configure('dupe'); + const first = attach(mockProvider('dupe')); + const second = attach(mockProvider('dupe')); + sinon.assert.notCalled(second.init); + expect(rtdModule.subModules).to.eql([first]); + }); + + it('re-initializes a provider that is registered again after detaching', () => { + configure('reattach'); + const provider = mockProvider('reattach'); + rtdModule.attachRealTimeDataProvider(provider); + rtdModule.detachRealTimeDataProvider(provider); + expect(rtdModule.subModules).to.not.include(provider); + attach(provider); + sinon.assert.calledTwice(provider.init); + expect(rtdModule.subModules).to.include(provider); + }); + + describe('logging', () => { + let logInfoStub; + + beforeEach(() => { + logInfoStub = sinon.stub(utils, 'logInfo'); + }); + + afterEach(() => { + logInfoStub.restore(); + }); + + function messages(prefix) { + return logInfoStub.args + .map(([msg]) => msg) + .filter((msg) => typeof msg === 'string' && msg.startsWith(prefix)); + } + + const announcements = () => messages('Real time data module enabled'); + const enablements = () => messages('Real time data module: enabling submodule'); + + describe('announces the enabled submodules once', () => { + it('listing the providers that registered before configuration', () => { + attach(mockProvider('one')); + attach(mockProvider('two')); + configure('one', 'two'); + expect(announcements()).to.have.length(1); + expect(announcements()[0]).to.contain('one').and.to.contain('two'); + }); + + it('without repeating it for each provider that registers after configuration', () => { + configure('one', 'two'); + attach(mockProvider('one')); + attach(mockProvider('two')); + expect(announcements()).to.have.length(1); + expect(announcements()[0]).to.not.contain('one'); + expect(announcements()[0]).to.not.contain('two'); + }); + }); + + describe('announces each provider enabled after configuration', () => { + it('as it is enabled, rather than in configuration order', () => { + configure('one', 'two'); + attach(mockProvider('two')); + attach(mockProvider('one')); + expect(enablements()).to.eql([ + 'Real time data module: enabling submodule two', + 'Real time data module: enabling submodule one' + ]); + }); + + it('only once per provider', () => { + configure('one', 'two'); + attach(mockProvider('one')); + attach(mockProvider('two')); + expect(enablements().filter((msg) => msg.endsWith('one'))).to.have.length(1); + }); + + it('but not for providers already enabled at configuration time', () => { + attach(mockProvider('one')); + configure('one'); + expect(enablements()).to.be.empty; + }); + + it('but not for a provider whose init fails', () => { + configure('failing'); + attach(mockProvider('failing', false)); + expect(enablements()).to.be.empty; + }); + + it('but not for a provider that is not configured', () => { + configure('configured'); + attach(mockProvider('unconfigured')); + expect(enablements()).to.be.empty; + }); + }); + }); + + it('installs a provider submitted through `submodule` after hooks are ready', () => { + configure('viaSubmodule'); + const provider = mockProvider('viaSubmodule'); + submodule('realTimeData', provider); + attached.push(provider); + expect(rtdModule.subModules).to.include(provider); + }); + }); }); From 9a1de95ac55bd9556c3913fff9404e0a067877cb Mon Sep 17 00:00:00 2001 From: Anna Yablonsky Date: Tue, 28 Jul 2026 15:12:12 +0300 Subject: [PATCH 13/21] Copper6 adapter: change utility suite (#14991) * override Copper6 adapter * fix * name fix * fix name of types * some additional explanation in types * adding some clarity to the md file about bids ext object * adding map for legacy params to support publishers that use old params for copper6ssp bid adapter * lint fixes * FIX - Validate legacy params before normalizing them * FIX - Preserve legacy params in the exported type --------- Co-authored-by: Anna Yablonsky --- libraries/vidazooUtils/bidderUtils.js | 4 +++- libraries/vidazooUtils/vidazooTypes.ts | 6 +++-- modules/copper6sspBidAdapter.d.ts | 33 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 modules/copper6sspBidAdapter.d.ts diff --git a/libraries/vidazooUtils/bidderUtils.js b/libraries/vidazooUtils/bidderUtils.js index f9e14c424..f4a39637f 100644 --- a/libraries/vidazooUtils/bidderUtils.js +++ b/libraries/vidazooUtils/bidderUtils.js @@ -338,7 +338,6 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder url: encodeURIComponent(topWindowUrl), uqs: getTopWindowQueryParams(), cb: Date.now(), - bidFloor: bidFloor, bidId: bidId, referrer: bidderRequest.refererInfo.ref, adUnitCode: adUnitCode, @@ -368,6 +367,9 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder ...uniqueRequestData }; + if (bidFloor) { + data.bidFloor = bidFloor; + } // backward compatible userId generators if (bid.userIdAsEids?.length > 0) { appendUserIdsAsEidsToRequestPayload(data, bid.userIdAsEids); diff --git a/libraries/vidazooUtils/vidazooTypes.ts b/libraries/vidazooUtils/vidazooTypes.ts index cd29cbb03..b8a24cd40 100644 --- a/libraries/vidazooUtils/vidazooTypes.ts +++ b/libraries/vidazooUtils/vidazooTypes.ts @@ -12,12 +12,14 @@ export interface VidazooBaseBidderParams { /** * The minimum bid value desired. Adapter will not respond with bids lower than this value */ - bidFloor: number; + bidFloor?: number; /** * Placement id on platform. */ - placementId?: number; + /** + * Custom parameters for the request + */ ext?: Ext; /** * Subdomain define subdomain in the bid request URL diff --git a/modules/copper6sspBidAdapter.d.ts b/modules/copper6sspBidAdapter.d.ts new file mode 100644 index 000000000..6d2244bb0 --- /dev/null +++ b/modules/copper6sspBidAdapter.d.ts @@ -0,0 +1,33 @@ +import { Ext } from '../libraries/vidazooUtils/vidazooTypes.ts'; + +interface Copper6SSPCommonParams { + bidFloor?: number; + ext?: Ext; + subDomain?: string; +} + +/** Current documented params */ +interface Copper6SSPModernParams extends Copper6SSPCommonParams { + cId: string; + pId: string; + placementId?: never; + endpointId?: never; +} + +/** Previously documented legacy params */ +interface Copper6SSPLegacyParams extends Copper6SSPCommonParams { + placementId: string; + endpointId: string; + cId?: never; + pId?: never; +} + +export type Copper6SSPBidRequestParams = + | Copper6SSPModernParams + | Copper6SSPLegacyParams; + +declare module '../src/adUnits' { + interface BidderParams { + copper6ssp: Copper6SSPBidRequestParams; + } +} From eaf1dfb5ff90f5a4889fdb700498a4c641a343cc Mon Sep 17 00:00:00 2001 From: adzida-adquery Date: Tue, 28 Jul 2026 14:32:02 +0200 Subject: [PATCH 14/21] adQuery ID System: resolve qid synchronously without backend round-trip (#15326) * resolve qid synchronously without backend round-trip * add test * fall back to generateUUID when crypto is unavailable * Discard stored qid longer than 36 characters and regenerate * Remove docs for params.url/params.urlArg, no longer used --------- Co-authored-by: Adrian Dzida --- modules/adqueryIdSystem.js | 89 +++++------------------ modules/adqueryIdSystem.md | 5 +- test/spec/modules/adqueryIdSystem_spec.js | 84 ++++++++++++++------- 3 files changed, 80 insertions(+), 98 deletions(-) diff --git a/modules/adqueryIdSystem.js b/modules/adqueryIdSystem.js index 22e30b380..3975cc489 100644 --- a/modules/adqueryIdSystem.js +++ b/modules/adqueryIdSystem.js @@ -5,10 +5,9 @@ * @requires module:modules/userId */ -import { ajax } from '../src/ajax.js'; import { getStorageManager } from '../src/storageManager.js'; import { submodule } from '../src/hook.js'; -import { isFn, isPlainObject, isStr, logError, logInfo, logMessage } from '../src/utils.js'; +import { generateUUID, logInfo, logMessage } from '../src/utils.js'; import { MODULE_TYPE_UID } from '../src/activities/modules.js'; /** @@ -22,20 +21,6 @@ const AU_GVLID = 902; export const storage = getStorageManager({ moduleType: MODULE_TYPE_UID, moduleName: 'qid' }); -/** - * Param or default. - * @param {String} param - * @param {String} defaultVal - */ -function paramOrDefault(param, defaultVal, arg) { - if (isFn(param)) { - return param(arg); - } else if (isStr(param)) { - return param; - } - return defaultVal; -} - /** @type {Submodule} */ export const adqueryIdSubmodule = { /** @@ -60,70 +45,36 @@ export const adqueryIdSubmodule = { return { qid: value }; }, /** - * performs action to obtain id and return a value in the callback's response argument + * performs action to obtain id and return a value synchronously * @function - * @param {SubmoduleConfig} [config] * @returns {IdResponse|undefined} */ - getId(config) { + getId() { logMessage('adqueryIdSubmodule getId'); - const qid = storage.getDataFromLocalStorage('qid'); - - if (qid) { - return { - callback: function (callback) { - callback(qid); - } - }; - } + let qid = storage.getDataFromLocalStorage('qid'); - if (!isPlainObject(config.params)) { - config.params = {}; + if (qid && qid.length > 36) { + logInfo('adqueryIdSubmodule ID QID invalid length, removing:', qid.length); + storage.removeDataFromLocalStorage('qid'); + qid = null; } - const url = paramOrDefault( - config.params.url, - `https://bidder.adquery.io/prebid/qid`, - config.params.urlArg - ); - - const resp = function (callback) { - let qid = window.qid; + if (!qid) { + if (window.crypto && window.crypto.getRandomValues) { + const randomValues = Array.from(window.crypto.getRandomValues(new Uint32Array(4))); + qid = randomValues.map(val => val.toString(36)).join('').substring(0, 20); + } else { + qid = generateUUID(); + } + storage.setDataInLocalStorage('qid', qid); - if (!qid) { - const ramdomValues = Array.from(window.crypto.getRandomValues(new Uint32Array(4))); - qid = ramdomValues.map(val => val.toString(36)).join('').substring(0, 20); + logInfo('adqueryIdSubmodule ID QID GENERATED:', qid); + } - logInfo('adqueryIdSubmodule ID QID GENERTAED:', qid); - } - logInfo('adqueryIdSubmodule ID QID:', qid); + logInfo('adqueryIdSubmodule ID QID:', qid); - const callbacks = { - success: response => { - let responseObj; - if (response) { - try { - responseObj = JSON.parse(response); - } catch (error) { - logError(error); - } - } - if (responseObj.qid) { - const myQid = responseObj.qid; - storage.setDataInLocalStorage('qid', myQid); - return callback(myQid); - } - callback(); - }, - error: error => { - logError(`${MODULE_NAME}: ID fetch encountered an error`, error); - callback(); - } - }; - ajax(url + '?qid=' + qid, callbacks, undefined, { method: 'GET' }); - }; - return { callback: resp }; + return { id: qid }; }, eids: { 'qid': { diff --git a/modules/adqueryIdSystem.md b/modules/adqueryIdSystem.md index 4d3643f65..621345e3d 100644 --- a/modules/adqueryIdSystem.md +++ b/modules/adqueryIdSystem.md @@ -29,7 +29,4 @@ The below parameters apply only to the Adquery User ID Module integration. | storage.type | Required | String | This is where the results of the user ID will be stored. The recommended method is `localStorage` by specifying `html5`. | `"html5"` | | storage.name | Required | String | The name of the html5 local storage where the user ID will be stored. | `"qid"` | | storage.expires | Optional | Integer | How long (in days) the user ID information will be stored. | `365` | -| value | Optional | Object | Used only if the page has a separate mechanism for storing the Adquery ID. The value is an object containing the values to be sent to the adapters. In this scenario, no URL is called and nothing is added to local storage | `{"qid": "2abf9f001fcd81241b67"}` | -| params | Optional | Object | Used to store params for the id system | -| params.url | Optional | String | Set an alternate GET url for qid with this parameter | -| params.urlArg | Optional | Object | Optional url parameter for params.url | +| value | Optional | Object | Used only if the page has a separate mechanism for storing the Adquery ID. The value is an object containing the values to be sent to the adapters. In this scenario, nothing is added to local storage | `{"qid": "2abf9f001fcd81241b67"}` | diff --git a/test/spec/modules/adqueryIdSystem_spec.js b/test/spec/modules/adqueryIdSystem_spec.js index acf6caf5f..1b8f7e8b9 100644 --- a/test/spec/modules/adqueryIdSystem_spec.js +++ b/test/spec/modules/adqueryIdSystem_spec.js @@ -1,5 +1,4 @@ import { adqueryIdSubmodule, storage } from 'modules/adqueryIdSystem.js'; -import { server } from 'test/mocks/xhr.js'; import sinon from 'sinon'; import { attachIdSystem } from '../../../modules/userId/index.js'; import { createEidsArray } from '../../../modules/userId/eids.js'; @@ -18,41 +17,76 @@ describe('AdqueryIdSystem', function () { describe('getId', function () { let getDataFromLocalStorageStub; + let setDataInLocalStorageStub; + let removeDataFromLocalStorageStub; beforeEach(function () { getDataFromLocalStorageStub = sinon.stub(storage, 'getDataFromLocalStorage'); + setDataInLocalStorageStub = sinon.stub(storage, 'setDataInLocalStorage'); + removeDataFromLocalStorageStub = sinon.stub(storage, 'removeDataFromLocalStorage'); }); afterEach(function () { getDataFromLocalStorageStub.restore(); + setDataInLocalStorageStub.restore(); + removeDataFromLocalStorageStub.restore(); }); - it('gets a adqueryId', function () { - const config = { - params: {} - }; - const callbackSpy = sinon.spy(); - const callback = adqueryIdSubmodule.getId(config).callback; - callback(callbackSpy); - const request = server.requests[0]; - expect(request.url).to.contain(`https://bidder.adquery.io/prebid/qid`); - request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ qid: 'qid_string' })); - expect(callbackSpy.lastCall.lastArg).to.deep.equal('qid_string'); + it('returns the persisted qid synchronously when one already exists', function () { + getDataFromLocalStorageStub.withArgs('qid').returns('existing-qid'); + + const result = adqueryIdSubmodule.getId(); + + expect(result).to.deep.equal({ id: 'existing-qid' }); + expect(setDataInLocalStorageStub.called).to.be.false; }); - it('allows configurable id url', function () { - const config = { - params: { - url: 'https://bidder2.adquery.io' - } - }; - const callbackSpy = sinon.spy(); - const callback = adqueryIdSubmodule.getId(config).callback; - callback(callbackSpy); - const request = server.requests[0]; - expect(request.url).to.contains('https://bidder2.adquery.io'); - request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ qid: 'testqid' })); - expect(callbackSpy.lastCall.lastArg).to.deep.equal('testqid'); + it('generates and persists a new qid when none is stored yet', function () { + getDataFromLocalStorageStub.withArgs('qid').returns(null); + + const result = adqueryIdSubmodule.getId(); + + expect(result.id).to.be.a('string').that.is.not.empty; + expect(setDataInLocalStorageStub.calledWith('qid', result.id)).to.be.true; + }); + + it('reuses the same qid across multiple getId calls once persisted', function () { + getDataFromLocalStorageStub.withArgs('qid').returns(null); + const first = adqueryIdSubmodule.getId(); + + getDataFromLocalStorageStub.withArgs('qid').returns(first.id); + const second = adqueryIdSubmodule.getId(); + + expect(second.id).to.equal(first.id); + }); + + it('falls back to Math.random when window.crypto.getRandomValues is unavailable', function () { + getDataFromLocalStorageStub.withArgs('qid').returns(null); + + const cryptoTmp = window.crypto; + delete window.crypto; + + let result; + try { + result = adqueryIdSubmodule.getId(); + } finally { + window.crypto = cryptoTmp; + } + + expect(result.id).to.be.a('string').that.is.not.empty; + expect(setDataInLocalStorageStub.calledWith('qid', result.id)).to.be.true; + }); + + it('discards a stored qid longer than 36 characters and generates a fresh one', function () { + const oversizedQid = 'a'.repeat(37); + getDataFromLocalStorageStub.withArgs('qid').returns(oversizedQid); + + const result = adqueryIdSubmodule.getId(); + + expect(removeDataFromLocalStorageStub.calledWith('qid')).to.be.true; + expect(result.id).to.not.equal(oversizedQid); + expect(result.id.length).to.be.at.most(36); + expect(setDataInLocalStorageStub.calledWith('qid', result.id)).to.be.true; }); }); describe('eid', () => { From 53915def557903485d6c0e15959e65237ce6e284 Mon Sep 17 00:00:00 2001 From: Anna Yablonsky Date: Tue, 28 Jul 2026 15:32:47 +0300 Subject: [PATCH 15/21] Vidazoo utils: add event callbacks (#15335) * remove use of bid.userId; use right schain object; tests adjustment; * remove use of bid.userId; use right schain object; tests adjustment; fixing import * removing tests for deprecated bid.userId that is not mapped in v10 and v11 * name fix * lint fix * fixing params support in urls * fixing Propagate the new tracking URLs onto bids * fxing paste error in tests --------- Co-authored-by: Anna Yablonsky --- libraries/vidazooUtils/bidderUtils.js | 83 +++++--- .../vidazooUtils/bidderUtils_spec.js | 190 ++++++++++++------ 2 files changed, 184 insertions(+), 89 deletions(-) diff --git a/libraries/vidazooUtils/bidderUtils.js b/libraries/vidazooUtils/bidderUtils.js index f4a39637f..367e482b3 100644 --- a/libraries/vidazooUtils/bidderUtils.js +++ b/libraries/vidazooUtils/bidderUtils.js @@ -188,6 +188,54 @@ export function onBidBillable(bid) { triggerPixel(url); } +export function onBidViewable(bid) { + if (!bid.viewableUrl) { + return; + } + const viewablePayload = { + adId: bid.adId, + creativeId: bid.creativeId, + auctionId: bid.auctionId, + transactionId: bid.transactionId, + adUnitCode: bid.adUnitCode, + cpm: bid.cpm, + currency: bid.currency, + originalCpm: bid.originalCpm, + originalCurrency: bid.originalCurrency, + netRevenue: bid.netRevenue, + mediaType: bid.mediaType, + timeToRespond: bid.timeToRespond, + status: bid.status, + }; + const qs = formatQS(viewablePayload); + const url = bid.viewableUrl + (bid.viewableUrl.indexOf('?') === -1 ? '?' : '&') + qs; + triggerPixel(url); +} + +export function onAdRenderSucceeded(bid) { + if (!bid.renderSuccessUrl) { + return; + } + const renderSuccessPayload = { + adId: bid.adId, + creativeId: bid.creativeId, + auctionId: bid.auctionId, + transactionId: bid.transactionId, + adUnitCode: bid.adUnitCode, + cpm: bid.cpm, + currency: bid.currency, + originalCpm: bid.originalCpm, + originalCurrency: bid.originalCurrency, + netRevenue: bid.netRevenue, + mediaType: bid.mediaType, + timeToRespond: bid.timeToRespond, + status: bid.status, + }; + const qs = formatQS(renderSuccessPayload); + const url = bid.renderSuccessUrl + (bid.renderSuccessUrl.indexOf('?') === -1 ? '?' : '&') + qs; + triggerPixel(url); +} + /** * Create the spec function for getting user syncs * @@ -257,24 +305,6 @@ export function createUserSyncGetter(options = { }; } -export function appendUserIdsToRequestPayload(payloadRef, userIds) { - let key; - _each(userIds, (userId, idSystemProviderName) => { - key = `uid.${idSystemProviderName}`; - - switch (idSystemProviderName) { - case 'lipb': - payloadRef[key] = userId.lipbid; - break; - case 'id5id': - payloadRef[key] = userId.uid; - break; - default: - payloadRef[key] = userId; - } - }); -} - function appendUserIdsAsEidsToRequestPayload(payloadRef, userIds) { let key; userIds.forEach((userIdObj) => { @@ -292,7 +322,6 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder params, bidId, adUnitCode, - schain, mediaTypes, ortb2Imp, bidderRequestId, @@ -316,6 +345,9 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder const contentLang = bidderRequest?.ortb2?.site?.content?.language || document.documentElement.lang; const coppa = bidderRequest?.ortb2?.regs?.coppa ?? 0; const device = bidderRequest?.ortb2?.device ? deepClone(bidderRequest?.ortb2?.device) : {}; + const schain = bid?.ortb2?.source?.ext?.schain || + bidderRequest?.ortb2?.source?.ext?.schain || + bid.schain; // legacy fallback only // delete device.devicetype if invalid if (!Number.isInteger(device.devicetype)) { @@ -377,9 +409,6 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder if (bid.user?.ext?.eids?.length > 0) { appendUserIdsAsEidsToRequestPayload(data, bid.user.ext.eids); } - if (bid.userId) { - appendUserIdsToRequestPayload(data, bid.userId); - } const sua = bidderRequest?.ortb2?.device?.sua; @@ -478,7 +507,9 @@ export function createInterpretResponseFn(bidderCode, allowSingleRequest) { burl, advertiserDomains, metaData, - mediaType = BANNER + mediaType = BANNER, + viewableUrl, + renderSuccessUrl, } = result; if (!ad || !price) { return; @@ -501,6 +532,12 @@ export function createInterpretResponseFn(bidderCode, allowSingleRequest) { if (burl) { response.burl = burl; } + if (viewableUrl) { + response.viewableUrl = viewableUrl; + } + if (renderSuccessUrl) { + response.renderSuccessUrl = renderSuccessUrl; + } if (metaData) { Object.assign(response, { diff --git a/test/spec/libraries/vidazooUtils/bidderUtils_spec.js b/test/spec/libraries/vidazooUtils/bidderUtils_spec.js index 4af4b2396..8cb568515 100644 --- a/test/spec/libraries/vidazooUtils/bidderUtils_spec.js +++ b/test/spec/libraries/vidazooUtils/bidderUtils_spec.js @@ -2,13 +2,13 @@ import * as utilities from 'libraries/vidazooUtils/bidderUtils.js'; import { expect } from "chai"; import sinon from "sinon"; import * as utils from 'src/utils.js'; -import { config } from 'src/config.js'; +import { config } from '../../../../src/config.js'; import { IFRAME_SYNC_DEFAULT_URL, IMAGE_SYNC_DEFAULT_URL, SESSION_ID_KEY } from "../../../../libraries/vidazooUtils/constants.js"; -import { bidderSettings } from 'src/bidderSettings.js'; +import { bidderSettings } from '../../../../src/bidderSettings.js'; describe('Vidazoo Bidder Utils Tests', function () { describe('createSessionId', function () { @@ -570,6 +570,126 @@ describe('Vidazoo Bidder Utils Tests', function () { }); }); + describe('onBidViewable', function () { + beforeEach(function () { + sinon.stub(utils, 'triggerPixel'); + }); + afterEach(function () { + utils.triggerPixel.restore(); + }); + + it('should append ? when viewableUrl has no existing query string', function () { + const bid = { + adUnitCode: 'div-gpt-ad-12345-0', + adId: '2d52001cabd527', + auctionId: '1fdb5ff1b6eaa7', + transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + status: 'rendered', + timeToRespond: 100, + cpm: 0.8, + originalCpm: 0.8, + creativeId: '12610997325162499419', + currency: 'USD', + originalCurrency: 'USD', + height: 250, + mediaType: 'banner', + viewableUrl: 'https://test.com/onBidViewable', + netRevenue: true, + requestId: '2d52001cabd527', + ttl: 30, + width: 300 + }; + utilities.onBidViewable(bid); + expect(utils.triggerPixel.called).to.be.true; + const url = utils.triggerPixel.args[0][0]; + expect(url).to.match(/^https:\/\/test\.com\/onBidViewable\?adId=/); + }); + + it('should not call triggerPixel when viewableUrl not passed in bid', function () { + const bid = { + adUnitCode: 'div-gpt-ad-12345-0', + adId: '2d52001cabd527', + auctionId: '1fdb5ff1b6eaa7', + transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + status: 'rendered', + timeToRespond: 100, + cpm: 0.8, + originalCpm: 0.8, + creativeId: '12610997325162499419', + currency: 'USD', + originalCurrency: 'USD', + height: 250, + mediaType: 'banner', + netRevenue: true, + requestId: '2d52001cabd527', + ttl: 30, + width: 300 + }; + utilities.onBidViewable(bid); + expect(utils.triggerPixel.called).to.be.false; + }); + }); + + describe('onAdRenderSucceeded', function () { + beforeEach(function () { + sinon.stub(utils, 'triggerPixel'); + }); + afterEach(function () { + utils.triggerPixel.restore(); + }); + + it('should append ? when renderSuccessUrl has no existing query string', function () { + const bid = { + adUnitCode: 'div-gpt-ad-12345-0', + adId: '2d52001cabd527', + auctionId: '1fdb5ff1b6eaa7', + transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + status: 'rendered', + timeToRespond: 100, + cpm: 0.8, + originalCpm: 0.8, + creativeId: '12610997325162499419', + currency: 'USD', + originalCurrency: 'USD', + height: 250, + mediaType: 'banner', + renderSuccessUrl: 'https://test.com/renderSuccessUrl', + netRevenue: true, + requestId: '2d52001cabd527', + ttl: 30, + width: 300 + }; + utilities.onAdRenderSucceeded(bid); + expect(utils.triggerPixel.called).to.be.true; + const url = utils.triggerPixel.args[0][0]; + expect(url).to.match(/^https:\/\/test\.com\/renderSuccessUrl\?adId=/); + }); + + it('should not call triggerPixel when renderSuccessUrl not passed in bid', function () { + const bid = { + adUnitCode: 'div-gpt-ad-12345-0', + adId: '2d52001cabd527', + auctionId: '1fdb5ff1b6eaa7', + transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + status: 'rendered', + timeToRespond: 100, + cpm: 0.8, + originalCpm: 0.8, + creativeId: '12610997325162499419', + currency: 'USD', + originalCurrency: 'USD', + height: 250, + mediaType: 'banner', + netRevenue: true, + requestId: '2d52001cabd527', + ttl: 30, + width: 300 + }; + utilities.onAdRenderSucceeded(bid); + expect(utils.triggerPixel.called).to.be.false; + }); + }); + describe('createUserSyncGetter', function () { let sandbox; const iframeSyncUrl = 'https://sync.example.com/api/sync/iframe'; @@ -699,57 +819,6 @@ describe('Vidazoo Bidder Utils Tests', function () { }); }); - describe('appendUserIdsToRequestPayload', function () { - it('should extract lipbid from lipb provider', function () { - const payload = {}; - const userIds = { - lipb: { lipbid: 'lipb-id-123' } - }; - utilities.appendUserIdsToRequestPayload(payload, userIds); - expect(payload['uid.lipb']).to.be.equal('lipb-id-123'); - }); - - it('should extract uid from id5id provider', function () { - const payload = {}; - const userIds = { - id5id: { uid: 'id5-uid-456' } - }; - utilities.appendUserIdsToRequestPayload(payload, userIds); - expect(payload['uid.id5id']).to.be.equal('id5-uid-456'); - }); - - it('should use raw value for other providers', function () { - const payload = {}; - const userIds = { - tdid: 'tdid-value-789', - criteoId: 'criteo-value-000' - }; - utilities.appendUserIdsToRequestPayload(payload, userIds); - expect(payload['uid.tdid']).to.be.equal('tdid-value-789'); - expect(payload['uid.criteoId']).to.be.equal('criteo-value-000'); - }); - - it('should handle all provider types together', function () { - const payload = {}; - const userIds = { - lipb: { lipbid: 'lipb-id' }, - id5id: { uid: 'id5-uid' }, - tdid: 'tdid-value' - }; - utilities.appendUserIdsToRequestPayload(payload, userIds); - expect(payload['uid.lipb']).to.be.equal('lipb-id'); - expect(payload['uid.id5id']).to.be.equal('id5-uid'); - expect(payload['uid.tdid']).to.be.equal('tdid-value'); - }); - - it('should not modify payload when userIds is empty', function () { - const payload = { existing: 'value' }; - utilities.appendUserIdsToRequestPayload(payload, {}); - expect(Object.keys(payload)).to.have.lengthOf(1); - expect(payload.existing).to.be.equal('value'); - }); - }); - describe('getVidazooSessionId', function () { it('should call getStorageItem with SESSION_ID_KEY via storage mock', function () { const storageMock = { @@ -1028,18 +1097,6 @@ describe('Vidazoo Bidder Utils Tests', function () { expect(data['uid.adserver.org']).to.equal('eid-123'); }); - it('should append userId to request data', function () { - const bid = { - ...baseBid, - userId: { tdid: 'tdid-val', lipb: { lipbid: 'lipb-val' } } - }; - const data = utilities.buildRequestData( - bid, 'https://publisher.com', [[300, 250]], baseBidderRequest, 3000, storageMock, '1.0.0', 'vidazoo', null - ); - expect(data['uid.tdid']).to.equal('tdid-val'); - expect(data['uid.lipb']).to.equal('lipb-val'); - }); - it('should include ortb2 and ortb2Imp in data', function () { const data = utilities.buildRequestData( baseBid, 'https://publisher.com', [[300, 250]], baseBidderRequest, 3000, storageMock, '1.0.0', 'vidazoo', null @@ -1460,7 +1517,8 @@ describe('Vidazoo Bidder Utils Tests', function () { site: { cat: [], pagecat: [], content: { data: [], language: 'en' } }, user: { data: [] }, device: {}, - regs: { coppa: 0 } + regs: { coppa: 0 }, + source: { ext: { schain: null } } } }; From 53795e3b536a8a78df267ffeb7d4af8d61a50d6f Mon Sep 17 00:00:00 2001 From: Patrick McCann Date: Tue, 28 Jul 2026 10:58:50 -0400 Subject: [PATCH 16/21] Revert "Vidazoo utils: add event callbacks (#15335)" (#15400) This reverts commit 30cce05c3fb8f3c136357a97528ff56f3d8400f6. --- libraries/vidazooUtils/bidderUtils.js | 83 +++----- .../vidazooUtils/bidderUtils_spec.js | 190 ++++++------------ 2 files changed, 89 insertions(+), 184 deletions(-) diff --git a/libraries/vidazooUtils/bidderUtils.js b/libraries/vidazooUtils/bidderUtils.js index 367e482b3..f4a39637f 100644 --- a/libraries/vidazooUtils/bidderUtils.js +++ b/libraries/vidazooUtils/bidderUtils.js @@ -188,54 +188,6 @@ export function onBidBillable(bid) { triggerPixel(url); } -export function onBidViewable(bid) { - if (!bid.viewableUrl) { - return; - } - const viewablePayload = { - adId: bid.adId, - creativeId: bid.creativeId, - auctionId: bid.auctionId, - transactionId: bid.transactionId, - adUnitCode: bid.adUnitCode, - cpm: bid.cpm, - currency: bid.currency, - originalCpm: bid.originalCpm, - originalCurrency: bid.originalCurrency, - netRevenue: bid.netRevenue, - mediaType: bid.mediaType, - timeToRespond: bid.timeToRespond, - status: bid.status, - }; - const qs = formatQS(viewablePayload); - const url = bid.viewableUrl + (bid.viewableUrl.indexOf('?') === -1 ? '?' : '&') + qs; - triggerPixel(url); -} - -export function onAdRenderSucceeded(bid) { - if (!bid.renderSuccessUrl) { - return; - } - const renderSuccessPayload = { - adId: bid.adId, - creativeId: bid.creativeId, - auctionId: bid.auctionId, - transactionId: bid.transactionId, - adUnitCode: bid.adUnitCode, - cpm: bid.cpm, - currency: bid.currency, - originalCpm: bid.originalCpm, - originalCurrency: bid.originalCurrency, - netRevenue: bid.netRevenue, - mediaType: bid.mediaType, - timeToRespond: bid.timeToRespond, - status: bid.status, - }; - const qs = formatQS(renderSuccessPayload); - const url = bid.renderSuccessUrl + (bid.renderSuccessUrl.indexOf('?') === -1 ? '?' : '&') + qs; - triggerPixel(url); -} - /** * Create the spec function for getting user syncs * @@ -305,6 +257,24 @@ export function createUserSyncGetter(options = { }; } +export function appendUserIdsToRequestPayload(payloadRef, userIds) { + let key; + _each(userIds, (userId, idSystemProviderName) => { + key = `uid.${idSystemProviderName}`; + + switch (idSystemProviderName) { + case 'lipb': + payloadRef[key] = userId.lipbid; + break; + case 'id5id': + payloadRef[key] = userId.uid; + break; + default: + payloadRef[key] = userId; + } + }); +} + function appendUserIdsAsEidsToRequestPayload(payloadRef, userIds) { let key; userIds.forEach((userIdObj) => { @@ -322,6 +292,7 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder params, bidId, adUnitCode, + schain, mediaTypes, ortb2Imp, bidderRequestId, @@ -345,9 +316,6 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder const contentLang = bidderRequest?.ortb2?.site?.content?.language || document.documentElement.lang; const coppa = bidderRequest?.ortb2?.regs?.coppa ?? 0; const device = bidderRequest?.ortb2?.device ? deepClone(bidderRequest?.ortb2?.device) : {}; - const schain = bid?.ortb2?.source?.ext?.schain || - bidderRequest?.ortb2?.source?.ext?.schain || - bid.schain; // legacy fallback only // delete device.devicetype if invalid if (!Number.isInteger(device.devicetype)) { @@ -409,6 +377,9 @@ export function buildRequestData(bid, topWindowUrl, sizes, bidderRequest, bidder if (bid.user?.ext?.eids?.length > 0) { appendUserIdsAsEidsToRequestPayload(data, bid.user.ext.eids); } + if (bid.userId) { + appendUserIdsToRequestPayload(data, bid.userId); + } const sua = bidderRequest?.ortb2?.device?.sua; @@ -507,9 +478,7 @@ export function createInterpretResponseFn(bidderCode, allowSingleRequest) { burl, advertiserDomains, metaData, - mediaType = BANNER, - viewableUrl, - renderSuccessUrl, + mediaType = BANNER } = result; if (!ad || !price) { return; @@ -532,12 +501,6 @@ export function createInterpretResponseFn(bidderCode, allowSingleRequest) { if (burl) { response.burl = burl; } - if (viewableUrl) { - response.viewableUrl = viewableUrl; - } - if (renderSuccessUrl) { - response.renderSuccessUrl = renderSuccessUrl; - } if (metaData) { Object.assign(response, { diff --git a/test/spec/libraries/vidazooUtils/bidderUtils_spec.js b/test/spec/libraries/vidazooUtils/bidderUtils_spec.js index 8cb568515..4af4b2396 100644 --- a/test/spec/libraries/vidazooUtils/bidderUtils_spec.js +++ b/test/spec/libraries/vidazooUtils/bidderUtils_spec.js @@ -2,13 +2,13 @@ import * as utilities from 'libraries/vidazooUtils/bidderUtils.js'; import { expect } from "chai"; import sinon from "sinon"; import * as utils from 'src/utils.js'; -import { config } from '../../../../src/config.js'; +import { config } from 'src/config.js'; import { IFRAME_SYNC_DEFAULT_URL, IMAGE_SYNC_DEFAULT_URL, SESSION_ID_KEY } from "../../../../libraries/vidazooUtils/constants.js"; -import { bidderSettings } from '../../../../src/bidderSettings.js'; +import { bidderSettings } from 'src/bidderSettings.js'; describe('Vidazoo Bidder Utils Tests', function () { describe('createSessionId', function () { @@ -570,126 +570,6 @@ describe('Vidazoo Bidder Utils Tests', function () { }); }); - describe('onBidViewable', function () { - beforeEach(function () { - sinon.stub(utils, 'triggerPixel'); - }); - afterEach(function () { - utils.triggerPixel.restore(); - }); - - it('should append ? when viewableUrl has no existing query string', function () { - const bid = { - adUnitCode: 'div-gpt-ad-12345-0', - adId: '2d52001cabd527', - auctionId: '1fdb5ff1b6eaa7', - transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', - status: 'rendered', - timeToRespond: 100, - cpm: 0.8, - originalCpm: 0.8, - creativeId: '12610997325162499419', - currency: 'USD', - originalCurrency: 'USD', - height: 250, - mediaType: 'banner', - viewableUrl: 'https://test.com/onBidViewable', - netRevenue: true, - requestId: '2d52001cabd527', - ttl: 30, - width: 300 - }; - utilities.onBidViewable(bid); - expect(utils.triggerPixel.called).to.be.true; - const url = utils.triggerPixel.args[0][0]; - expect(url).to.match(/^https:\/\/test\.com\/onBidViewable\?adId=/); - }); - - it('should not call triggerPixel when viewableUrl not passed in bid', function () { - const bid = { - adUnitCode: 'div-gpt-ad-12345-0', - adId: '2d52001cabd527', - auctionId: '1fdb5ff1b6eaa7', - transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', - status: 'rendered', - timeToRespond: 100, - cpm: 0.8, - originalCpm: 0.8, - creativeId: '12610997325162499419', - currency: 'USD', - originalCurrency: 'USD', - height: 250, - mediaType: 'banner', - netRevenue: true, - requestId: '2d52001cabd527', - ttl: 30, - width: 300 - }; - utilities.onBidViewable(bid); - expect(utils.triggerPixel.called).to.be.false; - }); - }); - - describe('onAdRenderSucceeded', function () { - beforeEach(function () { - sinon.stub(utils, 'triggerPixel'); - }); - afterEach(function () { - utils.triggerPixel.restore(); - }); - - it('should append ? when renderSuccessUrl has no existing query string', function () { - const bid = { - adUnitCode: 'div-gpt-ad-12345-0', - adId: '2d52001cabd527', - auctionId: '1fdb5ff1b6eaa7', - transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', - status: 'rendered', - timeToRespond: 100, - cpm: 0.8, - originalCpm: 0.8, - creativeId: '12610997325162499419', - currency: 'USD', - originalCurrency: 'USD', - height: 250, - mediaType: 'banner', - renderSuccessUrl: 'https://test.com/renderSuccessUrl', - netRevenue: true, - requestId: '2d52001cabd527', - ttl: 30, - width: 300 - }; - utilities.onAdRenderSucceeded(bid); - expect(utils.triggerPixel.called).to.be.true; - const url = utils.triggerPixel.args[0][0]; - expect(url).to.match(/^https:\/\/test\.com\/renderSuccessUrl\?adId=/); - }); - - it('should not call triggerPixel when renderSuccessUrl not passed in bid', function () { - const bid = { - adUnitCode: 'div-gpt-ad-12345-0', - adId: '2d52001cabd527', - auctionId: '1fdb5ff1b6eaa7', - transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', - status: 'rendered', - timeToRespond: 100, - cpm: 0.8, - originalCpm: 0.8, - creativeId: '12610997325162499419', - currency: 'USD', - originalCurrency: 'USD', - height: 250, - mediaType: 'banner', - netRevenue: true, - requestId: '2d52001cabd527', - ttl: 30, - width: 300 - }; - utilities.onAdRenderSucceeded(bid); - expect(utils.triggerPixel.called).to.be.false; - }); - }); - describe('createUserSyncGetter', function () { let sandbox; const iframeSyncUrl = 'https://sync.example.com/api/sync/iframe'; @@ -819,6 +699,57 @@ describe('Vidazoo Bidder Utils Tests', function () { }); }); + describe('appendUserIdsToRequestPayload', function () { + it('should extract lipbid from lipb provider', function () { + const payload = {}; + const userIds = { + lipb: { lipbid: 'lipb-id-123' } + }; + utilities.appendUserIdsToRequestPayload(payload, userIds); + expect(payload['uid.lipb']).to.be.equal('lipb-id-123'); + }); + + it('should extract uid from id5id provider', function () { + const payload = {}; + const userIds = { + id5id: { uid: 'id5-uid-456' } + }; + utilities.appendUserIdsToRequestPayload(payload, userIds); + expect(payload['uid.id5id']).to.be.equal('id5-uid-456'); + }); + + it('should use raw value for other providers', function () { + const payload = {}; + const userIds = { + tdid: 'tdid-value-789', + criteoId: 'criteo-value-000' + }; + utilities.appendUserIdsToRequestPayload(payload, userIds); + expect(payload['uid.tdid']).to.be.equal('tdid-value-789'); + expect(payload['uid.criteoId']).to.be.equal('criteo-value-000'); + }); + + it('should handle all provider types together', function () { + const payload = {}; + const userIds = { + lipb: { lipbid: 'lipb-id' }, + id5id: { uid: 'id5-uid' }, + tdid: 'tdid-value' + }; + utilities.appendUserIdsToRequestPayload(payload, userIds); + expect(payload['uid.lipb']).to.be.equal('lipb-id'); + expect(payload['uid.id5id']).to.be.equal('id5-uid'); + expect(payload['uid.tdid']).to.be.equal('tdid-value'); + }); + + it('should not modify payload when userIds is empty', function () { + const payload = { existing: 'value' }; + utilities.appendUserIdsToRequestPayload(payload, {}); + expect(Object.keys(payload)).to.have.lengthOf(1); + expect(payload.existing).to.be.equal('value'); + }); + }); + describe('getVidazooSessionId', function () { it('should call getStorageItem with SESSION_ID_KEY via storage mock', function () { const storageMock = { @@ -1097,6 +1028,18 @@ describe('Vidazoo Bidder Utils Tests', function () { expect(data['uid.adserver.org']).to.equal('eid-123'); }); + it('should append userId to request data', function () { + const bid = { + ...baseBid, + userId: { tdid: 'tdid-val', lipb: { lipbid: 'lipb-val' } } + }; + const data = utilities.buildRequestData( + bid, 'https://publisher.com', [[300, 250]], baseBidderRequest, 3000, storageMock, '1.0.0', 'vidazoo', null + ); + expect(data['uid.tdid']).to.equal('tdid-val'); + expect(data['uid.lipb']).to.equal('lipb-val'); + }); + it('should include ortb2 and ortb2Imp in data', function () { const data = utilities.buildRequestData( baseBid, 'https://publisher.com', [[300, 250]], baseBidderRequest, 3000, storageMock, '1.0.0', 'vidazoo', null @@ -1517,8 +1460,7 @@ describe('Vidazoo Bidder Utils Tests', function () { site: { cat: [], pagecat: [], content: { data: [], language: 'en' } }, user: { data: [] }, device: {}, - regs: { coppa: 0 }, - source: { ext: { schain: null } } + regs: { coppa: 0 } } }; From 44f01a72007827d648489fdb801a78347399a327 Mon Sep 17 00:00:00 2001 From: Patrick McCann Date: Tue, 28 Jul 2026 11:36:23 -0400 Subject: [PATCH 17/21] Tests: demonstrate tracking pixel URL HTML insertion isn't dangerous (#15049) * Core: escape tracking pixel URLs in HTML attributes * Core: avoid double-escaping tracker query separators (#15325) * Core: avoid double-escaping tracker query separators (#15327) - Tracker image URLs coming from ad markup or XML can already contain HTML-entity encoded separators like `&`, and passing them through the existing encode+escape path produced `&amp;` in emitted HTML which breaks server-side parameter parsing. - Add a small helper `decodeAmpersandEntities(url)` that converts existing `&` back to `&` before encoding. - Normalize URLs by calling `encode(decodeAmpersandEntities(url))` inside `createTrackPixelHtml` so already-encoded query separators are not double-escaped. - Add a unit test in `test/spec/utils_spec.js` that asserts `createTrackPixelHtml` preserves `&` separators in emitted `src` for an input URL containing `&`. - Fix nearby missing semicolons in `test/spec/utils_spec.js` to satisfy linting for the changed file. - Ran lint on the changed files with `npx eslint src/utils.js test/spec/utils_spec.js --cache --cache-strategy content` and it passed for the final change set. - Ran the targeted unit tests with `npx gulp test --nolint --file test/spec/utils_spec.js` and the test chunk completed successfully (`185 tests completed`). * Kimberlite Bid Adapter: fix nurl pixel test expectation (#15338) * TrustX Bid Adapter: fix tracking URL test assertion (#15337) * Adkernel Bid Adapter: fix banner nurl pixel test (#15339) * Core: preserve tracking pixel URL behavior (#15390) --------- Co-authored-by: Demetrio Girardi --- test/spec/utils_spec.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/spec/utils_spec.js b/test/spec/utils_spec.js index 55561864a..a0678d9ab 100644 --- a/test/spec/utils_spec.js +++ b/test/spec/utils_spec.js @@ -842,6 +842,44 @@ describe('Utils', function () { }); }); }); + + describe('createTrackPixelHtml', () => { + it('keeps an encoded quote inside the src attribute', () => { + const url = 'https://www.example.com/?x="onerror=alert(1)//'; + const container = document.createElement('div'); + + // This browser-level assertion, added by a bot, documents that character references do not create attributes. + container.innerHTML = utils.createTrackPixelHtml(url); + const pixel = container.querySelector('img'); + expect(pixel.getAttribute('src')).to.equal('https://www.example.com/?x="onerror=alert(1)//'); + expect(pixel.hasAttribute('onerror')).to.be.false; + }); + + it('escapes encoded quotes in the url', () => { + const url = 'https://www.example.com/?x="test"'; + + expect(utils.createTrackPixelHtml(url)).to.contain('src="https://www.example.com/?x=%22test%22"'); + }); + + it('lets the browser decode character references in tracker URLs', () => { + const cases = [ + ['&', '&'], + ['&', '&'], + ['&', '&'], + ['&', '&'], + ['&', '&'], + ['&', '&'], + ['"', '"'], + ['"', '"'] + ]; + + cases.forEach(([entity, expected]) => { + const container = document.createElement('div'); + container.innerHTML = utils.createTrackPixelHtml(`https://www.example.com/?x=${entity}`); + expect(container.querySelector('img').getAttribute('src')).to.equal(`https://www.example.com/?x=${expected}`); + }); + }); + }); }); describe('insertElement', function () { From af7cd9b613d9a1b775e5875edf590ab4cf35026a Mon Sep 17 00:00:00 2001 From: "null[bot]" <2886085+null[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:33:09 +0000 Subject: [PATCH 18/21] Prebid 11.26.0 release - reautomated --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index be1088951..6007b4c0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openads.js", - "version": "11.25.0", + "version": "11.26.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "openads.js", - "version": "11.25.0", + "version": "11.26.0", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.28.4", diff --git a/package.json b/package.json index 09f286ff4..174d6ea11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openads.js", - "version": "11.25.0", + "version": "11.26.0", "oaVersion": "1.15.0", "description": "Header Bidding Management Library", "main": "dist/src/prebid.public.ts", From bece749a1d9cd7d827890400d9103285c73faa00 Mon Sep 17 00:00:00 2001 From: Khang Vu Date: Fri, 14 Aug 2026 10:56:19 -0700 Subject: [PATCH 19/21] Rename pbjs.setConfig to oajs.setConfig in new AdChoices module docs The DAA AdChoices Signal module (#15138) shipped its config example using upstream's pbjs naming, missing this fork's established rename applied to every other module doc. --- modules/adChoices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/adChoices.md b/modules/adChoices.md index d660604a3..e354c0488 100644 --- a/modules/adChoices.md +++ b/modules/adChoices.md @@ -35,7 +35,7 @@ The module works with no configuration. To supply a static signal or to opt into delaying auctions while the signal is read, use the `adChoices` config namespace: ```javascript -pbjs.setConfig({ +oajs.setConfig({ adChoices: { // Optional: a statically supplied AdChoices Signal. Takes precedence over a // value read from the browser extension. From 1ead2834643e5f67533f3fa30d1cbfdf4863a8d3 Mon Sep 17 00:00:00 2001 From: Khang Vu Date: Fri, 14 Aug 2026 11:03:40 -0700 Subject: [PATCH 20/21] Remove orphaned copper6sspBidAdapter.d.ts The Copper6 adapter itself (modules/copper6sspBidAdapter.js/.md and its spec) is intentionally dropped from this fork, matching the *BidAdapter* skip pattern. This new .d.ts file slipped through the auto-resolved cherry-pick as a clean add despite its .js sibling never existing here, tripping eslint's prebid/declaration-filename rule (a declaration file must have a corresponding .js file). --- modules/copper6sspBidAdapter.d.ts | 33 ------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 modules/copper6sspBidAdapter.d.ts diff --git a/modules/copper6sspBidAdapter.d.ts b/modules/copper6sspBidAdapter.d.ts deleted file mode 100644 index 6d2244bb0..000000000 --- a/modules/copper6sspBidAdapter.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Ext } from '../libraries/vidazooUtils/vidazooTypes.ts'; - -interface Copper6SSPCommonParams { - bidFloor?: number; - ext?: Ext; - subDomain?: string; -} - -/** Current documented params */ -interface Copper6SSPModernParams extends Copper6SSPCommonParams { - cId: string; - pId: string; - placementId?: never; - endpointId?: never; -} - -/** Previously documented legacy params */ -interface Copper6SSPLegacyParams extends Copper6SSPCommonParams { - placementId: string; - endpointId: string; - cId?: never; - pId?: never; -} - -export type Copper6SSPBidRequestParams = - | Copper6SSPModernParams - | Copper6SSPLegacyParams; - -declare module '../src/adUnits' { - interface BidderParams { - copper6ssp: Copper6SSPBidRequestParams; - } -} From 734a51f05e6103f78a34a7c9bf739480c9921927 Mon Sep 17 00:00:00 2001 From: Khang Vu Date: Fri, 14 Aug 2026 11:44:21 -0700 Subject: [PATCH 21/21] Tests: fix permutiveCombined_spec.js expiry timing fragility The identity-manager-gating tests' idPayload.providers.id5id.expiryTime was computed once when Mocha registers the describe block, not per test run -- on a slow/loaded CI runner deep into a large chunk, enough wall-clock time can pass between suite registration and this specific test executing for the frozen expiryTime to have already lapsed, causing getId() to correctly (but unintentionally) treat the stored ID as expired. Reproduced on both SafariNative and Browserstack Safari (different versions), chunk 5 of 8, consistently across retries; passed cleanly in Chrome locally where the chunk runs fast enough that this never surfaces. Moved the expiryTime computation into beforeEach so it's stamped fresh immediately before each test runs, removing the fragility rather than just widening the window. --- test/spec/modules/permutiveCombined_spec.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/spec/modules/permutiveCombined_spec.js b/test/spec/modules/permutiveCombined_spec.js index 6668ca3bc..e539aa99d 100644 --- a/test/spec/modules/permutiveCombined_spec.js +++ b/test/spec/modules/permutiveCombined_spec.js @@ -83,9 +83,11 @@ describe('permutiveRtdProvider', function () { describe('identity manager gating', function () { const idKey = 'permutive-oajs-id'; - const idPayload = { providers: { id5id: { userId: 'abc', expiryTime: Date.now() + 10000 } } }; beforeEach(function () { + // compute expiryTime fresh for each test, rather than once at suite + // registration time, so it can't lapse before this test actually runs + const idPayload = { providers: { id5id: { userId: 'abc', expiryTime: Date.now() + 10000 } } }; permutiveIdStorage.setDataInLocalStorage(idKey, JSON.stringify(idPayload)); });