diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 83c7df34..2833e63e 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -476,7 +476,7 @@ fn parse_client_auction_uid(raw: &JsonValue) -> Option { let atype = uid .get("atype") .and_then(JsonValue::as_u64) - .and_then(|atype| u8::try_from(atype).ok()); + .and_then(|atype| i32::try_from(atype).ok()); let ext = match uid.get("ext") { Some(JsonValue::Object(_)) => uid.get("ext").cloned(), @@ -1057,6 +1057,24 @@ mod tests { assert_eq!(parsed[0].uids[0].id, "valid", "should keep valid UID"); } + #[test] + fn parse_client_auction_eids_preserves_pair_atype() { + let raw = json!([ + { + "source": "google.com", + "uids": [{ "id": "pair-id", "atype": 571187 }] + } + ]); + + let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse PAIR EID"); + + assert_eq!( + parsed[0].uids[0].atype, + Some(571187), + "should preserve PAIR's vendor-specific atype" + ); + } + #[test] fn parse_client_auction_eids_preserves_uid_ext_and_sanitizes_invalid_atype() { let raw = json!([ @@ -1070,7 +1088,7 @@ mod tests { }, { "id": "uid-bad-atype", - "atype": 999, + "atype": 2_147_483_648_u64, "ext": { "keep": true } }, { diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1dd8b179..2c01bf85 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -25,7 +25,7 @@ pub struct ResolvedPartnerId { /// The synced user ID value. pub uid: String, /// `OpenRTB` agent type for this partner's identifiers. - pub openrtb_atype: u8, + pub openrtb_atype: i32, } /// Resolves source-domain keyed IDs from a KV entry against the partner registry. @@ -214,17 +214,29 @@ mod tests { source_domain: "id5-sync.com".to_owned(), openrtb_atype: 1, }, + ResolvedPartnerId { + uid: "pair-id".to_owned(), + source_domain: "google.com".to_owned(), + openrtb_atype: 571187, + }, ]; let eids = to_eids(&resolved); - assert_eq!(eids.len(), 2, "should produce one EID per resolved partner"); + assert_eq!(eids.len(), 3, "should produce one EID per resolved partner"); assert_eq!(eids[0].source, "liveramp.com"); assert_eq!(eids[0].uids[0].id, "LR_xyz"); assert_eq!(eids[0].uids[0].atype, Some(3)); assert_eq!(eids[1].source, "id5-sync.com"); assert_eq!(eids[1].uids[0].id, "ID5_abc"); assert_eq!(eids[1].uids[0].atype, Some(1)); + assert_eq!(eids[2].source, "google.com", "should preserve PAIR source"); + assert_eq!(eids[2].uids[0].id, "pair-id", "should preserve PAIR ID"); + assert_eq!( + eids[2].uids[0].atype, + Some(571187), + "should preserve PAIR vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 22620e59..dc95be1a 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -33,7 +33,7 @@ struct LegacyCookieEid { dead_code, reason = "legacy cookie field is deserialized for compatibility but not emitted" )] - atype: u8, + atype: i32, } /// OpenRTB-style `ts-eids` cookie entry. @@ -48,7 +48,7 @@ struct StructuredCookieEid { struct StructuredCookieUid { id: String, #[serde(default)] - atype: Option, + atype: Option, #[serde(default)] ext: Option, } @@ -318,6 +318,7 @@ fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { return None; } + let atype = uid.atype.filter(|atype| *atype >= 0); let ext = match uid.ext { Some(JsonValue::Object(_)) => uid.ext, _ => None, @@ -325,7 +326,7 @@ fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { Some(Uid { id: uid.id, - atype: uid.atype, + atype, ext, }) } @@ -338,7 +339,7 @@ fn legacy_cookie_eids_to_openrtb(entries: Vec) -> Vec { source: entry.source, uids: vec![Uid { id: entry.id, - atype: Some(entry.atype), + atype: (entry.atype >= 0).then_some(entry.atype), ext: None, }], }) @@ -407,15 +408,25 @@ mod tests { let eids = vec![ json!({"source": "id5-sync.com", "id": "ID5_abc", "atype": 1}), json!({"source": "liveramp.com", "id": "LR_xyz", "atype": 3}), + json!({"source": "google.com", "id": "pair-id", "atype": 571187}), ]; let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); - assert_eq!(decoded.len(), 2, "should parse both EIDs"); + assert_eq!(decoded.len(), 3, "should parse all EIDs"); assert_eq!(decoded[0].source, "id5-sync.com"); assert_eq!(decoded[0].uids[0].id, "ID5_abc"); assert_eq!(decoded[1].source, "liveramp.com"); assert_eq!(decoded[1].uids[0].id, "LR_xyz"); + assert_eq!( + decoded[2].source, "google.com", + "should preserve PAIR source" + ); + assert_eq!( + decoded[2].uids[0].atype, + Some(571187), + "should preserve PAIR vendor-specific atype" + ); } #[test] @@ -424,7 +435,8 @@ mod tests { "source": "sharedid.org", "uids": [ {"id": "shared_123", "atype": 3}, - {"id": "shared_456", "ext": {"provider": "example"}} + {"id": "shared_456", "ext": {"provider": "example"}}, + {"id": "shared_invalid", "atype": -1} ] })]; let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); @@ -432,7 +444,7 @@ mod tests { let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); assert_eq!(decoded.len(), 1, "should parse one structured EID entry"); assert_eq!(decoded[0].source, "sharedid.org"); - assert_eq!(decoded[0].uids.len(), 2, "should preserve multiple UIDs"); + assert_eq!(decoded[0].uids.len(), 3, "should preserve multiple UIDs"); assert_eq!(decoded[0].uids[0].id, "shared_123"); assert_eq!(decoded[0].uids[0].atype, Some(3)); assert_eq!( @@ -440,6 +452,28 @@ mod tests { Some(json!({"provider": "example"})), "should preserve UID ext objects" ); + assert_eq!( + decoded[0].uids[2].atype, None, + "should drop negative atype values" + ); + } + + #[test] + fn parse_prebid_eids_cookie_preserves_pair_atype() { + let encoded = encode_json(&json!([ + { + "source": "google.com", + "uids": [{ "id": "pair-id", "atype": 571187 }] + } + ])); + + let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode PAIR EID"); + + assert_eq!( + decoded[0].uids[0].atype, + Some(571187), + "should preserve PAIR's vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index 6b688d30..8532de03 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -25,7 +25,7 @@ pub struct PartnerConfig { /// Canonical `OpenRTB` EID source domain and EC KV `ids` key. pub source_domain: String, /// `OpenRTB` `atype` value. - pub openrtb_atype: u8, + pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. pub bidstream_enabled: bool, /// SHA-256 hex of the partner's API token (precomputed at startup). diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..acfef572 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -4654,6 +4654,14 @@ external_bundle_sri = "sha384-AAAA" ext: None, }], }, + crate::openrtb::Eid { + source: "google.com".to_owned(), + uids: vec![crate::openrtb::Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }, ]); let settings = make_settings(); @@ -4670,7 +4678,7 @@ external_bundle_sri = "sha384-AAAA" let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); let ext_eids = &serialized["user"]["ext"]["eids"]; assert!(ext_eids.is_array(), "should populate user.ext.eids"); - assert_eq!(ext_eids.as_array().unwrap().len(), 2, "should have 2 EIDs"); + assert_eq!(ext_eids.as_array().unwrap().len(), 3, "should have 3 EIDs"); assert_eq!( ext_eids[0]["source"], "liveramp.com", "should include liveramp EID" @@ -4679,6 +4687,14 @@ external_bundle_sri = "sha384-AAAA" ext_eids[1]["source"], "id5-sync.com", "should include id5 EID" ); + assert_eq!( + ext_eids[2]["source"], "google.com", + "should include PAIR EID" + ); + assert_eq!( + ext_eids[2]["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index df99f565..65237ce0 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -80,9 +80,9 @@ pub struct Eid { pub struct Uid { /// The identifier value. pub id: String, - /// Agent type: 1 = cookie/device, 2 = person, 3 = user-provided. + /// `OpenRTB` agent type, including vendor-specific values such as PAIR's `571187`. #[serde(skip_serializing_if = "Option::is_none")] - pub atype: Option, + pub atype: Option, /// Provider-specific extension data. #[serde(skip_serializing_if = "Option::is_none")] pub ext: Option, @@ -400,4 +400,23 @@ mod tests { "ext should be omitted when None" ); } + + #[test] + fn eid_serializes_vendor_specific_atype() { + let eid = Eid { + source: "google.com".to_owned(), + uids: vec![Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize"); + + assert_eq!( + serialized["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); + } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a54..aaacb29d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -299,12 +299,13 @@ pub struct EcPartner { /// This normalized domain is also the canonical EC KV `ids` map key. #[validate(custom(function = EcPartner::validate_source_domain))] pub source_domain: String, - /// `OpenRTB` `atype` value (typically 3). + /// `OpenRTB` `atype` value, including vendor-specific values such as PAIR's `571187`. #[serde( default = "EcPartner::default_openrtb_atype", deserialize_with = "from_value_or_str" )] - pub openrtb_atype: u8, + #[validate(range(min = 0, message = "must be a non-negative OpenRTB agent type"))] + pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. #[serde(default, deserialize_with = "from_value_or_str")] pub bidstream_enabled: bool, @@ -417,7 +418,7 @@ impl EcPartner { } #[must_use] - pub const fn default_openrtb_atype() -> u8 { + pub const fn default_openrtb_atype() -> i32 { 3 } @@ -2808,6 +2809,46 @@ mod tests { } } + #[test] + fn validate_accepts_vendor_specific_ec_partner_atype() { + let toml_str = format!( + r#"{} + [[ec.partners]] + name = "PAIR Partner" + source_domain = "google.com" + openrtb_atype = 571187 + api_token = "test-vendor-token-32-bytes-minimum" + "#, + crate_test_settings_str(), + ); + + let settings = Settings::from_toml(&toml_str) + .expect("should accept vendor-specific OpenRTB agent type"); + + assert_eq!( + settings.ec.partners[0].openrtb_atype, 571187, + "should preserve PAIR's vendor-specific atype" + ); + } + + #[test] + fn validate_rejects_negative_ec_partner_atype() { + let toml_str = format!( + r#"{} + [[ec.partners]] + name = "Invalid Partner" + source_domain = "partner.example.com" + openrtb_atype = -1 + api_token = "test-vendor-token-32-bytes-minimum" + "#, + crate_test_settings_str(), + ); + + let result = Settings::from_toml(&toml_str); + + assert!(result.is_err(), "should reject negative OpenRTB agent type"); + } + #[test] fn validate_accepts_origin_host_header_override() { let toml_str = crate_test_settings_str().replace( @@ -3647,7 +3688,7 @@ origin_host_header_overide = "www.example.com""#, (origin_key, Some("https://origin.test-publisher.com")), (partner_0_name_key, Some("Env Partner 0")), (partner_0_source_domain_key, Some("envpartner0.example.com")), - (partner_0_openrtb_atype_key, Some("1")), + (partner_0_openrtb_atype_key, Some("571187")), (partner_0_bidstream_enabled_key, Some("true")), (partner_0_api_token_key, Some("env-token-0")), (partner_1_name_key, Some("Env Partner 1")), @@ -3666,7 +3707,7 @@ origin_host_header_overide = "www.example.com""#, settings.ec.partners[0].source_domain, "envpartner0.example.com" ); - assert_eq!(settings.ec.partners[0].openrtb_atype, 1); + assert_eq!(settings.ec.partners[0].openrtb_atype, 571187); assert!(settings.ec.partners[0].bidstream_enabled); assert_eq!(settings.ec.partners[0].api_token.expose(), "env-token-0"); assert_eq!(settings.ec.partners[1].name, "Env Partner 1"); diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index e3bb5ab3..4e89723e 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -107,7 +107,7 @@ function validateUserIdImport(entry) { } } -function writeGeneratedModule(filePath, title, moduleNames, imports) { +function writeGeneratedModule(filePath, title, moduleNames, imports, exports = []) { const content = [ '// Auto-generated by build-prebid-external.mjs.', '//', @@ -115,12 +115,17 @@ function writeGeneratedModule(filePath, title, moduleNames, imports) { `// Modules: ${moduleNames.join(', ')}`, '', ...imports, + ...(exports.length > 0 ? ['', ...exports] : []), '', ].join('\n'); fs.writeFileSync(filePath, content); } +export function renderIncludedUserIdModulesExport(moduleNames) { + return `export const INCLUDED_PREBID_USER_ID_MODULES = ${JSON.stringify(moduleNames)};`; +} + function generateAdapterImports(adapterNames, adaptersFile) { const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); const imports = []; @@ -165,7 +170,8 @@ function generateUserIdImports(requestedModules, userIdsFile) { userIdsFile, '// External Prebid bundle User ID module imports.', moduleNames, - imports + imports, + [renderIncludedUserIdModulesExport(moduleNames)] ); return moduleNames; } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts index e9fa55f7..e7c0112a 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -1,6 +1,7 @@ // Placeholder for generated Prebid User ID module imports. // // build-prebid-external.mjs aliases this module to a temporary file containing -// publisher-specific imports during external bundle generation. +// publisher-specific imports and the corresponding module-name list during +// external bundle generation. -export {}; +export const INCLUDED_PREBID_USER_ID_MODULES: string[] = []; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index be839d8f..342e4038 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,16 +25,19 @@ import 'prebid.js/modules/userId.js'; // shim leaves its bids untouched and the corresponding adapter handles them // natively in the browser. import './_adapters.generated'; -import './_user_ids.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; -import { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; +import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; +// OpenRTB permits vendor-specific agent types; PAIR uses 571187. +// Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. +const MAX_OPENRTB_ATYPE = 2_147_483_647; const BIDDER_PARAMS_KEY = 'bidderParams'; const ZONE_KEY = 'zone'; const TS_REFRESH_TARGETING_KEYS = [ @@ -139,7 +142,7 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort(); const coveredConfigNames = new Set( PREBID_USER_ID_MODULE_REGISTRY.filter((entry) => - DEFAULT_PREBID_USER_ID_MODULES.includes(entry.moduleName) + INCLUDED_PREBID_USER_ID_MODULES.includes(entry.moduleName) ).flatMap((entry) => entry.configNames) ); const missingConfiguredUserIdNames = configuredUserIdNames.filter( @@ -147,15 +150,20 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { ); const diagnostics: PrebidUserIdDiagnostics = { - includedModules: [...DEFAULT_PREBID_USER_ID_MODULES], + includedModules: [...INCLUDED_PREBID_USER_ID_MODULES], configuredUserIdNames, missingConfiguredUserIdNames, }; + const previouslyMissingConfiguredUserIdNames = new Set(); if (typeof window !== 'undefined') { const tsjsWindow = window as typeof window & { __tsjs_prebid_diagnostics?: { userIdModules?: PrebidUserIdDiagnostics }; }; + for (const name of tsjsWindow.__tsjs_prebid_diagnostics?.userIdModules + ?.missingConfiguredUserIdNames ?? []) { + previouslyMissingConfiguredUserIdNames.add(name); + } tsjsWindow.__tsjs_prebid_diagnostics = { ...(tsjsWindow.__tsjs_prebid_diagnostics ?? {}), userIdModules: diagnostics, @@ -163,9 +171,11 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { } for (const name of missingConfiguredUserIdNames) { - log.warn( - `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` - ); + if (!previouslyMissingConfiguredUserIdNames.has(name)) { + log.warn( + `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` + ); + } } return diagnostics; @@ -274,7 +284,7 @@ function sanitizeAuctionUid(uid: { typeof uid.atype === 'number' && Number.isInteger(uid.atype) && uid.atype >= 0 && - uid.atype <= 255 + uid.atype <= MAX_OPENRTB_ATYPE ) { sanitizedUid.atype = uid.atype; } @@ -559,6 +569,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // client-side bidders are left untouched. pbjs.requestBids = function (requestObj?: Parameters[0]) { log.debug('[tsjs-prebid] requestBids called'); + recordUserIdModuleDiagnostics(); const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -811,25 +822,13 @@ export function installRefreshHandler(timeoutMs = 1500): void { } /** - * Configure Prebid.js userID modules for identity warm-up. - * - * Runs post-window.load (called from installPrebidNpm after setup). - * Writes identity tokens to 1P cookies so the next server-side request - * can harvest them for EC graph enrichment. + * Configure identity sync behavior for the generated Prebid User ID modules. * - * **Current state:** This function only configures `pbjs.userSync` settings. - * It does NOT import or register any userID modules. Actual module imports - * (ID5, sharedID, LiveRamp ATS, Lockr) must be added to this bundle explicitly - * — there is currently no `_userIdModules.generated.ts` build step. - * Track as Phase B follow-up: add `TSJS_PREBID_USER_ID_MODULES` handling to - * `build-all.mjs` (similar to `TSJS_PREBID_ADAPTERS`) and import generated file. + * The external bundle generator statically imports the selected modules through + * `_user_ids.generated.ts`. This post-window-load configuration controls when + * those modules synchronize identities; it does not select or register modules. */ export function installUserIdModules(): void { - // NOTE: No userID module imports exist yet. `_userIdModules.generated.ts` and - // `TSJS_PREBID_USER_ID_MODULES` handling in `build-all.mjs` are not implemented. - // This function only configures pbjs.userSync settings; actual module registration - // requires the Phase B follow-up described in the docblock above. - // Configure sync behavior so modules will run post-window.load when added. try { pbjs.setConfig({ userSync: { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json index 10f244f3..a4fd58db 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json @@ -58,6 +58,18 @@ "importPath": "prebid.js/modules/liveIntentIdSystem.js", "notes": "Imported through a local ESM shim because the public Prebid wrapper contains CommonJS require()." }, + { + "moduleName": "lockrAIMIdSystem", + "configNames": ["lockrAIMId"], + "eidSources": [], + "importPath": "prebid.js/modules/lockrAIMIdSystem.js" + }, + { + "moduleName": "pairIdSystem", + "configNames": ["pairId"], + "eidSources": ["google.com"], + "importPath": "prebid.js/modules/pairIdSystem.js" + }, { "moduleName": "pubProvidedIdSystem", "configNames": ["pubProvidedId"], diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 609bdba9..a1c77c47 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -1,9 +1,18 @@ +// @vitest-environment node + import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { deriveBundleMetadata, parseArgs } from '../build-prebid-external.mjs'; +import { + deriveBundleMetadata, + main, + parseArgs, + renderIncludedUserIdModulesExport, +} from '../build-prebid-external.mjs'; describe('build-prebid-external metadata', () => { it('derives filename, sha256, and SRI from exact bundle bytes', () => { @@ -18,6 +27,39 @@ describe('build-prebid-external metadata', () => { }); }); + it('renders the exact selected User ID modules for runtime diagnostics', () => { + expect(renderIncludedUserIdModulesExport(['liveIntentIdSystem', 'pairIdSystem'])).toBe( + 'export const INCLUDED_PREBID_USER_ID_MODULES = ["liveIntentIdSystem","pairIdSystem"];' + ); + }); + + it('includes generated User ID metadata in the production external bundle', async () => { + const outputDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'trusted-server-prebid-build-test-') + ); + + try { + await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'pairIdSystem,lockrAIMIdSystem', + '--out', + outputDirectory, + ]); + + const manifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); + expect(bundle).toContain('["pairIdSystem","lockrAIMIdSystem"]'); + } finally { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } + }, 120_000); + it('resolves relative output paths against the current working directory', () => { const parsed = parseArgs(['--adapters', 'rubicon', '--out', 'dist/prebid']); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c..726f40b4 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -7,6 +7,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockGetConfig, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -19,12 +20,14 @@ const { const mockGetUserIdsAsEids = vi.fn( () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); + const mockGetConfig = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, + getConfig: mockGetConfig, adUnits: [] as any[], }; const mockAdapterManager = { @@ -36,6 +39,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockGetConfig, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -53,9 +57,11 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); -// Mock the build-generated side-effect imports (no-op in tests) +// Mock the build-generated imports in tests. vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); -vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({})); +vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({ + INCLUDED_PREBID_USER_ID_MODULES: ['sharedIdSystem'], +})); import { collectBidders, @@ -65,6 +71,7 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import { log } from '../../../src/core/log'; describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { @@ -207,8 +214,14 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); + mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete (window as any).__tsjs_prebid; + delete (window as any).__tsjs_prebid_diagnostics; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('registers the trustedServer bid adapter', () => { @@ -251,6 +264,36 @@ describe('prebid/installPrebidNpm', () => { expect(mockProcessQueue).toHaveBeenCalledTimes(1); }); + it('reports the User ID modules selected by the generated bundle', () => { + installPrebidNpm(); + + expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: [], + missingConfiguredUserIdNames: [], + }); + }); + + it('refreshes late User ID config without repeating missing-module warnings', () => { + installPrebidNpm(); + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} + ); + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + mockPbjs.requestBids({ adUnits: [] }); + mockPbjs.requestBids({ adUnits: [] }); + + expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: ['pairId', 'sharedId'], + missingConfiguredUserIdNames: ['pairId'], + }); + expect( + warnSpy.mock.calls.filter(([message]) => String(message).includes('"pairId"')) + ).toHaveLength(1); + }); + it('returns the pbjs instance', () => { const result = installPrebidNpm(); expect(result).toBe(mockPbjs); @@ -301,6 +344,10 @@ describe('prebid/installPrebidNpm', () => { source: 'sharedid.org', uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], }, + { + source: 'google.com', + uids: [{ id: 'pair_123', atype: 571187 }], + }, ]); const result = spec.buildRequests([ @@ -322,6 +369,10 @@ describe('prebid/installPrebidNpm', () => { source: 'sharedid.org', uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], }, + { + source: 'google.com', + uids: [{ id: 'pair_123', atype: 571187 }], + }, ]); }); @@ -355,7 +406,7 @@ describe('prebid/installPrebidNpm', () => { }, { id: 'uid-bad-atype', - atype: 999, + atype: 2_147_483_648, ext: { keep: true }, }, { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts index f011f294..2832a408 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { resolvePrebidUserIdModulesFromEids } from '../../../src/integrations/prebid/user_id_modules'; +import { + knownUserIdConfigNames, + resolvePrebidUserIdModulesFromEids, +} from '../../../src/integrations/prebid/user_id_modules'; const sampleEids = [ { source: 'yahoo.com', uids: [{ id: 'connect-id', atype: 3 }] }, @@ -65,6 +68,23 @@ describe('prebid user ID module registry', () => { }); }); + it('exposes config names for modules that do not map EID sources', () => { + expect(knownUserIdConfigNames()).toEqual( + expect.arrayContaining(['lockrAIMId', 'pubProvidedId']) + ); + }); + + it('maps the Google PAIR EID source to pairIdSystem', () => { + const result = resolvePrebidUserIdModulesFromEids([ + { source: 'google.com', uids: [{ id: 'pair-id' }] }, + ]); + + expect(result).toEqual({ + modules: ['userId', 'pairIdSystem'], + missingSources: [], + }); + }); + it('maps unknown LiveIntent provider-backed sources to liveIntentIdSystem', () => { const result = resolvePrebidUserIdModulesFromEids([ { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781..879eb4b9 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -22,6 +22,7 @@ pull_sync_concurrency = 3 # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" +# OpenRTB agent type; vendor-specific values are supported (PAIR uses 571187). # openrtb_atype = 3 # bidstream_enabled = true # api_token = "replace-with-partner-api-token-32-bytes-minimum"