From b95813ccc9a5c6542eebd38dd9b63a2b3ba6200f Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 09:29:00 -0500 Subject: [PATCH 1/3] Fix Prebid User ID diagnostics Publisher-specific bundles need diagnostics based on the modules they actually contain. Otherwise, missing identity integrations can be hidden by the default preset. Refresh the comparison when auctions begin so late publisher configuration is visible without repeating warnings. Resolves: #886 --- .../lib/build-prebid-external.mjs | 10 +++- .../prebid/_user_ids.generated.ts | 5 +- .../lib/src/integrations/prebid/index.ts | 42 ++++++++--------- .../integrations/prebid/user_id_modules.json | 12 +++++ .../lib/test/build-prebid-external.test.mjs | 12 ++++- .../test/integrations/prebid/index.test.ts | 47 ++++++++++++++++++- .../prebid/user_id_modules.test.ts | 22 ++++++++- 7 files changed, 119 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index e3bb5ab3c..4e89723ed 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 e9fa55f7d..e7c0112a9 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 be839d8fc..c6cf97cfe 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,14 +25,14 @@ 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 { INCLUDED_PREBID_USER_ID_MODULES } from './_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 { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; @@ -139,7 +139,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 +147,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 +168,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; @@ -559,6 +566,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 +819,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 10f244f31..a4fd58dbe 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 609bdba91..10702f914 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 @@ -3,7 +3,11 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { deriveBundleMetadata, parseArgs } from '../build-prebid-external.mjs'; +import { + deriveBundleMetadata, + parseArgs, + renderIncludedUserIdModulesExport, +} from '../build-prebid-external.mjs'; describe('build-prebid-external metadata', () => { it('derives filename, sha256, and SRI from exact bundle bytes', () => { @@ -18,6 +22,12 @@ 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('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 9ad7945c4..e6b5fd354 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); 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 f011f294d..2832a4082 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([ { From e94430eb72afe9feffdba612e01ec63de5ed8934 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 09:50:08 -0500 Subject: [PATCH 2/3] Order Prebid imports for lint --- crates/trusted-server-js/lib/src/integrations/prebid/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c6cf97cfe..a4b1ccff2 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,13 +25,13 @@ 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 { INCLUDED_PREBID_USER_ID_MODULES } from './_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 { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; From b56adcb39ade3902ff8c131bb8ac7397fbfff74c Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 10:28:03 -0500 Subject: [PATCH 3/3] Preserve vendor-specific OpenRTB atype values --- .../src/auction/endpoints.rs | 22 +++++++- crates/trusted-server-core/src/ec/eids.rs | 16 +++++- .../trusted-server-core/src/ec/prebid_eids.rs | 48 ++++++++++++++--- crates/trusted-server-core/src/ec/registry.rs | 2 +- .../src/integrations/prebid.rs | 18 ++++++- crates/trusted-server-core/src/openrtb.rs | 23 ++++++++- crates/trusted-server-core/src/settings.rs | 51 +++++++++++++++++-- .../lib/src/integrations/prebid/index.ts | 5 +- .../lib/test/build-prebid-external.test.mjs | 32 ++++++++++++ .../test/integrations/prebid/index.test.ts | 10 +++- trusted-server.example.toml | 1 + 11 files changed, 206 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 83c7df349..2833e63eb 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 1dd8b1795..2c01bf852 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 22620e599..dc95be1aa 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 6b688d30e..8532de03b 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 32a1c2a85..acfef5727 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 df99f5653..65237ce0e 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 9cbb2a546..aaacb29df 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/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index a4b1ccff2..342e4038d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -35,6 +35,9 @@ 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 = [ @@ -281,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; } 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 10702f914..a1c77c47d 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,10 +1,15 @@ +// @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, + main, parseArgs, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; @@ -28,6 +33,33 @@ describe('build-prebid-external metadata', () => { ); }); + 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 e6b5fd354..726f40b49 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 @@ -344,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([ @@ -365,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 }], + }, ]); }); @@ -398,7 +406,7 @@ describe('prebid/installPrebidNpm', () => { }, { id: 'uid-bad-atype', - atype: 999, + atype: 2_147_483_648, ext: { keep: true }, }, { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781e..879eb4b91 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"