diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..f8fcfadaf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -64,6 +64,13 @@ run_cli_macos = "run --package trusted-server-cli --target aarch64-apple-darwin test_cli_linux = "test --package trusted-server-cli --target x86_64-unknown-linux-gnu" test_cli_macos = "test --package trusted-server-cli --target aarch64-apple-darwin" +# --- Host-target lint gates that no adapter alias covers --- +# CI lints these two crates explicitly (see .github/workflows/format.yml), but +# pins the Linux triple, so there was no command a developer could run locally +# to reproduce them. These omit --target and therefore build for the host. +clippy-cli = "clippy -p trusted-server-cli --all-targets --all-features -- -D warnings" +clippy-codegen = "clippy -p trusted-server-openrtb-codegen --all-targets -- -D warnings" + # When a wasm binary IS built, run it under Viceroy. [target.'cfg(all(target_arch = "wasm32"))'] runner = "viceroy run -C ../../fastly.toml -- " diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..0447da1cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,12 @@ cargo clippy-cloudflare-wasm cargo clippy-spin-native cargo clippy-spin-wasm +# The CLI and the OpenRTB codegen crate are host-target members that no adapter +# alias covers. CI lints both with the Linux triple pinned; these aliases omit +# `--target` so they reproduce it on any host. +cargo clippy-cli +cargo clippy-codegen + # Check compilation (per-target aliases — bare `cargo check` fails at the workspace root) cargo check-fastly && cargo check-axum && cargo check-cloudflare @@ -336,7 +342,7 @@ IntegrationRegistration::builder(ID) Every PR must pass: 1. `cargo fmt --all -- --check` -2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm` +2. `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo clippy-cli && cargo clippy-codegen` 3. `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin` 4. `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` 5. JS build and test (`cd crates/trusted-server-js/lib && npx vitest run`) diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 802d854ce..1b2abd768 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -11,6 +11,12 @@ pub(crate) type CliResult = Result; const NODE_MODULES_MISSING_HELP: &str = "Prebid bundling dependencies are missing. Run `cd crates/trusted-server-js/lib && npm ci`, then retry `ts prebid bundle`."; +/// Prebid User ID module that backs `[integrations.prebid.liveramp]`. +/// +/// Without it in the bundle the managed `identityLink` entry the server injects +/// has no submodule to drive, so `LiveRamp` silently resolves nothing. +const IDENTITY_LINK_USER_ID_MODULE: &str = "identityLinkIdSystem"; + #[derive(Debug, clap::Args)] pub(crate) struct PrebidBundleArgs { /// Trusted Server config path. @@ -235,6 +241,22 @@ pub(crate) fn load_bundle_config(config_path: &Path) -> CliResult u16 { + 15 +} + +const fn default_liveramp_refresh_in_seconds() -> u32 { + 1800 +} + +fn validate_liveramp_placement_id(value: &str) -> Result<(), ValidationError> { + if !value.is_empty() && value.trim() == value && value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Ok(()); + } + + let mut error = ValidationError::new("invalid_liveramp_placement_id"); + error.message = Some( + "LiveRamp placement_id must be a non-empty ASCII-digit string without surrounding whitespace" + .into(), + ); + Err(error) +} + +/// Browser storage mechanism used by Prebid's `LiveRamp` `IdentityLink` module. +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PrebidLiveRampStorageType { + /// Store the opaque `RampID` envelope in a browser cookie. + #[default] + Cookie, + /// Store the opaque `RampID` envelope in browser local storage. + Html5, +} + +/// Operator-owned configuration for Prebid's `LiveRamp` `IdentityLink` module. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PrebidLiveRampConfig { + /// Numeric Placement ID assigned by `LiveRamp` for the approved publisher origin. + #[validate(custom(function = "validate_liveramp_placement_id"))] + pub placement_id: String, + /// Disable third-party-cookie recognition when `true`. + #[serde(default)] + pub not_use_3p: bool, + /// Browser storage mechanism for the opaque `RampID` envelope. + #[serde(default)] + pub storage_type: PrebidLiveRampStorageType, + /// Number of days for which the browser stores the opaque envelope. + #[serde(default = "default_liveramp_expires_days")] + #[validate(range(min = 1, max = 30))] + pub expires_days: u16, + /// Number of seconds before Prebid may refresh the opaque envelope. + #[serde(default = "default_liveramp_refresh_in_seconds")] + #[validate(range(min = 1))] + pub refresh_in_seconds: u32, +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { #[serde(default = "default_enabled")] @@ -210,6 +266,10 @@ pub struct PrebidIntegrationConfig { /// it in JavaScript. #[serde(default)] pub account_id: Option, + /// Optional managed `LiveRamp` `RampID` configuration for Prebid.js. + #[serde(default)] + #[validate(nested)] + pub liveramp: Option, #[serde(default = "default_timeout_ms")] pub timeout_ms: u32, #[serde( @@ -1078,10 +1138,35 @@ impl IntegrationHeadInjector for PrebidIntegration { } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct InjectedPrebidLiveRampConfig<'a> { + placement_id: &'a str, + #[serde(rename = "notUse3P")] + not_use_3p: bool, + storage_type: PrebidLiveRampStorageType, + expires_days: u16, + refresh_in_seconds: u32, + } + + impl<'a> From<&'a PrebidLiveRampConfig> for InjectedPrebidLiveRampConfig<'a> { + fn from(config: &'a PrebidLiveRampConfig) -> Self { + Self { + placement_id: &config.placement_id, + not_use_3p: config.not_use_3p, + storage_type: config.storage_type, + expires_days: config.expires_days, + refresh_in_seconds: config.refresh_in_seconds, + } + } + } + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InjectedPrebidClientConfig<'a> { account_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + live_ramp: Option>, timeout: u32, debug: bool, bidders: &'a [String], @@ -1093,6 +1178,11 @@ impl IntegrationHeadInjector for PrebidIntegration { let payload = InjectedPrebidClientConfig { account_id: self.config.account_id.as_deref().unwrap_or_default(), + live_ramp: self + .config + .liveramp + .as_ref() + .map(InjectedPrebidLiveRampConfig::from), timeout: self.config.timeout_ms, debug: self.config.debug, bidders: &self.config.bidders, @@ -2730,6 +2820,7 @@ mod tests { enabled: true, server_url: "https://prebid.example".to_string(), account_id: Some("test-account".to_string()), + liveramp: None, timeout_ms: 1000, bidders: vec!["exampleBidder".to_string()], debug: false, @@ -2752,6 +2843,16 @@ mod tests { } } + fn valid_liveramp_config() -> PrebidLiveRampConfig { + PrebidLiveRampConfig { + placement_id: "999".to_string(), + not_use_3p: false, + storage_type: PrebidLiveRampStorageType::Cookie, + expires_days: 15, + refresh_in_seconds: 1800, + } + } + struct PredictOnlyBackend; impl PlatformBackend for PredictOnlyBackend { @@ -3122,6 +3223,129 @@ server_url = "https://prebid.example/openrtb2/auction" ); } + #[test] + fn liveramp_config_parses_with_documented_defaults() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "999" +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert_eq!(liveramp.placement_id, "999", "should preserve placement ID"); + assert!( + !liveramp.not_use_3p, + "should allow cookie recognition by default" + ); + assert_eq!( + liveramp.storage_type, + PrebidLiveRampStorageType::Cookie, + "should default to cookie storage" + ); + assert_eq!( + liveramp.expires_days, 15, + "should default to conservative expiry" + ); + assert_eq!( + liveramp.refresh_in_seconds, 1800, + "should default to LiveRamp's recommended refresh" + ); + } + + #[test] + fn liveramp_config_accepts_explicit_supported_values() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "12345" +not_use_3p = true +storage_type = "html5" +expires_days = 30 +refresh_in_seconds = 3600 +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert!(liveramp.not_use_3p, "should preserve not_use_3p"); + assert_eq!( + liveramp.storage_type, + PrebidLiveRampStorageType::Html5, + "should preserve HTML5 storage" + ); + assert_eq!( + liveramp.expires_days, 30, + "should preserve configured expiry" + ); + assert_eq!( + liveramp.refresh_in_seconds, 3600, + "should preserve configured refresh interval" + ); + } + + #[test] + fn liveramp_config_rejects_invalid_values() { + for (name, live_ramp_section) in [ + ("missing placement ID", "not_use_3p = true"), + ("empty placement ID", "placement_id = \"\""), + ("padded placement ID", "placement_id = \" 999 \""), + ( + "nonnumeric placement ID", + "placement_id = \"placement-999\"", + ), + ("zero expiry", "placement_id = \"999\"\nexpires_days = 0"), + ( + "expiry above limit", + "placement_id = \"999\"\nexpires_days = 31", + ), + ( + "zero refresh", + "placement_id = \"999\"\nrefresh_in_seconds = 0", + ), + ( + "unknown storage", + "placement_id = \"999\"\nstorage_type = \"session\"", + ), + ( + "unknown field", + "placement_id = \"999\"\nunsupported = true", + ), + ] { + let result = parse_prebid_toml_result(&format!( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +{live_ramp_section} +"# + )); + + assert!(result.is_err(), "should reject {name}"); + } + } + + #[test] + fn liveramp_config_is_optional() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" +"#, + ); + + assert!( + config.liveramp.is_none(), + "should omit LiveRamp configuration by default" + ); + } + #[test] fn excluded_gam_ad_unit_path_suffixes_reject_invalid_values() { for (suffix, expected_message) in [ @@ -4027,6 +4251,86 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + fn head_injector_includes_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "999".to_string(), + not_use_3p: true, + storage_type: PrebidLiveRampStorageType::Html5, + expires_days: 30, + refresh_in_seconds: 3600, + }); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + let script = &inserts[0]; + + assert!( + script.contains( + r#""liveRamp":{"placementId":"999","notUse3P":true,"storageType":"html5","expiresDays":30,"refreshInSeconds":3600}"# + ), + "should inject camel-cased LiveRamp config: {script}" + ); + } + + #[test] + fn head_injector_omits_liveramp_config_when_absent() { + let integration = PrebidIntegration::new(base_config()); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + let script = &inserts[0]; + + assert!( + !script.contains("liveRamp"), + "should omit LiveRamp config when absent: {script}" + ); + } + + #[test] + fn head_injector_escapes_script_breakout_in_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "1".to_string(), + ..valid_liveramp_config() + }); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + let script = &inserts[0]; + + assert!( + script.contains(r#""placementId":"1<\/script>").count(), + 1, + "should contain only the legitimate outer closing script tag" + ); + } + #[test] fn head_injector_includes_excluded_gam_ad_unit_path_suffixes() { let mut config = base_config(); diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index eb6e42826..24176fef8 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -241,6 +241,13 @@ function generateExternalEntry(entryFile, adapters, bidderCodes) { "import 'prebid.js/modules/consentManagementTcf.js';", "import 'prebid.js/modules/consentManagementGpp.js';", "import 'prebid.js/modules/consentManagementUsp.js';", + // consentManagement* only retrieves the consent signal. tcfControl is what + // registers the activity controls (accessDevice, syncUser, enrichEids, + // transmitEids, fetchBids) that act on it, so without it a TC string that + // denies a purpose changes nothing: User ID submodules still write storage + // and still call their vendor endpoints. Keep it bundled whenever + // consentManagementTcf is bundled. + "import 'prebid.js/modules/tcfControl.js';", "import 'prebid.js/modules/userId.js';", "import './_adapters.generated';", "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", 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 44b47f2da..96e95475a 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -131,6 +131,9 @@ const TS_REFRESH_TARGETING_KEYS = [ const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; const MAX_PENDING_PUBLISHER_BIDS = 2048; const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; +const IDENTITY_LINK_CONFIG_NAME = 'identityLink'; +const IDENTITY_LINK_STORAGE_NAME = 'idl_env'; +const LIVE_RAMP_SET_CONFIG_SENTINEL = '__tsLiveRampSetConfigInstalled'; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -155,8 +158,20 @@ interface InjectedPrebidConfig { clientSideBidders?: string[]; /** GAM ad-unit-path suffixes excluded from refresh auctions. */ excludedGamAdUnitPathSuffixes?: string[]; + /** Operator-owned LiveRamp IdentityLink configuration. */ + liveRamp?: InjectedLiveRampConfig; } +interface InjectedLiveRampConfig { + placementId: string; + notUse3P: boolean; + storageType: 'cookie' | 'html5'; + expiresDays: number; + refreshInSeconds: number; +} + +type PrebidUserIdConfigEntry = Record & { name: string }; + interface PrebidUserIdDiagnostics { includedModules: string[]; configuredUserIdNames: string[]; @@ -187,29 +202,74 @@ export function collectBidders(adUnits: Array<{ bids?: Array<{ bidder?: string } return [...bidders]; } -function configuredUserIdNamesFromConfig(config: unknown): string[] { - const userIds = Array.isArray(config) - ? config - : config && typeof config === 'object' - ? (( - config as { - userSync?: { userIds?: Array<{ name?: unknown }> }; - userIds?: Array<{ name?: unknown }>; - } - ).userSync?.userIds ?? (config as { userIds?: Array<{ name?: unknown }> }).userIds) - : undefined; +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} - if (!Array.isArray(userIds)) { - return []; +function configuredUserIdEntries(config: unknown): PrebidUserIdConfigEntry[] { + let userIds: unknown; + if (Array.isArray(config)) { + userIds = config; + } else if (isRecord(config)) { + userIds = isRecord(config.userSync) ? config.userSync.userIds : undefined; + if (!Array.isArray(userIds)) { + userIds = config.userIds; + } } - return [ - ...new Set( - userIds - .map((entry) => entry?.name) - .filter((name): name is string => typeof name === 'string' && name.length > 0) - ), - ].sort(); + if (!Array.isArray(userIds)) return []; + + return userIds.filter( + (entry): entry is PrebidUserIdConfigEntry => + isRecord(entry) && typeof entry.name === 'string' && entry.name.length > 0 + ); +} + +function hasUserIdsPath(config: unknown): config is Record & { + userSync: Record & { userIds: unknown }; +} { + return ( + isRecord(config) && + isRecord(config.userSync) && + Object.prototype.hasOwnProperty.call(config.userSync, 'userIds') + ); +} + +function configuredUserIdNamesFromConfig(config: unknown): string[] { + const userIds = configuredUserIdEntries(config); + + return [...new Set(userIds.map((entry) => entry.name))].sort(); +} + +function liveRampUserId(config: InjectedLiveRampConfig): PrebidUserIdConfigEntry { + return { + name: IDENTITY_LINK_CONFIG_NAME, + params: { pid: config.placementId, notUse3P: config.notUse3P }, + storage: { + type: config.storageType, + name: IDENTITY_LINK_STORAGE_NAME, + expires: config.expiresDays, + refreshInSeconds: config.refreshInSeconds, + }, + }; +} + +function withManagedLiveRampUserId( + config: PbjsConfig, + managedEntry: PrebidUserIdConfigEntry +): PbjsConfig { + if (!hasUserIdsPath(config)) return config; + + const retained = configuredUserIdEntries(config.userSync.userIds).filter( + (entry) => entry.name !== IDENTITY_LINK_CONFIG_NAME + ); + return { + ...config, + userSync: { + ...config.userSync, + userIds: [...retained, managedEntry], + }, + } as PbjsConfig; } function readConfiguredUserIdNames(): string[] { @@ -1106,6 +1166,55 @@ export function installPrebidNpm(config?: Partial): typeof pbjs debug: config?.debug ?? injected?.debug, }; + const managedPbjs = pbjs as typeof pbjs & Record; + const liveRampConfig = injected?.liveRamp; + if (liveRampConfig && managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { + const originalSetConfig = pbjs.setConfig.bind(pbjs); + const prebidConfigApi = pbjs as typeof pbjs & { + mergeConfig?: typeof pbjs.setConfig; + }; + const originalMergeConfig = prebidConfigApi.mergeConfig?.bind(pbjs); + + const normalizePublisherConfig = (publisherConfig: PbjsConfig): PbjsConfig => { + try { + // Build the managed entry per call rather than sharing one object. + // Prebid retains whatever it receives as `submodule.config` for the + // life of the page, so a shared instance would let any mutation there + // leak into every later normalization. + return withManagedLiveRampUserId(publisherConfig, liveRampUserId(liveRampConfig)); + } catch (error) { + // Publisher configuration is arbitrary page data: a throwing accessor + // must not break the publisher's own setConfig call. + log.error('[tsjs-prebid] LiveRamp configuration could not be normalized', error); + return publisherConfig; + } + }; + + pbjs.setConfig = ((publisherConfig: PbjsConfig) => { + return originalSetConfig(normalizePublisherConfig(publisherConfig)); + }) as typeof pbjs.setConfig; + if (originalMergeConfig) { + prebidConfigApi.mergeConfig = ((publisherConfig: PbjsConfig) => { + return originalMergeConfig(normalizePublisherConfig(publisherConfig)); + }) as typeof pbjs.setConfig; + } + managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true; + + const getConfig = (pbjs as unknown as { getConfig?: (key?: string) => unknown }).getConfig; + if (typeof getConfig === 'function') { + const effectiveUserIds = configuredUserIdEntries(getConfig.call(pbjs, 'userSync.userIds')); + pbjs.setConfig({ userSync: { userIds: effectiveUserIds } } as PbjsConfig); + } else { + // Without getConfig the effective User ID entries cannot be read, and + // seeding the managed entry alone would silently drop every publisher + // module already configured. Leave the wrappers installed so the next + // publisher userIds call still gets the managed entry. + log.error( + '[tsjs-prebid] window.pbjs.getConfig is unavailable; managed LiveRamp entry not seeded' + ); + } + } + auctionEndpoint = merged.endpoint ?? '/auction'; const apsRendererSupported = hasApsRendererApi(); if (apsRendererSupported) { 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 f22717f79..1997cac95 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 @@ -79,6 +79,33 @@ describe('build-prebid-external metadata', () => { } }, 120_000); + it('builds and stamps identityLinkIdSystem when explicitly selected', async () => { + const outputDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'trusted-server-liveramp-prebid-build-test-') + ); + + try { + await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'identityLinkIdSystem', + '--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(['identityLinkIdSystem']); + expect(bundle).toContain('identityLinkIdSystem'); + } 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 8ead01aa8..600ab9eae 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 @@ -27,6 +27,25 @@ const DEFAULT_BUNDLE_MANIFEST = { userIdModules: ['sharedIdSystem'], }; +const LIVE_RAMP_CONFIG = { + placementId: '999', + notUse3P: false, + storageType: 'cookie' as const, + expiresDays: 15, + refreshInSeconds: 1800, +}; + +const EXPECTED_IDENTITY_LINK = { + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + storage: { + type: 'cookie', + name: 'idl_env', + expires: 15, + refreshInSeconds: 1800, + }, +}; + /** Loose bid shape used by the requestBids shim tests. */ interface TestBid { bidder: string; @@ -47,6 +66,7 @@ interface InjectedPrebidTestConfig { bidders?: string[]; clientSideBidders?: string[]; excludedGamAdUnitPathSuffixes?: unknown; + liveRamp?: typeof LIVE_RAMP_CONFIG; } interface TestGoogletag { @@ -109,6 +129,7 @@ interface TestAdapterSpec { // of mocking module imports. const { mockSetConfig, + mockMergeConfig, mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, @@ -120,6 +141,9 @@ const { mockPbjs, } = vi.hoisted(() => { const mockSetConfig = vi.fn(); + // Prebid's public mergeConfig closes over its internal setConfig rather than + // calling pbjs.setConfig, so wrapping setConfig alone cannot intercept it. + const mockMergeConfig = vi.fn((config: unknown) => mockSetConfig(config)); const mockProcessQueue = vi.fn(); const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); @@ -140,6 +164,7 @@ const { }); const mockPbjs: { setConfig: typeof mockSetConfig; + mergeConfig: typeof mockMergeConfig; processQueue: typeof mockProcessQueue; requestBids: typeof mockRequestBids; registerBidAdapter: typeof mockRegisterBidAdapter; @@ -152,6 +177,7 @@ const { [key: string]: unknown; } = { setConfig: mockSetConfig, + mergeConfig: mockMergeConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, @@ -181,6 +207,7 @@ const { return { mockSetConfig, + mockMergeConfig, mockProcessQueue, mockRequestBids, mockRegisterBidAdapter, @@ -210,6 +237,10 @@ import envelope from '../../fixtures/aps-renderer-v1.json'; // self-init above already set it), so every test starts from a clean page. beforeEach(() => { delete testWindow.__tsjsPrebidShimInstalled; + mockPbjs.setConfig = mockSetConfig; + mockPbjs.mergeConfig = mockMergeConfig; + mockPbjs.processQueue = mockProcessQueue; + delete mockPbjs['__tsLiveRampSetConfigInstalled']; }); describe('prebid/collectBidders', () => { @@ -403,9 +434,16 @@ describe('prebid/auctionBidsToPrebidBids', () => { describe('prebid/installPrebidNpm', () => { beforeEach(() => { vi.clearAllMocks(); + mockSetConfig.mockReset(); + mockProcessQueue.mockReset(); // Reset requestBids to the mock so each test starts fresh mockPbjs.requestBids = mockRequestBids; + mockPbjs.setConfig = mockSetConfig; + mockPbjs.mergeConfig = mockMergeConfig; + mockPbjs.processQueue = mockProcessQueue; mockPbjs.adUnits = []; + mockPbjs.que = []; + mockPbjs.getConfig = mockGetConfig; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); mockGetConfig.mockReset(); @@ -414,6 +452,7 @@ describe('prebid/installPrebidNpm', () => { delete testWindow.__tsjs_prebid_diagnostics; delete testWindow.tsjs; delete mockPbjs['__tsApsBidResponseListenerInstalled']; + delete mockPbjs['__tsLiveRampSetConfigInstalled']; delete mockPbjs.bidderSettings; }); @@ -785,6 +824,265 @@ describe('prebid/installPrebidNpm', () => { expect(mockProcessQueue).toHaveBeenCalledTimes(1); }); + it('leaves the public config APIs unchanged when LiveRamp is not configured', () => { + const originalSetConfig = mockPbjs.setConfig; + const originalMergeConfig = mockPbjs.mergeConfig; + + installPrebidNpm(); + + expect(mockPbjs.setConfig).toBe(originalSetConfig); + expect(mockPbjs.mergeConfig).toBe(originalMergeConfig); + expect(mockSetConfig.mock.calls.some(([value]) => value?.userSync?.userIds)).toBe(false); + }); + + it('preserves effective User ID entries and replaces identityLink exactly once', () => { + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' + ? [ + { name: 'sharedId', storage: { name: '_sharedid' } }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + { name: 'identityLink', params: { pid: 'duplicate-value' } }, + ] + : {} + ); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + + installPrebidNpm(); + + const managedCall = mockSetConfig.mock.calls.find(([value]) => value?.userSync?.userIds); + expect(managedCall?.[0]).toEqual({ + userSync: { + userIds: [{ name: 'sharedId', storage: { name: '_sharedid' } }, EXPECTED_IDENTITY_LINK], + }, + }); + expect( + managedCall?.[0].userSync.userIds.filter( + (entry: { name?: string }) => entry.name === 'identityLink' + ) + ).toHaveLength(1); + }); + + it('drops malformed effective User ID state and installs the managed entry', () => { + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [null, 'invalid', {}, { name: '' }, { name: 'sharedId' }] : {} + ); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + + expect(() => installPrebidNpm()).not.toThrow(); + + const managedCall = mockSetConfig.mock.calls.find(([value]) => value?.userSync?.userIds); + expect(managedCall?.[0].userSync.userIds).toEqual([ + { name: 'sharedId' }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('installs the managed entry before processing the publisher queue', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + + installPrebidNpm(); + + const managedCallOrder = mockSetConfig.mock.invocationCallOrder.find( + (_, index) => mockSetConfig.mock.calls[index][0]?.userSync?.userIds + ); + expect(managedCallOrder).toBeLessThan(mockProcessQueue.mock.invocationCallOrder[0]); + }); + + it('normalizes queued User ID config before a queued auction observes it', () => { + let observedUserIds: unknown; + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + mockPbjs.que = [ + () => + mockPbjs.setConfig({ + userSync: { + syncDelay: 50, + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + auctionOptions: { suppressStaleRender: true }, + }), + () => { + observedUserIds = mockSetConfig.mock.calls.at(-1)?.[0]?.userSync?.userIds; + }, + ]; + mockProcessQueue.mockImplementation(() => { + for (const callback of mockPbjs.que.splice(0)) callback(); + }); + + installPrebidNpm(); + + expect(observedUserIds).toEqual([{ name: 'sharedId' }, EXPECTED_IDENTITY_LINK]); + const queuedConfig = mockSetConfig.mock.calls.at(-1)?.[0]; + expect(queuedConfig.userSync.syncDelay).toBe(50); + expect(queuedConfig.auctionOptions).toEqual({ suppressStaleRender: true }); + }); + + it('normalizes publisher identityLink updates after processQueue', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + + mockPbjs.setConfig({ + userSync: { + userIds: [ + { name: 'id5Id', params: { partner: 1 } }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }); + + expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'id5Id', params: { partner: 1 } }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('normalizes queued identityLink updates made through mergeConfig', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + mockPbjs.que = [ + () => + mockPbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }), + ]; + mockProcessQueue.mockImplementation(() => { + for (const callback of mockPbjs.que.splice(0)) callback(); + }); + + installPrebidNpm(); + + expect(mockMergeConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'sharedId' }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('normalizes late identityLink updates made through mergeConfig', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + + mockPbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'id5Id', params: { partner: 1 } }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }); + + expect(mockMergeConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'id5Id', params: { partner: 1 } }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('passes unrelated publisher configuration through by reference', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + const publisherConfig = { priceGranularity: 'medium', userSync: { syncDelay: 50 } }; + + mockPbjs.setConfig(publisherConfig); + + expect(mockSetConfig.mock.calls.at(-1)?.[0]).toBe(publisherConfig); + }); + + it('does not stack the LiveRamp config wrappers across shim reinstallations', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + const managedSetConfig = mockPbjs.setConfig; + const managedMergeConfig = mockPbjs.mergeConfig; + delete testWindow.__tsjsPrebidShimInstalled; + + installPrebidNpm(); + + expect(mockPbjs.setConfig).toBe(managedSetConfig); + expect(mockPbjs.mergeConfig).toBe(managedMergeConfig); + mockPbjs.setConfig({ userSync: { userIds: [] } }); + expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([EXPECTED_IDENTITY_LINK]); + }); + + it('skips seeding the managed entry when getConfig is unavailable', () => { + // Seeding needs the effective User ID entries. Without getConfig they + // cannot be read, and installing the managed entry alone would silently + // drop every publisher-configured module. + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + delete (mockPbjs as { getConfig?: unknown }).getConfig; + + expect(() => installPrebidNpm()).not.toThrow(); + + expect(mockSetConfig.mock.calls.some(([value]) => value?.userSync?.userIds)).toBe(false); + expect(errorSpy).toHaveBeenCalledWith( + '[tsjs-prebid] window.pbjs.getConfig is unavailable; managed LiveRamp entry not seeded' + ); + + // The wrappers are still installed, so the next publisher userIds call + // still gets the managed entry. + mockPbjs.setConfig({ userSync: { userIds: [{ name: 'sharedId' }] } }); + expect(mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds).toEqual([ + { name: 'sharedId' }, + EXPECTED_IDENTITY_LINK, + ]); + }); + + it('passes the publisher config through when normalization throws', () => { + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + const hostileConfig = { + userSync: { + get userIds(): never { + throw new Error('example accessor failure'); + }, + }, + }; + + mockPbjs.setConfig(hostileConfig as unknown as Parameters[0]); + + expect(mockSetConfig.mock.calls.at(-1)?.[0]).toBe(hostileConfig); + expect(errorSpy).toHaveBeenCalledWith( + '[tsjs-prebid] LiveRamp configuration could not be normalized', + expect.any(Error) + ); + }); + + it('hands Prebid a distinct managed entry object per normalization', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + installPrebidNpm(); + + mockPbjs.setConfig({ userSync: { userIds: [] } }); + const first = mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds[0]; + mockPbjs.setConfig({ userSync: { userIds: [] } }); + const second = mockSetConfig.mock.calls.at(-1)?.[0].userSync.userIds[0]; + + expect(first).toEqual(EXPECTED_IDENTITY_LINK); + expect(second).toEqual(EXPECTED_IDENTITY_LINK); + expect(second).not.toBe(first); + }); + + it('reports identityLink missing when its bundle module is absent', () => { + testWindow.__tsjs_prebid = { liveRamp: LIVE_RAMP_CONFIG }; + testWindow.__tsjs_prebid_bundle = { userIdModules: [] }; + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [EXPECTED_IDENTITY_LINK] : {} + ); + + installPrebidNpm(); + + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: [], + configuredUserIdNames: ['identityLink'], + missingConfiguredUserIdNames: ['identityLink'], + }); + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + it('reports the User ID modules selected by the generated bundle', () => { installPrebidNpm(); @@ -935,6 +1233,90 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('forwards the opaque LiveRamp envelope as a liveramp.com EID', () => { + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + + const request = spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidId: 'bid-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + + expect(JSON.parse(request.data).eids).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + }); + + it('drops empty and malformed LiveRamp envelope values', () => { + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [ + { id: '' }, + { id: undefined as unknown as string }, + { id: 'opaque-valid-envelope', atype: 3 }, + ], + }, + { source: '', uids: [{ id: 'opaque-invalid-source' }] }, + ]); + + const request = spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + + expect(JSON.parse(request.data).eids).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-valid-envelope', atype: 3 }], + }, + ]); + }); + + it('never writes an opaque LiveRamp envelope to logs', () => { + const sentinel = 'opaque-envelope-must-not-be-logged'; + const spies = [ + vi.spyOn(log, 'debug').mockImplementation(() => {}), + vi.spyOn(log, 'info').mockImplementation(() => {}), + vi.spyOn(log, 'warn').mockImplementation(() => {}), + vi.spyOn(log, 'error').mockImplementation(() => {}), + ]; + const spec = getAdapterSpec(); + mockGetUserIdsAsEids.mockReturnValue([ + { source: 'liveramp.com', uids: [{ id: sentinel, atype: 3 }] }, + ]); + + spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidder: 'trustedServer', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + ]); + + const logged = spies.flatMap((spy) => spy.mock.calls).flat(); + expect(logged.some((value) => JSON.stringify(value).includes(sentinel))).toBe(false); + }); + it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { const spec = getAdapterSpec(); document.cookie = 'ts-eids=stale-value'; @@ -1389,6 +1771,32 @@ describe('prebid/installPrebidNpm', () => { ]); }); + it('preserves an opaque LiveRamp envelope in the ts-eids cookie', () => { + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + + const pbjs = installPrebidNpm(); + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); + + const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; + expect(cookieValue).toBeDefined(); + expect(JSON.parse(atob(cookieValue!))).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]); + }); + it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { 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 2832a4082..1cd0a19b1 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 @@ -4,6 +4,7 @@ import { knownUserIdConfigNames, resolvePrebidUserIdModulesFromEids, } from '../../../src/integrations/prebid/user_id_modules'; +import registry from '../../../src/integrations/prebid/user_id_modules.json'; const sampleEids = [ { source: 'yahoo.com', uids: [{ id: 'connect-id', atype: 3 }] }, @@ -85,6 +86,21 @@ describe('prebid user ID module registry', () => { }); }); + it('maps LiveRamp EIDs to identityLinkIdSystem', () => { + expect( + resolvePrebidUserIdModulesFromEids([ + { source: 'liveramp.com', uids: [{ id: 'opaque-envelope', atype: 3 }] }, + ]) + ).toEqual({ + modules: ['userId', 'identityLinkIdSystem'], + missingSources: [], + }); + }); + + it('includes identityLinkIdSystem in the checked-in default preset', () => { + expect(registry.defaultPreset).toContain('identityLinkIdSystem'); + }); + it('maps unknown LiveIntent provider-backed sources to liveIntentIdSystem', () => { const result = resolvePrebidUserIdModulesFromEids([ { diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6ee858568..25d7a88ee 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -34,7 +34,7 @@ beforeAll(async () => { '--adapters', 'adf', '--user-id-modules', - 'sharedIdSystem', + 'sharedIdSystem,identityLinkIdSystem', '--out', outputDirectory, ]); @@ -132,9 +132,22 @@ describe('external bundle + served shim evaluated together', () => { // Mirror the server's head-injected state, which always precedes the // bundle script in document order. pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { clientSideBidders: [] }; + pageWindow.__tsjs_prebid = { + clientSideBidders: [], + liveRamp: { + placementId: '999', + notUse3P: false, + storageType: 'cookie', + expiresDays: 15, + refreshInSeconds: 1800, + }, + }; pageWindow.eval(bundleCode); + pageWindow.pbjs.setConfig({ + userSync: { userIds: [{ name: 'sharedId' }] }, + }); + pageWindow.pbjs.setConfig({ userSync: { syncDelay: 41 } }); expect(typeof pageWindow.pbjs.requestBids).toBe('function'); expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); @@ -144,7 +157,10 @@ describe('external bundle + served shim evaluated together', () => { 'adform', 'adformOpenRTB', ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); + expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual([ + 'sharedIdSystem', + 'identityLinkIdSystem', + ]); // Count trustedServer registrations across repeated shim evaluations. const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); @@ -154,6 +170,70 @@ describe('external bundle + served shim evaluated together', () => { pageWindow.eval(shimCode); const wrappedRequestBids = pageWindow.pbjs.requestBids; + expect(pageWindow.pbjs.getConfig('userSync.userIds')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'sharedId' }), + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + storage: expect.objectContaining({ name: 'idl_env' }), + }), + ]) + ); + + // Characterize the pinned Prebid artifact: partial userSync updates retain + // its effective User ID list, including the operator-managed entry. + pageWindow.pbjs.setConfig({ userSync: { syncDelay: 50 } }); + + const userIdsAfterPartialUpdate = pageWindow.pbjs.getConfig('userSync.userIds'); + expect(userIdsAfterPartialUpdate.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), + ]); + expect(userIdsAfterPartialUpdate).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) + ); + expect(pageWindow.pbjs.getConfig('userSync.syncDelay')).toBe(50); + + // Exercise the real Prebid mergeConfig implementation. It closes over + // Prebid's internal setConfig, so the shim must guard mergeConfig itself + // to prevent a publisher-owned duplicate from bypassing the setConfig guard. + pageWindow.pbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, + }); + + const mergedUserIds = pageWindow.pbjs.getConfig('userSync.userIds'); + expect(mergedUserIds.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), + ]); + expect(mergedUserIds).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) + ); + + pageWindow.pbjs.mergeConfig({ userSync: { syncDelay: 75 } }); + + const userIdsAfterPartialMerge = pageWindow.pbjs.getConfig('userSync.userIds'); + expect(userIdsAfterPartialMerge.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), + ]); + expect(userIdsAfterPartialMerge).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) + ); + expect(pageWindow.pbjs.getConfig('userSync.syncDelay')).toBe(75); + // A second evaluation (double script inclusion, or a legacy bundle that // still carries a baked-in shim running after this one) must be a no-op. pageWindow.eval(shimCode); diff --git a/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs new file mode 100644 index 000000000..8f42528e4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs @@ -0,0 +1,247 @@ +// @vitest-environment node + +// Proves the generated external Prebid bundle actually ENFORCES the TCF signal +// it collects. `consentManagementTcf` only retrieves the consent string; the +// activity controls that act on it live in `tcfControl`. Without that module a +// TC string denying Purpose 1 changes nothing: User ID submodules still write +// browser storage and still call their vendor endpoints. +// +// This matters most for the managed LiveRamp entry, which Trusted Server +// configures on the operator's behalf: the publisher never wrote the page code +// that turns it on, so the bundle is the only place enforcement can come from. +// +// Runs in the node environment (vite/esbuild cannot run under jsdom globals) +// and evaluates the artifacts in an explicit JSDOM window instead. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM } from 'jsdom'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { main } from '../build-prebid-external.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const libDir = path.resolve(__dirname, '..'); + +const LIVE_RAMP_ENVELOPE_HOST = 'api.rlcdn.com'; +const LIVE_RAMP_STORAGE_NAME = 'idl_env'; +// LiveRamp's IAB Global Vendor List ID. +const LIVE_RAMP_GVL_VENDOR_ID = 97; + +/** + * Requests the page made to LiveRamp's envelope endpoint. + * + * Matches the parsed hostname rather than a substring: `includes()` would also + * match an unrelated host that merely carries this one in its name or query + * string, which could let the granted-consent assertion count the wrong + * request. + * + * @returns the matching URLs + */ +function envelopeRequests(urls) { + return urls.filter((url) => { + try { + return new URL(String(url), 'https://pub.example.com').hostname === LIVE_RAMP_ENVELOPE_HOST; + } catch { + return false; + } + }); +} + +let outputDirectory; +let bundleCode; +let shimCode; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-consent-')); + + await main([ + '--adapters', + 'adf', + '--user-id-modules', + 'identityLinkIdSystem', + '--out', + outputDirectory, + ]); + const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); + bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + const { build } = await import('vite'); + await build({ + configFile: false, + root: libDir, + build: { + emptyOutDir: false, + outDir: outputDirectory, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: path.join(libDir, 'src', 'integrations', 'prebid', 'index.ts'), + output: { + format: 'iife', + dir: outputDirectory, + entryFileNames: 'tsjs-prebid.js', + inlineDynamicImports: true, + extend: false, + name: 'tsjs_prebid', + }, + }, + }, + logLevel: 'warn', + }); + shimCode = fs.readFileSync(path.join(outputDirectory, 'tsjs-prebid.js'), 'utf8'); +}, 240_000); + +afterAll(() => { + fs.rmSync(outputDirectory, { recursive: true, force: true }); +}); + +// `tcfControl` reads the CMP's structured `vendorData`, not the encoded string, +// so the purpose and vendor grants below are what the rules actually evaluate. +// The string only has to be present and non-empty. +function tcData({ purpose1 = true, purpose3 = true, purpose4 = true, vendor97 = true } = {}) { + return { + gdprApplies: true, + tcString: 'CPexampleTCStringForTests', + eventStatus: 'tcloaded', + cmpStatus: 'loaded', + apiVersion: '2', + purpose: { + consents: { 1: purpose1, 3: purpose3, 4: purpose4 }, + legitimateInterests: {}, + }, + vendor: { + consents: { [LIVE_RAMP_GVL_VENDOR_ID]: vendor97 }, + legitimateInterests: {}, + }, + publisher: { restrictions: {} }, + specialFeatureOptins: {}, + }; +} + +/** + * Evaluates both artifacts on a GDPR page whose CMP grants or denies the + * purpose and vendor grants, then runs one auction. + * + * @returns the URLs the page requested and the cookies it managed to set. + */ +async function runGdprPage(grants = {}) { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const pageWindow = dom.window; + + const requestedUrls = []; + pageWindow.fetch = vi.fn(async (resource) => { + requestedUrls.push(typeof resource === 'string' ? resource : resource?.url); + return new Response(JSON.stringify({ envelope: 'opaque-test-envelope' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + pageWindow.Request = class PageRequest extends Request { + constructor(resource, init) { + super( + typeof resource === 'string' ? new URL(resource, 'https://pub.example.com').href : resource, + init + ); + } + }; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) { + pageWindow.isSecureContext = true; + } + + const consentData = tcData(grants); + pageWindow.__tcfapi = (command, _version, callback) => { + if (command === 'addEventListener' || command === 'getTCData') { + callback(consentData, true); + } else if (command === 'removeEventListener') { + callback(true, true); + } + }; + + // Mirror the server's head-injected state, which always precedes the bundle + // script in document order. + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__tsjs_prebid = { + clientSideBidders: [], + liveRamp: { + placementId: '999', + notUse3P: false, + storageType: 'cookie', + expiresDays: 15, + refreshInSeconds: 1800, + }, + }; + + pageWindow.eval(bundleCode); + pageWindow.pbjs.setConfig({ + consentManagement: { gdpr: { cmpApi: 'iab', timeout: 500, defaultGdprScope: true } }, + // Resolve User IDs before the auction so one auction is enough to observe + // whether IdentityLink ran. + userSync: { auctionDelay: 300, syncEnabled: false }, + }); + pageWindow.eval(shimCode); + + pageWindow.pbjs.requestBids({ adUnits: [], bidsBackHandler: () => {} }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => pageWindow.setTimeout(resolve, 400)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + return { requestedUrls, cookies: pageWindow.document.cookie }; +} + +describe('external bundle TCF enforcement', () => { + it('bundles the activity-control module alongside the consent collectors', () => { + // A bundle that collects consent but cannot act on it is the failure mode + // this whole suite exists to prevent. + expect(bundleCode).toContain('consentManagementTcf'); + expect(bundleCode).toContain('tcfControl'); + }); + + it('blocks IdentityLink storage and vendor calls when Purpose 1 is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ purpose1: false }); + + expect(envelopeRequests(requestedUrls)).toEqual([]); + expect(cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); + expect(cookies).not.toContain('_lr_retry_request'); + }); + + it('blocks IdentityLink storage and vendor calls when vendor 97 is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ vendor97: false }); + + expect(envelopeRequests(requestedUrls)).toEqual([]); + expect(cookies).not.toContain(LIVE_RAMP_STORAGE_NAME); + expect(cookies).not.toContain('_lr_retry_request'); + }); + + it('still resolves IdentityLink when Purpose 3 alone is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ purpose3: false }); + + expect(envelopeRequests(requestedUrls)).toHaveLength(1); + expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); + }); + + it('still resolves IdentityLink when Purpose 4 alone is denied', async () => { + const { requestedUrls, cookies } = await runGdprPage({ purpose4: false }); + + expect(envelopeRequests(requestedUrls)).toHaveLength(1); + expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); + }); + + it('resolves IdentityLink when all relevant grants are present', async () => { + const { requestedUrls, cookies } = await runGdprPage(); + + expect(envelopeRequests(requestedUrls)).toHaveLength(1); + expect(cookies).toContain(LIVE_RAMP_STORAGE_NAME); + }); +}); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1240dce20..0b70b886f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1186,22 +1186,27 @@ apply when the integration section exists in `trusted-server.toml`. **Section**: `[integrations.prebid]` -| Field | Type | Default | Description | -| -------------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | Boolean | `true` | Enable Prebid integration | -| `server_url` | String | Required | Prebid Server endpoint URL | -| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | -| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | -| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | -| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | -| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | -| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | -| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | -| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | -| `debug_query_params` | String | `None` | Extra query params appended for debugging | -| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | -| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| Field | Type | Default | Description | +| ----------------------------- | ------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | Boolean | `true` | Enable Prebid integration | +| `server_url` | String | Required | Prebid Server endpoint URL | +| `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | +| `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | +| `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | +| `bid_param_override_rules` | Array[Table] | `[]` | Canonical ordered override rules with `when` matchers and `set` objects; evaluated after compatibility fields so later rules win on conflicts | +| `suppress_nurl` | Boolean | `false` | Strip `nurl` and `burl` from every PBS bid when the PBS deployment fires win/billing notifications server-side | +| `suppress_nurl_bidders` | Array[String] | `[]` | Bidder seats whose `nurl` and `burl` should be stripped while preserving client-side win/billing pixels for other bidders | +| `debug` | Boolean | `false` | Enable debug mode (sets `ext.prebid.debug` and `returnallbidstatus`; surfaces debug metadata in responses) | +| `test_mode` | Boolean | `false` | Set OpenRTB `test: 1` flag for non-billable test traffic (independent of `debug`) | +| `debug_query_params` | String | `None` | Extra query params appended for debugging | +| `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side (see [Prebid docs](/guide/integrations/prebid#client-side-bidders)) | +| `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| `liveramp.placement_id` | String | Required when subsection exists | Numeric LiveRamp Placement ID for Prebid IdentityLink | +| `liveramp.not_use_3p` | Boolean | `false` | Disable cookie-recognized RampID envelopes when `true` | +| `liveramp.storage_type` | String | `cookie` | Browser storage used by IdentityLink: `cookie` or `html5` | +| `liveramp.expires_days` | Integer | `15` | Envelope storage lifetime in days; valid range is 1–30 | +| `liveramp.refresh_in_seconds` | Integer | `1800` | Positive interval before retrieving a potentially refreshed envelope | APS is configured exclusively under `[integrations.aps]`. `aps` entries in `bidders` or `client_side_bidders` are logged and removed case-insensitively so @@ -1226,6 +1231,13 @@ client_side_bidders = ["rubicon"] # Customize script interception (optional) script_patterns = ["/prebid.js", "/prebid.min.js"] +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 + [integrations.prebid.bid_param_overrides.criteo] networkId = 99999 pubid = "server-pub" @@ -1254,8 +1266,18 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false TRUSTED_SERVER__INTEGRATIONS__PREBID__TEST_MODE=false TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG_QUERY_PARAMS=debug=1 TRUSTED_SERVER__INTEGRATIONS__PREBID__SCRIPT_PATTERNS='["/prebid.js","/prebid.min.js"]' +TRUSTED_SERVER__INTEGRATIONS__PREBID__LIVERAMP__PLACEMENT_ID=999 ``` +The LiveRamp storage name is fixed to `idl_env`. The Placement ID and an +approved publisher origin are operational prerequisites, and the external +Prebid bundle must include `identityLinkIdSystem`. The environment override +above only replaces a `placement_id` that the published TOML already declares — +it cannot introduce the subsection, so LiveRamp cannot be enabled from the +environment alone. See +[Managed LiveRamp RampID](/guide/integrations/prebid#managed-liveramp-rampid) +for consent, timing, privacy, degraded behavior, and live validation guidance. + **Script Pattern Matching**: The `script_patterns` configuration determines which Prebid scripts are intercepted and replaced with empty JavaScript responses. This prevents client-side Prebid.js from loading when using server-side bidding. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 32f2827fb..79bf20f62 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -39,6 +39,14 @@ excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] +# Optional operator-owned LiveRamp RampID configuration. +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 + # Required when external_bundle_url is configured. Include the bundle host and # any HTTPS redirect targets used by that host. [proxy] @@ -47,7 +55,7 @@ allowed_domains = ["assets.example"] # External bundle generation inputs used by `ts prebid bundle`. [integrations.prebid.bundle] adapters = ["rubicon"] -user_id_modules = ["sharedIdSystem"] +user_id_modules = ["sharedIdSystem", "identityLinkIdSystem"] # Optional static per-bidder param overrides (shallow merge) [integrations.prebid.bid_param_overrides.criteo] @@ -90,6 +98,11 @@ set = { placementId = "_s2sHeaderPlacement" } | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | | `bundle.adapters` | Array[String] | Required for `ts prebid bundle` | Prebid.js bidder adapter modules imported into the generated external browser bundle | | `bundle.user_id_modules` | Array[String] | Generator default preset when omitted | Prebid User ID modules imported into the generated external browser bundle | +| `liveramp.placement_id` | String | Required when subsection exists | Numeric LiveRamp Placement ID for the approved publisher origin | +| `liveramp.not_use_3p` | Boolean | `false` | Disable cookie-recognized RampID envelopes when `true` | +| `liveramp.storage_type` | String | `cookie` | Browser storage used by IdentityLink: `cookie` or `html5` | +| `liveramp.expires_days` | Integer | `15` | Envelope storage lifetime in days; valid range is 1–30 | +| `liveramp.refresh_in_seconds` | Integer | `1800` | Positive interval before retrieving a potentially refreshed envelope | ## External Bundle Generation @@ -118,6 +131,14 @@ regenerating and re-uploading the bundle (and pushing the updated rollout. The shim refuses to install twice on one page via the `window.__tsjsPrebidShimInstalled` sentinel. +The consent modules include Prebid's `tcfControl`, so a regenerated bundle can +enforce the TCF signal it collects rather than only reporting it. Its default +rules are activity-specific. For the managed IdentityLink entry, Purpose 1 and +LiveRamp vendor consent gate browser resolution and storage. Purpose 3 has no +standalone default rule, while Purpose 4 controls user-provided-data activity +rather than IdentityLink resolution. Validate a regenerated bundle against a +live CMP before rolling it out broadly. + ## Debug Mode When `debug = true`, the Prebid integration enables additional diagnostics on both the outgoing OpenRTB request and the incoming response. @@ -458,6 +479,147 @@ Example EID source mapping: User ID module selection is separate from `--adapters`, which controls client-side bidder adapter modules. +## Managed LiveRamp RampID + +Trusted Server can configure Prebid's `identityLink` User ID submodule and +forward the resulting RampID identity envelope through the existing EID path. +This feature does not collect email addresses, hash identifiers, call a +server-to-server ATS API, or add a new application-facing envelope API. + +### Prerequisites + +Before enabling the subsection, obtain a test or production Placement ID from +LiveRamp, have the exact publisher origin approved by LiveRamp, and confirm the +publisher's CMP and LiveRamp contract permit the intended recognition mode. +The external Prebid bundle must include `identityLinkIdSystem`: + +```toml +[integrations.prebid.bundle] +adapters = ["rubicon"] +user_id_modules = ["identityLinkIdSystem"] + +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 +``` + +Run `ts prebid bundle`, upload the generated content-addressed bundle, copy its +hash metadata into `[integrations.prebid]`, and validate the configuration +before rollout. The storage name is fixed to `idl_env`; operators choose only +the storage type, expiry, and refresh interval. `ts prebid bundle` rejects a +config that sets `[integrations.prebid.liveramp]` while pinning +`bundle.user_id_modules` to a list without `identityLinkIdSystem`; omit the +list to take the generator's default preset, which includes it. + +The generated bundle carries Prebid's `tcfControl` module alongside the +`consentManagement*` modules. That pairing is what makes the TCF signal +enforceable: `consentManagement*` retrieves the consent data, while `tcfControl` +registers activity controls that act on it. Under pinned Prebid's defaults, +Purpose 1 and LiveRamp's GVL vendor consent (vendor 97) gate IdentityLink +resolution and storage. Purpose 3 has no standalone default rule. Purpose 4 +controls user-provided-data activity, but denying it alone does not block +IdentityLink resolution or storage. + +Default EID transmission accepts a qualifying purpose and vendor basis from any +of Purposes 2–10. Publishers can require Purpose 4 specifically by enabling +Prebid's `eidsRequireP4Consent` setting. These are the generated bundle's TCF +defaults; equivalent GPP/US-state browser activity-control modules are not +bundled, so US-state opt-outs remain enforced at Trusted Server's forwarding +gate. + +When enabled, Trusted Server owns one deterministic `identityLink` entry in +`userSync.userIds` for publisher configuration applied through the public +`pbjs.setConfig` and `pbjs.mergeConfig` APIs. Other publisher-configured User ID +entries are preserved, but calls through those APIs that add, remove, or replace +`identityLink` are normalized back to the operator-managed values. This is a +configuration-ownership convention, not a security boundary against same-origin +code that retained a pre-wrapper function reference or directly mutates Prebid's +internal configuration. Including `identityLinkIdSystem` in a bundle is inert +until this configuration is enabled. + +### Resolution timing and data flow + +IdentityLink resolves asynchronously. A new browser's first auction can run +before RampID is available; later auctions can include it without blocking the +page or auction. When available, the opaque value follows the standard path: + +1. `pbjs.getUserIdsAsEids()` exposes an entry whose source is `liveramp.com`. +2. The current `/auction` request includes that entry. +3. Trusted Server merges and consent-gates it, then forwards it to Prebid + Server as `user.ext.eids`. +4. The browser persists the same opaque value in the bounded `ts-eids` cookie. +5. A later request can ingest it into an EC/KV partner configured with + `source_domain = "liveramp.com"`. + +Trusted Server treats the RampID envelope as an opaque string. Do not log, +decode, publish, or dimension metrics by the value. Source names, counts, +booleans, and status codes are sufficient for diagnostics. + +### Browser network and storage footprint + +With the default `not_use_3p = false`, the IdentityLink submodule performs +third-party recognition from the browser. Operators should plan for this before +enabling the subsection: + +| Effect | Detail | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Outbound request | A credentialed `GET` from the page to LiveRamp's envelope endpoint (`api.rlcdn.com`). Trusted Server does not proxy it. | +| Content Security Policy | Publishers running a strict CSP must allow that host in `connect-src`, or recognition fails silently. | +| Browser storage | `idl_env` plus IdentityLink's bookkeeping entries (`idl_env_cst`, `idl_env_last`, `_lr_retry_request`, `_lr_env_src_ats`). | +| Recognition opt-out | `not_use_3p = true` suppresses the third-party request. RampID then resolves only where an authenticated envelope is already available on the page. | + +Because the request leaves the browser directly rather than through the edge, +this integration is not a first-party replacement for LiveRamp recognition; it +configures Prebid's client-side submodule on the operator's behalf. Server-side +resolution is tracked separately (see the design document's out-of-scope +section). + +If the publisher's page already loads LiveRamp's ATS library, the submodule +prefers `window.ats.retrieveEnvelope` over the third-party endpoint. That is +the submodule's own behavior — Trusted Server neither loads ATS nor calls a +server-to-server ATS API. + +### Degraded behavior + +| Condition | Result | +| -------------------------------------------------- | ----------------------------------------------------------------------- | +| TCF Purpose 1 or LiveRamp vendor consent is denied | Default `tcfControl` blocks IdentityLink resolution and storage | +| TCF Purpose 3 or 4 alone is denied | Resolution/storage continues under defaults; publisher rules may differ | +| The user opts out under a US state signal | No LiveRamp EID is forwarded; the auction continues | +| LiveRamp cannot recognize the browser | IdentityLink yields no EID; the auction continues | +| LiveRamp network resolution fails | The current auction continues without RampID | +| `identityLinkIdSystem` is missing from the bundle | Existing diagnostics report the missing module; auctions continue | +| The origin is not approved by LiveRamp | Resolution yields no usable EID; the auction continues | +| EC/KV is unavailable | A current-request EID can still reach `/auction`; persistence degrades | + +### Credential-based validation + +Live validation must run outside CI on a LiveRamp-approved non-production +origin. Never commit a live Placement ID or envelope. Record only the approved +domain, booleans, source names, counts, and status codes: + +1. Build a bundle containing `identityLinkIdSystem` and configure the test + Placement ID. +2. With positive consent, confirm `idl_env` is created or refreshed. +3. Confirm `pbjs.getUserIdsAsEids()` reports source `liveramp.com` without + recording its value. +4. Confirm a controlled Prebid Server request contains that source in + `user.ext.eids`. +5. Confirm a later request ingests the source into the configured + `liveramp.com` EC partner. +6. Repeat with denied consent and confirm the envelope endpoint is not called, + `idl_env` is not written, and no LiveRamp EID is forwarded. +7. Repeat on an unapproved origin and confirm identity resolution degrades + without blocking the auction. + +This integration forwards RampID identity envelopes through the Prebid auction +path. LiveRamp ATS Direct audience segments, including `_lr_atsDirect` storage +and GAM or Prebid segment activation, require a separate integration and are +not passed by this implementation. + ## Identity Forwarding Trusted Server uses a **hybrid EID forwarding model** for Prebid-routed auctions: diff --git a/docs/superpowers/plans/2026-08-21-liveramp-integration.md b/docs/superpowers/plans/2026-08-21-liveramp-integration.md new file mode 100644 index 000000000..c56887a81 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-liveramp-integration.md @@ -0,0 +1,1188 @@ +# LiveRamp Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Trusted Server's existing Prebid RampID EID path first-class by adding validated operator configuration, deterministic `identityLink` setup, bundle diagnostics, tests, and documentation. + +**Architecture:** Add an optional typed `liveramp` subsection to `PrebidIntegrationConfig` and serialize it into the existing `window.__tsjs_prebid` bootstrap. The TSJS Prebid shim installs idempotent `pbjs.setConfig` and `pbjs.mergeConfig` normalizers before `processQueue()`, synchronously merges the operator-owned `identityLink` entry with effective publisher User ID entries, and preserves all unrelated configuration. Existing `/auction`, OpenRTB `user.ext.eids`, `ts-eids`, consent, and EC/KV paths remain unchanged. + +**Tech Stack:** Rust 2024, Serde, validator, TypeScript, Prebid.js 10, Vitest, JSDOM, Vite, VitePress/Markdown, Cargo workspace aliases. + +**Specification:** `docs/superpowers/specs/2026-08-21-liveramp-integration-design.md` + +--- + +## File structure + +| File | Responsibility | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Define and validate LiveRamp operator settings; inject the browser-safe camel-cased config; host Rust unit tests. | +| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Validate the injected shape, install the managed `identityLink` configuration guard, and preserve publisher User ID settings. | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Prove initial, queued, and late Prebid configuration behavior plus diagnostics and EID transport. | +| `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` | Characterize the existing `liveramp.com` → `identityLinkIdSystem` registry mapping. | +| `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` | Prove an external bundle can include and manifest `identityLinkIdSystem`. | +| `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` | Exercise the real generated Prebid bundle and served shim together with LiveRamp config. | +| `trusted-server.example.toml` | Show safe, commented operator configuration. | +| `docs/guide/integrations/prebid.md` | Explain LiveRamp prerequisites, configuration, lifecycle, degraded behavior, and verification. | +| `docs/guide/configuration.md` | Add the typed settings reference. | + +No new integration module, route, storage schema, cookie format, or upstream HTTP client is created. + +## Task 1: Add typed Rust configuration and head injection + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs:202` +- Test: `crates/trusted-server-core/src/integrations/prebid.rs:3028` +- Test: `crates/trusted-server-core/src/integrations/prebid.rs:4010` + +- [ ] **Step 1: Write failing configuration tests** + +Add focused tests beside the existing Prebid TOML parsing tests: + +```rust +#[test] +fn liveramp_config_parses_with_documented_defaults() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "999" +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert_eq!(liveramp.placement_id, "999", "should preserve placement ID"); + assert!(!liveramp.not_use_3p, "should allow cookie recognition by default"); + assert_eq!( + liveramp.storage_type, + PrebidLiveRampStorageType::Cookie, + "should default to cookie storage" + ); + assert_eq!(liveramp.expires_days, 15, "should default to conservative expiry"); + assert_eq!( + liveramp.refresh_in_seconds, 1800, + "should default to LiveRamp's recommended refresh" + ); +} + +#[test] +fn liveramp_config_accepts_explicit_supported_values() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +server_url = "https://prebid.example/openrtb2/auction" + +[integrations.prebid.liveramp] +placement_id = "12345" +not_use_3p = true +storage_type = "html5" +expires_days = 30 +refresh_in_seconds = 3600 +"#, + ); + + let liveramp = config.liveramp.expect("should parse LiveRamp config"); + assert!(liveramp.not_use_3p, "should preserve not_use_3p"); + assert_eq!(liveramp.storage_type, PrebidLiveRampStorageType::Html5); + assert_eq!(liveramp.expires_days, 30); + assert_eq!(liveramp.refresh_in_seconds, 3600); +} +``` + +Add table-driven rejection coverage using `parse_prebid_toml_result` for: + +- an otherwise valid `[integrations.prebid.liveramp]` subsection with + `placement_id` entirely absent; +- empty, whitespace-padded, and nonnumeric `placement_id`; +- `expires_days = 0` and `expires_days = 31`; +- `refresh_in_seconds = 0`; +- unknown `storage_type`; +- unknown fields within `[integrations.prebid.liveramp]`. + +Also assert that omitting the subsection leaves `config.liveramp == None`. + +- [ ] **Step 2: Run the focused Rust tests and verify they fail** + +Run: + +```bash +cargo test-fastly liveramp_config +``` + +Expected: compilation/test failure because `PrebidLiveRampConfig`, +`PrebidLiveRampStorageType`, and `PrebidIntegrationConfig::liveramp` do not yet +exist. + +- [ ] **Step 3: Implement the minimal typed settings** + +Add near `PrebidIntegrationConfig`: + +```rust +const fn default_liveramp_expires_days() -> u16 { + 15 +} + +const fn default_liveramp_refresh_in_seconds() -> u32 { + 1800 +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PrebidLiveRampStorageType { + #[default] + Cookie, + Html5, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PrebidLiveRampConfig { + #[validate(custom(function = "validate_liveramp_placement_id"))] + pub placement_id: String, + #[serde(default)] + pub not_use_3p: bool, + #[serde(default)] + pub storage_type: PrebidLiveRampStorageType, + #[serde(default = "default_liveramp_expires_days")] + #[validate(range(min = 1, max = 30))] + pub expires_days: u16, + #[serde(default = "default_liveramp_refresh_in_seconds")] + #[validate(range(min = 1))] + pub refresh_in_seconds: u32, +} +``` + +Implement `validate_liveramp_placement_id` using a `ValidationError` with a +stable message. Accept only a non-empty, already-trimmed ASCII-digit string. + +Add to `PrebidIntegrationConfig`: + +```rust +#[serde(default)] +#[validate(nested)] +pub liveramp: Option, +``` + +Update every direct `PrebidIntegrationConfig` initializer, especially +`base_config()`, with `liveramp: None`. + +- [ ] **Step 4: Run the focused configuration tests and verify they pass** + +Run: + +```bash +cargo test-fastly liveramp_config +``` + +Expected: all LiveRamp parsing/default/validation tests pass. + +- [ ] **Step 5: Write failing head-injection tests** + +Add tests beside the current head-injector tests: + +```rust +#[test] +fn head_injector_includes_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "999".to_string(), + not_use_3p: true, + storage_type: PrebidLiveRampStorageType::Html5, + expires_days: 30, + refresh_in_seconds: 3600, + }); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let script = &integration.head_inserts(&ctx)[0]; + assert!( + script.contains( + r#""liveRamp":{"placementId":"999","notUse3P":true,"storageType":"html5","expiresDays":30,"refreshInSeconds":3600}"# + ), + "should inject camel-cased LiveRamp config: {script}" + ); +} + +#[test] +fn head_injector_omits_liveramp_config_when_absent() { + // Build the normal context with base_config(). + // Assert the first insert does not contain `liveRamp`. +} + +#[test] +fn head_injector_escapes_script_breakout_in_liveramp_config() { + let mut config = base_config(); + config.liveramp = Some(PrebidLiveRampConfig { + placement_id: "1".to_string(), + ..valid_liveramp_config() + }); + + // Build the normal context. Assert the injected payload contains + // `1<\/script>")` + // has count 1: only the insert's legitimate outer closing tag remains. + // This test may build the invalid value directly because it exercises the + // serializer's defense in depth rather than TOML validation. +} +``` + +- [ ] **Step 6: Run both head-injection tests and verify they fail** + +Run: + +```bash +cargo test-fastly head_injector_includes_liveramp_config +cargo test-fastly head_injector_escapes_script_breakout_in_liveramp_config +``` + +Expected: both fail because the injected payload has no `liveRamp` property or +escaped LiveRamp Placement ID. + +- [ ] **Step 7: Inject a browser-specific serialization shape** + +Inside `IntegrationHeadInjector::head_inserts`, define a borrowed injected +shape so TOML remains snake_case while browser JSON is camelCase: + +```rust +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InjectedPrebidLiveRampConfig<'a> { + placement_id: &'a str, + not_use_3p: bool, + storage_type: PrebidLiveRampStorageType, + expires_days: u16, + refresh_in_seconds: u32, +} +``` + +Add a skipped-when-absent `live_ramp` field to +`InjectedPrebidClientConfig`, map `self.config.liveramp.as_ref()` into the +borrowed shape, and retain the existing ` { + const spec = getAdapterSpec() + mockGetUserIdsAsEids.mockReturnValue([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]) + + const request = spec.buildRequests([ + { + adUnitCode: 'div-gpt-1', + bidId: 'bid-1', + sizes: [[300, 250]], + bidder: 'trustedServer', + params: {}, + }, + ]) + + expect(JSON.parse(request.data).eids).toEqual([ + { + source: 'liveramp.com', + uids: [{ id: 'opaque-test-envelope', atype: 3 }], + }, + ]) +}) +``` + +Add a logging assertion using a sentinel envelope and spies on `log.debug`, +`log.info`, `log.warn`, and `log.error`; no logged argument may contain the +sentinel. + +- [ ] **Step 4: Write the failing real-artifact test before implementation** + +Change the bundle built in `prebid-artifact-integration.test.mjs` to include +both `sharedIdSystem` and `identityLinkIdSystem`. Inject `liveRamp` before +evaluating the served shim, then assert after shim evaluation: + +```javascript +const configuredUserIds = pageWindow.pbjs.getConfig('userSync.userIds') +expect(configuredUserIds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + storage: expect.objectContaining({ name: 'idl_env' }), + }), + ]) +) +``` + +Retain the current real `/auction` request assertion. Network remains stubbed; +the test must not contact LiveRamp. + +After the initial managed-entry assertion, call the real public merge API and +prove it cannot append a publisher-owned duplicate: + +```javascript +pageWindow.pbjs.mergeConfig({ + userSync: { + userIds: [ + { name: 'sharedId' }, + { name: 'identityLink', params: { pid: 'publisher-value' } }, + ], + }, +}) + +const mergedUserIds = pageWindow.pbjs.getConfig('userSync.userIds') +expect(mergedUserIds.filter(({ name }) => name === 'identityLink')).toEqual([ + expect.objectContaining({ + name: 'identityLink', + params: { pid: '999', notUse3P: false }, + }), +]) +expect(mergedUserIds).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'sharedId' })]) +) +``` + +- [ ] **Step 5: Run the focused unit and artifact suites and verify failures** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts +npx vitest run test/prebid-artifact-integration.test.mjs +``` + +Expected: LiveRamp ownership tests fail because the injected shape and public +configuration normalizers do not exist, and the artifact test fails because no +managed entry is installed. The transport-only characterizations may already +pass; retain them as evidence of the pre-existing path. + +- [ ] **Step 6: Add the injected TypeScript types and constants** + +Add: + +```typescript +interface InjectedLiveRampConfig { + placementId: string + notUse3P: boolean + storageType: 'cookie' | 'html5' + expiresDays: number + refreshInSeconds: number +} + +interface InjectedPrebidConfig { + // Existing fields omitted. + liveRamp?: InjectedLiveRampConfig +} + +const IDENTITY_LINK_CONFIG_NAME = 'identityLink' +const IDENTITY_LINK_STORAGE_NAME = 'idl_env' +const LIVE_RAMP_SET_CONFIG_SENTINEL = '__tsLiveRampSetConfigInstalled' +``` + +Keep this internal to the Prebid module; do not add a new global API. + +- [ ] **Step 7: Extract reusable User ID list parsing** + +Refactor the shape handling currently embedded in +`configuredUserIdNamesFromConfig` into a helper that returns validated entry +objects from any of these inputs: + +- the direct array returned by `getConfig('userSync.userIds')`; +- `{ userSync: { userIds: [...] } }`; +- `{ userIds: [...] }`. + +Use explicit record and entry guards. A valid entry is a non-array object with +a non-empty string `name`; filter malformed members rather than forwarding +them. The parser returns an empty array for malformed containers. Keep a +separate `hasUserIdsPath(config)` predicate equivalent to: + +```typescript +function hasUserIdsPath(config: unknown): config is { + userSync: Record & { userIds: unknown } +} { + return ( + isRecord(config) && + isRecord(config.userSync) && + Object.prototype.hasOwnProperty.call(config.userSync, 'userIds') + ) +} +``` + +This distinction is required: an absent path passes through unchanged, while +an explicitly empty `userIds: []` is normalized to the managed entry. Update +`configuredUserIdNamesFromConfig` to derive names from that helper so +diagnostics and LiveRamp normalization agree on supported shapes. + +- [ ] **Step 8: Implement the managed entry and normalizer** + +Implement small focused helpers equivalent to: + +```typescript +function liveRampUserId( + config: InjectedLiveRampConfig +): Record { + return { + name: IDENTITY_LINK_CONFIG_NAME, + params: { pid: config.placementId, notUse3P: config.notUse3P }, + storage: { + type: config.storageType, + name: IDENTITY_LINK_STORAGE_NAME, + expires: config.expiresDays, + refreshInSeconds: config.refreshInSeconds, + }, + } +} + +function withManagedLiveRampUserId( + config: Record, + managedEntry: Record +): Record { + if (!hasUserIdsPath(config)) return config + + const retained = configuredUserIdEntries(config.userSync.userIds).filter( + (entry) => entry.name !== IDENTITY_LINK_CONFIG_NAME + ) + return { + ...config, + userSync: { + ...config.userSync, + userIds: [...retained, managedEntry], + }, + } +} +``` + +`configuredUserIdEntries` must support the three shapes from Step 7 and return +fresh arrays. The spread operations preserve top-level properties and sibling +`userSync` properties. Do not mutate publisher-owned arrays or objects in +place. + +- [ ] **Step 9: Install idempotent public configuration guards before queue processing** + +In `installPrebidNpm`, after confirming the real Prebid API and before the +existing base configuration and `processQueue()` call: + +1. If injected `liveRamp` is absent, do nothing. +2. Capture and bind the current `pbjs.setConfig` and optional + `pbjs.mergeConfig`. +3. Replace both public APIs with wrappers that share one normalizer for calls + containing `userSync.userIds`; pass all other calls through unchanged. +4. Mark the Prebid object with the sentinel so installation cannot stack. +5. Read effective User ID entries through `pbjs.getConfig`. +6. Call the wrapper synchronously with the effective list, producing one + managed entry before any queued auction. +7. Leave both wrappers installed across `processQueue()` and later calls. + +Use logic equivalent to: + +```typescript +const managedPbjs = pbjs as typeof pbjs & Record +if (managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] !== true) { + const originalSetConfig = pbjs.setConfig.bind(pbjs) + const originalMergeConfig = pbjs.mergeConfig?.bind(pbjs) + const managedEntry = liveRampUserId(config.liveRamp) + + const normalizePublisherConfig = (publisherConfig) => { + let nextConfig = publisherConfig + try { + if (hasUserIdsPath(publisherConfig)) { + nextConfig = withManagedLiveRampUserId(publisherConfig, managedEntry) + } + } catch { + log.error('Prebid LiveRamp configuration could not be normalized') + } + return nextConfig + } + + pbjs.setConfig = (publisherConfig) => + originalSetConfig(normalizePublisherConfig(publisherConfig)) + if (originalMergeConfig) { + pbjs.mergeConfig = (publisherConfig) => + originalMergeConfig(normalizePublisherConfig(publisherConfig)) + } + managedPbjs[LIVE_RAMP_SET_CONFIG_SENTINEL] = true + + const effective = configuredUserIdEntries(pbjs.getConfig('userSync.userIds')) + pbjs.setConfig({ userSync: { userIds: effective } }) +} +``` + +Adapt the callback and return types to the repository's actual `pbjs` typing. +Each original method is invoked exactly once, its return value is preserved, +and normalization errors never log values. The sentinel lives on `pbjs`, not +on the page-level shim state: a test must deliberately reset only +`__tsjsPrebidShimInstalled`, reinstall, and prove both wrapper references are +unchanged and publisher calls are normalized once. + +- [ ] **Step 10: Run focused unit and artifact tests and make them pass** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts +npx vitest run test/prebid-artifact-integration.test.mjs +``` + +Expected: unit tests pass; the real bundle advertises both User ID modules, the +shim configures one `identityLink` entry, a real `mergeConfig` call retains +exactly that managed entry, and the controlled auction still reaches +`/auction`. + +- [ ] **Step 11: Format, lint, and commit Task 2** + +Run: + +```bash +cd crates/trusted-server-js/lib +npm run format +npm run lint +git add src/integrations/prebid/index.ts test/integrations/prebid/index.test.ts test/prebid-artifact-integration.test.mjs +git commit -m "feat: manage LiveRamp identityLink configuration" +``` + +## Task 3: Lock bundle, transport, consent, and EC behavior with regression tests + +**Files:** + +- Test: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` +- Test: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` +- Test: `crates/trusted-server-core/src/auction/endpoints.rs` +- Test: `crates/trusted-server-core/src/consent/mod.rs` +- Test: `crates/trusted-server-core/src/ec/prebid_eids.rs` + +- [ ] **Step 1: Add the explicit registry mapping test** + +```typescript +it('maps LiveRamp EIDs to identityLinkIdSystem', () => { + expect( + resolvePrebidUserIdModulesFromEids([ + { source: 'liveramp.com', uids: [{ id: 'opaque-envelope', atype: 3 }] }, + ]) + ).toEqual({ + modules: ['userId', 'identityLinkIdSystem'], + missingSources: [], + }) +}) +``` + +Also load the checked-in registry JSON or expose a narrow helper and assert the +default preset contains `identityLinkIdSystem`. Do not duplicate the registry +as a second production constant. + +- [ ] **Step 2: Add a bundle manifest test for `identityLinkIdSystem`** + +Extend the existing `includes generated User ID metadata` case or add a focused +case that invokes: + +```javascript +await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'identityLinkIdSystem', + '--out', + outputDirectory, +]) +``` + +Assert the manifest's `userIdModules` is exactly +`['identityLinkIdSystem']` and the generated bundle contains the module name. + +- [ ] **Step 3: Run the two characterization suites** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run \ + test/integrations/prebid/user_id_modules.test.ts \ + test/build-prebid-external.test.mjs +``` + +Expected: tests pass using the existing registry and generator. If they fail, +fix the single checked-in registry/generator source rather than introducing a +LiveRamp-only bundle path. + +- [ ] **Step 4: Add or rename LiveRamp-specific Rust regression fixtures** + +Add focused tests (or rename/extend an existing generic fixture while keeping +its broader assertions) proving: + +- in `auction/endpoints.rs`, a client `liveramp.com` UID equal to the resolved + KV UID is merged once, with server-resolved metadata winning on conflict; +- in `consent/mod.rs`, a `liveramp.com` EID is removed when consent denies + identity forwarding; +- in `ec/prebid_eids.rs`, a structured `ts-eids` cookie containing an opaque + `liveramp.com` envelope writes that exact opaque string once to a registry + partner whose source domain is `liveramp.com`. + +Use only synthetic values such as `opaque-test-envelope`. Assert that tests do +not decode or inspect an envelope's contents. + +- [ ] **Step 5: Run the LiveRamp Rust regression fixtures** + +Run from the repository root: + +```bash +cargo test-fastly liveramp +``` + +Expected: forwarding, merge/deduplication, consent removal, and later-request +EC ingestion fixtures all pass. + +- [ ] **Step 6: Commit Task 3** + +Run: + +```bash +git add crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts \ + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ + crates/trusted-server-core/src/auction/endpoints.rs \ + crates/trusted-server-core/src/consent/mod.rs \ + crates/trusted-server-core/src/ec/prebid_eids.rs +git commit -m "test: cover LiveRamp Prebid bundle support" +``` + +## Task 4: Document configuration, lifecycle, and operational validation + +**Files:** + +- Modify: `trusted-server.example.toml:41` +- Modify: `docs/guide/integrations/prebid.md:50` +- Modify: `docs/guide/integrations/prebid.md:419` +- Modify: `docs/guide/configuration.md:1050` + +- [ ] **Step 1: Add the commented example configuration** + +Add beneath the Prebid bundle configuration in `trusted-server.example.toml`: + +```toml +# Optional managed LiveRamp RampID configuration. The external Prebid bundle +# must contain identityLinkIdSystem. Obtain the Placement ID and approve the +# publisher origin with LiveRamp before enabling. +# [integrations.prebid.liveramp] +# placement_id = "999" +# not_use_3p = false +# storage_type = "cookie" +# expires_days = 15 +# refresh_in_seconds = 1800 +``` + +Do not add a real Placement ID or credential. + +- [ ] **Step 2: Update the configuration reference** + +Add `[integrations.prebid.liveramp]` fields to both Prebid option tables: + +| Field | Type | Default | Description | +| ----------------------------- | ------------------- | ------------------------------- | ------------------------------------------------------------ | +| `liveramp.placement_id` | String | Required when subsection exists | Numeric LiveRamp Placement ID for `identityLink`. | +| `liveramp.not_use_3p` | Boolean | `false` | Disable cookie-recognized RampID envelopes when true. | +| `liveramp.storage_type` | `cookie` or `html5` | `cookie` | Browser storage used by the Prebid module. | +| `liveramp.expires_days` | Integer 1–30 | `15` | Envelope storage lifetime in days. | +| `liveramp.refresh_in_seconds` | Positive integer | `1800` | Interval before retrieving a potentially refreshed envelope. | + +State that storage name `idl_env` is fixed by the integration. + +- [ ] **Step 3: Add the LiveRamp guide section** + +In `docs/guide/integrations/prebid.md`, document: + +- prerequisites: Placement ID, approved origin, CMP/LiveRamp consent posture, + and an external bundle containing `identityLinkIdSystem`; +- the exact TOML example and `ts prebid bundle` selection; +- operator ownership of the single `identityLink` entry for calls through the + supported public `pbjs.setConfig` and `pbjs.mergeConfig` APIs while preserving + other User ID modules; explicitly state that this is not a security boundary + against retained pre-wrapper references or direct internal mutation; +- asynchronous resolution: a new browser's first auction may have no RampID; +- the existing flow through `getUserIdsAsEids()`, `/auction`, + `user.ext.eids`, `ts-eids`, and EC/KV; +- degraded behavior for no consent, no recognition, missing module, LiveRamp + network failure, and KV failure; +- privacy guidance: envelopes are opaque and must not be logged; +- the explicit product boundary: this forwards RampID EIDs, not ATS Direct + audience segments; +- a credential-based manual validation checklist matching Section 11.5 of the + design spec, recording only booleans, counts, source names, and status codes. + +- [ ] **Step 4: Format and verify docs** + +Run: + +```bash +cd docs +npm run format +``` + +Expected: all documentation and TOML examples satisfy Prettier checks. + +- [ ] **Step 5: Commit Task 4** + +Run: + +```bash +git add trusted-server.example.toml docs/guide/integrations/prebid.md docs/guide/configuration.md +git commit -m "docs: explain managed LiveRamp RampID setup" +``` + +## Task 5: Run full verification and prepare live validation handoff + +**Files:** + +- Verify: all files changed in Tasks 1–4 +- Reference: `docs/superpowers/specs/2026-08-21-liveramp-integration-design.md` + +- [ ] **Step 1: Run the complete TSJS test and build gates** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run +npm run format +npm run lint +node build-all.mjs +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Run Rust formatting and adapter test gates** + +From the repository root, run: + +```bash +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +Expected: all commands exit 0. Do not substitute bare +`cargo test --workspace`. + +- [ ] **Step 3: Run all target-matched clippy gates** + +Run: + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: all commands exit 0 with warnings denied. + +- [ ] **Step 4: Re-run documentation formatting and inspect the final diff** + +Run: + +```bash +cd docs +npm run format +cd .. +git diff --check +git status --short +git diff main...HEAD --stat +``` + +Expected: formatting and diff checks pass; status contains only intentional +plan/implementation changes. + +- [ ] **Step 5: Record credential-gated validation status** + +If LiveRamp test configuration is available, execute the guide's manual +validation on an approved non-production origin and report only: + +- approved origin used (domain, not credentials); +- whether `idl_env` was created/refreshed; +- whether `getUserIdsAsEids()` exposed source `liveramp.com`; +- whether the controlled PBS request contained that source; +- whether a later request ingested the EID into the configured + `liveramp.com` EC partner; +- whether opt-out removed it; and +- whether an unapproved origin degraded to no LiveRamp EID without blocking + the auction; and +- status codes/counts without envelope values. + +If credentials remain unavailable, report exactly: “Code complete; live +LiveRamp validation pending IABTechLab/uid2-optout#385.” Do not block automated +verification or add fake live-success evidence. + +In either case, prepare the explicit parent-epic acceptance handoff: “RampID +identity envelopes traverse the existing Prebid auction path; ATS Direct +audience segments are not passed by this implementation.” + +- [ ] **Step 6: Commit any verification-only corrections** + +Only if verification required source changes, repeat the affected focused and +full gates, then commit the minimal correction: + +```bash +git add +git commit -m "fix: address LiveRamp verification findings" +``` + +Do not create an empty verification commit. + +## Correction tasks added after PR #1054 review (2026-08-24) + +These tasks implement the reviewed correction in the specification. Complete +them in order and preserve artifact-level evidence for configuration behavior. + +## Task 6: Characterize partial `userSync` updates + +**Files:** + +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs:179` + +- [x] **Step 1: Test the review premise against the generated artifact** + +Preconfigure a publisher `sharedId`, install the shim, then call: + +```js +pageWindow.pbjs.setConfig({ userSync: { syncDelay: 50 } }) +``` + +Assert that `getConfig('userSync.userIds')` still contains `sharedId` and exactly +one managed `identityLink`, and that `getConfig('userSync.syncDelay')` is `50`. +This test uses the generated Prebid bundle and generated TSJS shim, not a mock +of `setConfig`. + +- [x] **Step 2: Verify actual pinned behavior** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts test/prebid-artifact-integration.test.mjs +``` + +Observed: all focused tests pass. The pinned Prebid artifact retains its +effective `userIds` list across the partial update. Mock-only tests that expected +the shim to inject `userIds` into the forwarded argument were discarded because +the mock does not model the shipped artifact's effective configuration behavior. +No production wrapper change is required. + +- [x] **Step 3: Commit the artifact characterization** + +```bash +git add \ + crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +git commit -m "Characterize partial Prebid userSync updates" +``` + +## Task 7: Characterize exact default TCF enforcement + +**Files:** + +- Modify: `crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs:103-225` +- Modify: `docs/guide/integrations/prebid.md:134-141` +- Modify: `docs/guide/integrations/prebid.md:518-524` +- Modify: `docs/guide/integrations/prebid.md:578-588` + +- [x] **Step 1: Replace the combined consent boolean with independent grants** + +Change the fixture API to accept explicit grants with safe defaults: + +```js +function tcData({ + purpose1 = true, + purpose3 = true, + purpose4 = true, + vendor97 = true, +} = {}) { + return { + // existing CMP fields + purpose: { + consents: { 1: purpose1, 3: purpose3, 4: purpose4 }, + legitimateInterests: {}, + }, + vendor: { + consents: { [LIVE_RAMP_GVL_VENDOR_ID]: vendor97 }, + legitimateInterests: {}, + }, + } +} +``` + +Pass this object through `runGdprPage` without combining the grants. + +- [x] **Step 2: Add four independent artifact cases plus the granted baseline** + +Assert the exact pinned defaults: + +1. Purpose 1 denied alone: no LiveRamp request, no `idl_env`, no retry cookie. +2. Vendor 97 denied alone: no LiveRamp request, no `idl_env`, no retry cookie. +3. Purpose 3 denied alone: one LiveRamp request and `idl_env` written. +4. Purpose 4 denied alone: one LiveRamp request and `idl_env` written. +5. All relevant grants present: one LiveRamp request and `idl_env` written. + +- [x] **Step 3: Run the consent artifact suite** + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/prebid-consent-enforcement.test.mjs +``` + +Expected: all five behavioral cases and the module-presence assertion pass +against the real generated artifacts. If a case differs, inspect pinned Prebid +before changing the expected policy. + +- [x] **Step 4: Correct the operator-facing consent claims** + +Document that default client-side resolution/storage is blocked by Purpose 1 +and LiveRamp vendor consent. State that Purpose 3 has no standalone default +rule, Purpose 4 controls UFPD, and default EID transmission accepts qualifying +purpose/vendor basis from any Purpose 2–10 unless the publisher enables +`eidsRequireP4Consent`. Preserve the existing explicit GPP/US-state limitation. + +- [x] **Step 5: Format and verify the focused documentation** + +```bash +cd docs +npx prettier --write guide/integrations/prebid.md +npm run format +``` + +Expected: the guide is formatted and makes no broader enforcement claim than +the artifact matrix proves. + +- [x] **Step 6: Commit the consent characterization** + +```bash +git add \ + crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs \ + docs/guide/integrations/prebid.md +git commit -m "Clarify LiveRamp TCF enforcement defaults" +``` + +## Task 8: Verify and refresh PR #1054 + +**Files:** + +- Verify: all PR files +- Update externally: PR #1054 description + +- [x] **Step 1: Run TypeScript tests, build, and formatting** + +```bash +cd crates/trusted-server-js/lib +npx vitest run +node build-all.mjs +npm run lint +npm run format +``` + +Expected: every command exits 0. + +- [x] **Step 2: Run repository Rust and documentation gates** + +```bash +cd /Users/prk-jr/Desktop/opensource/rust/trusted-server +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cd docs && npm run format +``` + +Expected: all commands exit 0. Do not use bare `cargo test --workspace`. + +- [x] **Step 3: Inspect the final branch** + +```bash +git diff --check +git status --short +git diff origin/main...HEAD --stat +git log origin/main..HEAD --oneline +``` + +Expected: no uncommitted source changes and only intentional LiveRamp commits. + +- [x] **Step 4: Request final code review** + +Review the entire `origin/main...HEAD` diff with special attention to the +partial-`userSync` real-artifact case and the independent consent matrix. Fix +all Critical or Important findings and repeat affected gates. + +- [x] **Step 5: Push and update the draft PR description** + +Push without force. Create `/tmp/pr-1054-body.md` with the repository PR +template and these exact sections: + +- Summary: managed RampID configuration, opaque `liveramp.com` EID transport, + exact TCF default behavior, and ATS Direct exclusion. +- Closes: `Closes #355`. +- Status: `Code complete; live LiveRamp validation pending +IABTechLab/uid2-optout#385.` +- Changes table containing every one of these final diff paths and no removed + `crates/trusted-server-core/src/auction/endpoints.rs` row: + - `.cargo/config.toml` + - `CLAUDE.md` + - `crates/trusted-server-cli/src/prebid_bundle.rs` + - `crates/trusted-server-core/src/consent/mod.rs` + - `crates/trusted-server-core/src/ec/prebid_eids.rs` + - `crates/trusted-server-core/src/integrations/prebid.rs` + - `crates/trusted-server-js/lib/build-prebid-external.mjs` + - `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` + - `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` + - `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + - `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` + - `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` + - `crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs` + - `docs/guide/configuration.md` + - `docs/guide/integrations/prebid.md` + - `docs/superpowers/plans/2026-08-21-liveramp-integration.md` + - `docs/superpowers/specs/2026-08-21-liveramp-integration-design.md` + - `trusted-server.example.toml` +- Test plan: check every command completed in Steps 1–2; leave only live + credential validation unchecked. +- Hardening note: no config-derived regex or pattern compilation was added; + invalid enabled LiveRamp config fails typed validation. + +Verify the enumerated paths against +`git diff --name-only origin/main...HEAD` before writing the file. Apply the +body exactly with: + +```bash +git push origin issue-355-liveramp-integration +gh pr edit 1054 \ + --repo IABTechLab/trusted-server \ + --title "Add managed LiveRamp RampID integration" \ + --body-file /tmp/pr-1054-body.md +gh pr view 1054 \ + --repo IABTechLab/trusted-server \ + --json url,isDraft,headRefOid,body,statusCheckRollup +``` + +Do not mark the PR ready for review automatically; report the final readiness +assessment to the user first. diff --git a/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md new file mode 100644 index 000000000..44a5308a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-liveramp-integration-design.md @@ -0,0 +1,642 @@ +# LiveRamp Integration Design + +**Issue:** [#355 — Investigate and document LiveRamp integration](https://github.com/IABTechLab/trusted-server/issues/355) + +**Parent epic:** [#354 — LiveRamp integration](https://github.com/IABTechLab/trusted-server/issues/354) + +**Initiative:** [#55 — Monetization integrations](https://github.com/IABTechLab/trusted-server/issues/55) + +**Status:** Proposed + +**Date:** 2026-08-21 + +## 1. Executive summary + +LiveRamp integration is feasible in two distinct forms, but they must not be +treated as one protocol: + +1. **RampID identity-envelope forwarding through Prebid.js is feasible now.** + Trusted Server already bundles Prebid's `identityLinkIdSystem`, reads + `liveramp.com` EIDs through `pbjs.getUserIdsAsEids()`, sends them to + `/auction`, merges them with EC/KV identities, applies consent gating, and + forwards them to Prebid Server as OpenRTB `user.ext.eids`. +2. **LiveRamp ATS Direct audience segments are not part of that EID flow.** ATS + Direct returns a separate segment envelope and has separate subscription, + storage, TTL, deal-approval, and activation requirements. The cited + LiveRamp documentation describes activating these values as GAM `atsd` + targeting, not as a `liveramp.com` EID. + +The first implementation should make the existing RampID path operationally +complete by adding typed LiveRamp configuration under the Prebid integration, +injecting a deterministic `identityLink` User ID configuration, validating the +generated bundle, and documenting a live verification procedure. Native +server-to-server ATS resolution and ATS Direct segment activation remain +separate follow-up decisions. + +## 2. Issue hierarchy and collected requirements + +The GitHub issue hierarchy is: + +```text +#55 Initiative: Monetization integrations +└── #354 Epic: LiveRamp integration + └── #355 Task: Investigate and document LiveRamp integration + └── IABTechLab/uid2-optout#385: Get test credentials from LR team +``` + +The three trusted-server issues have empty or placeholder bodies, so their +comments and linked documentation define the operative requirements. + +### 2.1 Issue #354 + +The only comment asks the team to confirm whether LiveRamp segments are passed +to auction requests through the Prebid.js integration. This specification must +therefore distinguish identity envelopes from segment data and answer both +questions explicitly. + +### 2.2 Issue #355 + +The comments establish the following sequence and requirements: + +1. Review LiveRamp's Real-Time Identity Service (RTIS) tag documentation. +2. Wait for LiveRamp to clarify the integration. +3. Test the documentation LiveRamp supplied. +4. Review the ATS Envelope API page LiveRamp recommended. +5. Write a specification and determine feasibility. + +The issue's direct deliverable is an evidence-backed specification. If the +recommended path is feasible, implementation follows the approved design. + +### 2.3 Credential dependency + +Issue #355 has a cross-repository child, +[IABTechLab/uid2-optout#385](https://github.com/IABTechLab/uid2-optout/issues/385), +named “Get test credentials from LR team.” It remains open. Its only comment +says that LiveRamp would be emailed. + +Automated tests must not depend on LiveRamp credentials. A live Placement ID +and a LiveRamp-approved test origin are nevertheless required for final +end-to-end verification against LiveRamp. + +## 3. Terminology and product boundaries + +### 3.1 RampID identity envelope + +Prebid's LiveRamp module is named `identityLinkIdSystem`, its configuration name +is `identityLink`, and its EID source is `liveramp.com`. It resolves an encrypted +RampID envelope into Prebid's identity APIs. The envelope identifies a user to +authorized demand partners; Trusted Server treats the value as opaque. + +### 3.2 RTIS + +LiveRamp's Real-Time Identity Service tag is a pixel or JavaScript tag that uses +LiveRamp cookie recognition and redirects a RampID to an endpoint registered +with LiveRamp. It requires LiveRamp to configure a tag ID and callback endpoint. +Trusted Server has no RTIS callback route today. + +RTIS is not selected for the first implementation because the managed Prebid +module already provides the browser-to-bidstream path, while a new callback +would require correlation, endpoint authentication, storage, abuse protection, +and a LiveRamp-specific server contract. + +### 3.3 ATS Envelope API + +The ATS Envelope API resolves hashed email, hashed phone, or configured custom +IDs into one or more encrypted envelopes. A server-to-server call requires a +Placement ID, a privacy-approved Origin, consent parameters where applicable, +and the browser's client IP in `X-Forwarded-For`. + +The ordinary ATS response contains an identity envelope with `type: 19` and +`source: "envelopeLiveramp"`. A no-consent response is HTTP 204. Configuration, +authorization, service, and geographic/consent failures use distinct 4xx +statuses. + +### 3.4 ATS Direct segments + +ATS Direct is a separate product layered onto an approved ATS placement and +subscription. Its V2 response can include `type: 26`, `source: "atsDirect"`, +whose value represents matching deal/segment IDs. LiveRamp documents storing +this in `_lr_atsDirect`, maintaining a region-dependent TTL, refreshing it, and +applying selected deal IDs to GAM under the `atsd` targeting key. + +An ATS Direct segment envelope is not a RampID and must not be placed in +`user.ext.eids` under `liveramp.com`. + +## 4. Current Trusted Server capabilities + +The following capabilities already exist on `main`: + +- `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` + includes `identityLinkIdSystem` in the default preset, maps the Prebid config + name `identityLink`, and maps EID source `liveramp.com`. +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` reads + `pbjs.getUserIdsAsEids()`, validates EID structure, and includes valid EIDs in + the current `/auction` request. +- The same TSJS module persists structured OpenRTB-style EIDs in the first-party + `ts-eids` cookie after auction completion. +- `crates/trusted-server-core/src/auction/endpoints.rs` parses current-request + EIDs, loads server-resolved EIDs from the EC/KV graph, merges and deduplicates + them, and applies centralized consent gating. +- `crates/trusted-server-core/src/integrations/prebid.rs` serializes the merged + set to Prebid Server as OpenRTB `user.ext.eids`. +- `crates/trusted-server-core/src/ec/prebid_eids.rs` ingests `ts-eids` on a + later request and maps configured sources such as `liveramp.com` into the + EC/KV identity graph. +- The external bundle manifest and runtime diagnostics already identify which + Prebid User ID modules were compiled into the bundle. + +### 4.1 Current gap + +Trusted Server includes the module but does not configure it. There is no typed +operator setting for LiveRamp's Placement ID or the module's storage behavior. +The integration only works when publisher JavaScript independently queues the +correct `pbjs.setConfig({ userSync: { userIds: [...] } })` call. + +That is not a complete managed integration: configuration can be lost when the +publisher's Prebid asset is intercepted, deployments cannot validate it, and +operators cannot audit it alongside the generated bundle manifest. + +## 5. Approaches considered + +### 5.1 Selected: typed LiveRamp configuration within Prebid + +Add an optional LiveRamp subsection to `PrebidIntegrationConfig`, inject it +through `window.__tsjs_prebid`, and let the TSJS Prebid shim install an +operator-owned `identityLinkIdSystem` configuration before queued Prebid work +is processed. + +Benefits: + +- Uses the existing module, bundle generator, EID transport, consent gate, and + EC/KV ingestion path. +- Keeps browser identity configuration beside the Prebid bundle that consumes + it. +- Adds no new upstream route or PII-bearing server API. +- Can be fully tested without external credentials, with a separate live + verification gate. + +Trade-off: this only resolves identities visible to the browser module; it does +not add server-side HEM resolution or ATS Direct segments. + +### 5.2 Rejected for the first implementation: standalone LiveRamp integration + +A new `integrations/liveramp` module could own browser and server APIs. This is +premature because only the Prebid browser path is approved, while the ATS API +input contract and ATS Direct product scope remain unresolved. It would also +duplicate Prebid lifecycle and bundle validation responsibilities. + +Revisit this boundary if a future approved design adds server-to-server ATS +resolution or a non-Prebid LiveRamp consumer. + +### 5.3 Rejected: RTIS callback endpoint + +An RTIS endpoint would introduce a new unauthenticated redirect/callback +surface and a correlation problem without improving the already-supported +Prebid identity path. LiveRamp also requires per-endpoint configuration. It is +not justified for the current requirement. + +### 5.4 Deferred: native server-to-server ATS resolution + +Native resolution is technically possible with Trusted Server's platform HTTP +abstractions, consent context, geo context, and client IP access. It is not +implementation-ready because: + +- Trusted Server has no approved source for hashed email, hashed phone, or a + LiveRamp custom ID. +- Sending a hashed identifier to LiveRamp is a privacy and publisher-contract + decision, not merely a transport detail. +- LiveRamp credentials/configuration and an approved Origin are unavailable. +- Rate limits, timeout policy, caching, envelope refresh, and identifier + deletion semantics are not confirmed. +- [#630 — HEM Resolution (LiveRamp)](https://github.com/IABTechLab/trusted-server/issues/630) + was closed as not planned and must not be silently revived. + +## 6. Proposed configuration + +LiveRamp configuration is optional and nested under the existing Prebid +integration: + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" +external_bundle_sha256 = "" +external_bundle_sri = "sha256-" + +[integrations.prebid.liveramp] +placement_id = "999" +not_use_3p = false +storage_type = "cookie" +expires_days = 15 +refresh_in_seconds = 1800 +``` + +The Rust representation is an optional field on `PrebidIntegrationConfig`: + +```rust +pub struct PrebidIntegrationConfig { + // Existing fields omitted. + pub liveramp: Option, +} + +pub struct PrebidLiveRampConfig { + pub placement_id: String, + pub not_use_3p: bool, + pub storage_type: PrebidLiveRampStorageType, + pub expires_days: u16, + pub refresh_in_seconds: u32, +} + +pub enum PrebidLiveRampStorageType { + Cookie, + Html5, +} +``` + +Defaults: + +| Field | Default | Reason | +| -------------------- | -------- | ---------------------------------------------------------------------------------------------------------- | +| `not_use_3p` | `false` | Matches the documented Prebid/LiveRamp example and permits RTIS until authentication replaces it with ATS. | +| `storage_type` | `cookie` | Provides first-party browser persistence and matches the documented example. | +| `expires_days` | `15` | LiveRamp's conservative recommendation for GDPR/CCPA traffic. | +| `refresh_in_seconds` | `1800` | LiveRamp recommends refreshing rotating encrypted envelopes every 30 minutes. | + +`placement_id` is required when the subsection exists. It must be non-empty, +trimmed, and contain only ASCII digits. `expires_days` must be from 1 through 30. `refresh_in_seconds` must be non-zero. The storage name is deliberately not +configurable: the LiveRamp/Prebid contract requires `idl_env`. + +Omitting `[integrations.prebid.liveramp]` preserves current behavior and emits +no LiveRamp configuration. + +## 7. Browser configuration and ordering + +The Rust Prebid head injector extends `window.__tsjs_prebid` with a camel-cased +`liveRamp` object containing the validated values. The Placement ID is an +operator identifier rather than a secret, but diagnostics must not copy +envelope values. + +The TSJS Prebid shim translates the injected object into: + +```javascript +{ + userSync: { + userIds: [ + { + name: 'identityLink', + params: { + pid: '999', + notUse3P: false, + }, + storage: { + type: 'cookie', + name: 'idl_env', + expires: 15, + refreshInSeconds: 1800, + }, + }, + ] + } +} +``` + +Publisher commands may already be waiting in `window.pbjs.que`, including a +`requestBids` command. Appending the managed configuration would be too late: +Prebid processes existing commands in insertion order, so a publisher auction +could run before the new entry. + +When LiveRamp is configured, the shim instead installs narrowly scoped, +idempotent wrappers around the public `pbjs.setConfig` and `pbjs.mergeConfig` +APIs before calling `pbjs.processQueue()`: + +1. Capture and bind the real `pbjs.setConfig` and, when present, + `pbjs.mergeConfig` implementations. +2. Replace both public APIs with wrappers that normalize every call containing + a `userSync` object. Calls without `userSync` pass through unchanged. +3. For a call with an explicit `userSync.userIds`, preserve every + non-`identityLink` entry, + remove all publisher-supplied `identityLink` entries, and append exactly one + operator-managed entry. Preserve sibling `userSync` and top-level fields. +4. Calls whose `userSync` object omits `userIds` pass through unchanged. The + pinned generated Prebid artifact retains its effective `userIds` defaults + across partial `setConfig` and `mergeConfig` updates, so injecting a copied + list in the shim would duplicate Prebid behavior and make the wrapper depend + on a mocked configuration model that does not match the shipped artifact. + A real-artifact characterization test protects this pinned behavior. +5. During initial installation, read the already-effective + `pbjs.getConfig('userSync.userIds')` value, + normalize its supported array/config shape, preserve its non-`identityLink` + entries, append the managed entry, and apply that merged list synchronously + through the captured function. This covers publisher configuration that ran + after the external Prebid bundle loaded but before the deferred TSJS shim. + An absent or malformed effective list degrades to an empty publisher list. + Complete this step before processing any existing queue entries. +6. Call `pbjs.processQueue()`. Queued publisher `setConfig` and `mergeConfig` + calls flow through the wrappers, so a later queued `requestBids` observes + the managed entry. +7. Keep the wrappers installed after queue processing so later publisher calls + through either public configuration API cannot silently replace or delete + the operator-owned LiveRamp policy. Repeated TSJS installation must not + stack wrappers. + +This is configuration ownership for supported Prebid API usage, not a security +boundary against adversarial same-origin JavaScript that retained an earlier +function reference or mutates internal configuration objects directly. + +This policy gives the operator ownership of the single `identityLink` entry +when `[integrations.prebid.liveramp]` exists. Publishers retain ownership of all +other Prebid and User ID configuration. Omitting the subsection installs no +wrapper and preserves current publisher behavior exactly. + +After queue processing, the existing bundle diagnostics confirm that +`identityLinkIdSystem` is present in the external bundle. A missing module is a +configuration error surfaced through existing TSJS diagnostics and logging; +the auction itself continues without a LiveRamp EID. + +## 8. Data flow + +```mermaid +sequenceDiagram + participant O as Operator config + participant TS as Trusted Server + participant B as Browser + participant LR as LiveRamp + participant PBS as Prebid Server + participant KV as EC identity graph + + O->>TS: Configure integrations.prebid.liveramp + TS-->>B: Inject liveRamp config and managed Prebid bundle + B->>B: Guard setConfig/mergeConfig and synchronously merge identityLink + B->>LR: Prebid identityLink module resolves/refreshes envelope + LR-->>B: Opaque RampID envelope + B->>B: pbjs.getUserIdsAsEids() + B->>TS: POST /auction with source=liveramp.com EID + TS->>TS: Validate, merge, deduplicate, consent-gate EIDs + TS->>PBS: OpenRTB user.ext.eids + B->>B: Persist structured EIDs in ts-eids after auction + B->>TS: Later request with ts-eids + ts-ec + TS->>KV: Upsert configured liveramp.com partner UID +``` + +Identity resolution is asynchronous. The design does not promise a LiveRamp +EID in the first auction on a new browser. Current-request forwarding applies +as soon as `getUserIdsAsEids()` exposes the envelope; `ts-eids` and EC/KV +ingestion provide reuse on later requests. + +## 9. Consent, privacy, and security + +- Trusted Server continues to apply its centralized consent gate before EIDs + reach providers. No LiveRamp-specific bypass is introduced. +- Prebid's User ID and consent-management modules remain responsible for + deciding whether the browser may call LiveRamp. LiveRamp must be configured + correctly in the publisher's CMP/GVL posture. +- Correction applied during implementation: `consentManagementTcf` only + _retrieves_ the TC string. Enforcement lives in Prebid's `tcfControl` + activity-control module, which the generated external bundle did not carry. + Without it a denied Purpose 1 still permitted the vendor call and the + `idl_env` write; only EID _forwarding_ was gated, server-side. The bundle now + imports `tcfControl`, covered by + `crates/trusted-server-js/lib/test/prebid-consent-enforcement.test.mjs`. + Equivalent GPP/US-state activity controls (`gppControl_usnat`, + `gppControl_usstates`) remain unbundled; US opt-outs are still enforced only + at the server's forwarding gate. +- Pinned Prebid's default `tcfControl` rules do not treat every denied purpose + identically. Purpose 1 plus the module's GVL vendor consent controls + IdentityLink device access, resolution, and storage. Purpose 2 controls bid + fetching. Purpose 3 has no standalone default `tcfControl` rule. Purpose 4 + controls user-provided-data activity. With the default + `eidsRequireP4Consent: false`, EID transmission is permitted when any Purpose + 2–10 has the required purpose/legal-interest and vendor basis; publishers may + opt into requiring Purpose 4 specifically. Therefore a Purpose 3 or Purpose + 4 denial alone does not establish that the LiveRamp vendor request or + `idl_env` write is blocked. Automated artifact tests must vary Purpose 1, + Purposes 3/4, and vendor 97 independently, and the operator guide must + describe these exact defaults rather than claiming that every denied purpose + blocks resolution. +- LiveRamp envelope values are opaque identifiers. They must never appear in + logs, public diagnostics, error bodies, or telemetry dimensions. +- The implementation does not collect plaintext or hashed email and does not + add an API for publishers to submit either value. +- The managed configuration preserves unrelated publisher User ID entries but + owns the single `identityLink` entry when enabled. This prevents ambiguous + duplicate LiveRamp configurations. +- Existing EID size limits, source/UID validation, cookie caps, merge rules, + and consent withdrawal behavior remain authoritative. +- Live credentials and Placement IDs must not be committed to fixtures or + repository configuration. + +## 10. Error and degraded behavior + +| Condition | Behavior | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| LiveRamp subsection absent | Preserve current behavior; configure no `identityLink` entry. | +| Invalid Placement ID or unsafe bounds | Reject configuration at startup/validation time. | +| `identityLinkIdSystem` missing from external bundle | Emit existing missing-module diagnostics; continue auctions without LiveRamp EID. | +| LiveRamp network or recognition failure | Prebid module yields no EID; continue auction normally. | +| TCF Purpose 1 or LiveRamp vendor consent denied | Default `tcfControl` blocks IdentityLink resolution/storage; continue auction normally. | +| TCF Purpose 3 or 4 denied alone | Default rules do not prove resolution/storage is blocked; publisher policy may add stricter rules. | +| US-state opt-out | Server forwarding gate drops the LiveRamp EID; browser activity controls remain a documented gap. | +| Malformed LiveRamp EID | Existing client/server EID sanitizers drop it. | +| Oversized `ts-eids` payload | Existing bounded cookie behavior truncates whole UID/source entries; no partial UID is written. | +| EC/KV unavailable | Current-request EID can still reach `/auction`; persistence degrades without blocking the auction. | + +Trusted Server does not parse LiveRamp envelope contents and therefore cannot +distinguish authenticated ATS envelopes from cookie-recognized RTIS envelopes. +That distinction remains inside LiveRamp's module and encrypted envelope. + +## 11. Testing strategy + +Implementation follows test-driven development. + +### 11.1 Rust configuration tests + +Add tests in `crates/trusted-server-core/src/integrations/prebid.rs` and the +settings tests to prove: + +- a complete LiveRamp subsection deserializes with expected values; +- documented defaults are applied; +- missing, blank, whitespace-padded, or nonnumeric Placement IDs fail; +- invalid expiry and zero refresh values fail; +- unknown storage types fail; +- omission remains backward-compatible; +- serialized head configuration uses the expected camel-cased keys; +- script-breaking input cannot escape the injected script element. + +### 11.2 TypeScript unit tests + +Add tests in +`crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` proving: + +- no LiveRamp config produces no `identityLink` entry; +- enabled config creates the exact documented Prebid object; +- an unrelated publisher `userIds` list is preserved; +- a publisher-provided `identityLink` entry is replaced, not duplicated; +- managed configuration is active before an already-queued publisher + `requestBids` call; +- queued publisher `setConfig` followed by `requestBids` preserves other User + ID entries while the auction observes the managed `identityLink` entry; +- User ID entries already effective before TSJS installation are preserved + while the managed `identityLink` entry is added; +- malformed pre-install `userSync.userIds` state degrades to the managed entry + without throwing; +- queued and late publisher `identityLink` updates through `mergeConfig` are + normalized back to the operator-managed values; +- a publisher `identityLink` update through `setConfig` after `processQueue()` + is normalized back to the operator-managed values; +- repeated installation does not stack either configuration wrapper; +- configuration calls without an explicit `userIds` list pass through + unchanged; +- missing `identityLinkIdSystem` appears in existing diagnostics; +- `getUserIdsAsEids()` output for `liveramp.com` enters the current auction; +- malformed and empty envelope values are dropped; +- envelope values are not written to logs or diagnostics; +- the existing `ts-eids` persistence path preserves the opaque value without + decoding it. + +### 11.3 Bundle tests + +Extend external bundle tests to prove: + +- the default preset contains `identityLinkIdSystem`; +- explicitly selecting it stamps the module into the manifest; +- selecting a bundle without it produces a deterministic diagnostic when + LiveRamp configuration is present. +- a generated-real-bundle case that denies only Purpose 1 while granting + Purposes 3/4 and vendor 97 produces no LiveRamp request and no `idl_env`; +- a separate case that denies only vendor 97 while granting Purposes 1/3/4 + produces no LiveRamp request and no `idl_env`; +- separate cases that deny only Purpose 3 or only Purpose 4 while granting + Purpose 1 and vendor 97 still produce one LiveRamp request and write + `idl_env` under pinned Prebid's default rules. +- a generated-real-bundle case proves a partial `userSync` update retains the + publisher entry and exactly one managed `identityLink` entry. + +### 11.4 Rust auction/EC regression tests + +Existing generic EID tests cover most transport behavior. Add or retain a +LiveRamp-named fixture proving that a `liveramp.com` EID: + +- is forwarded as `user.ext.eids` to the Prebid provider; +- merges without duplication against the EC/KV version; +- is removed when consent denies identity forwarding; +- is ingested into the configured `liveramp.com` EC partner namespace on a + later request. + +### 11.5 Live credential validation + +Run outside CI against a LiveRamp-approved non-production origin: + +1. Obtain a test Placement ID and confirm the origin is approved. +2. Generate a Prebid bundle containing `identityLinkIdSystem`. +3. Configure `[integrations.prebid.liveramp]` with the test Placement ID. +4. Load the publisher page with positive consent. +5. Confirm `idl_env` is created or refreshed according to the selected storage. +6. Confirm `pbjs.getUserIdsAsEids()` returns a `liveramp.com` entry without + recording its value. +7. Inspect a controlled Prebid Server request and confirm the same source is + present in `user.ext.eids`. +8. Confirm a later request can ingest the EID into the configured EC partner. +9. Repeat with opt-out/no-consent and confirm no LiveRamp EID is forwarded. +10. Repeat with an unapproved origin and document the expected degraded result. + +Record only booleans, source names, counts, and status codes. Do not capture or +publish live envelopes. + +## 12. Documentation changes + +Implementation updates: + +- `trusted-server.example.toml` with a commented LiveRamp example; +- `docs/guide/integrations/prebid.md` with configuration, lifecycle, bundle, + consent, troubleshooting, and verification guidance; +- `docs/guide/configuration.md` with the typed field reference; +- optionally a short `docs/guide/integrations/liveramp.md` page if the Prebid + guide would become difficult to navigate. The first implementation should + avoid duplicating the authoritative Prebid flow across two pages. + +The documentation must state that: + +- RampID envelopes, not audience segments, are forwarded as EIDs; +- a Placement ID and LiveRamp-approved origin are operational prerequisites; +- the first auction may not contain a newly resolved identity; +- a module included in a bundle is inert until configured; +- ATS Direct segments require separate enablement and implementation. + +## 13. Rollout and observability + +1. Land configuration and tests with the subsection absent by default. +2. Generate and publish a test bundle that includes `identityLinkIdSystem`. +3. Validate on a non-production approved origin with debug logging restricted + to source names/counts. +4. Enable for a canary publisher property. +5. Monitor missing-module diagnostics, LiveRamp EID presence counts, auction + error rates, and cookie/header size truncation counts. Never dimension + metrics by envelope value. +6. Validate opt-out behavior before broader rollout. +7. Document the tested Placement/origin configuration in operator-owned, + non-repository deployment records. + +No database or KV migration is required. Omitting the new subsection provides +an immediate configuration rollback. + +## 14. Acceptance criteria + +Issue #355's implementation portion is complete when: + +- operators can configure LiveRamp RampID through typed Trusted Server config; +- invalid configuration fails before serving traffic; +- managed configuration preserves non-LiveRamp publisher User ID modules and + owns one deterministic `identityLink` entry; +- bundle diagnostics detect a missing `identityLinkIdSystem`; +- valid `liveramp.com` EIDs follow the existing browser → `/auction` → Prebid + Server path without exposing envelope contents; +- existing consent, validation, merge, cookie, and EC/KV behavior is preserved; +- automated Rust and TypeScript tests pass; +- operator documentation explains setup, timing, privacy, failure behavior, + and live verification; +- credential-based validation is completed, or the remaining credential block + is explicitly recorded with an owner and the implementation is labeled + “code complete, live validation pending”; and +- the parent epic receives the explicit answer: RampID identity envelopes can + be passed through the Prebid auction path; ATS Direct segments are not passed + by this implementation. + +## 15. Out of scope and follow-up work + +### 15.1 Server-to-server ATS resolution + +Create or reopen a dedicated issue only after product approval. Its design must +define the hashed-identifier source, origin approval, consent mapping, +`X-Forwarded-For` handling, timeout/cache/refresh policy, geographic failure +behavior, data deletion, and credential storage. It must also reconcile the +decision that closed #630 as not planned. + +### 15.2 ATS Direct audience segments + +Create a separate issue if publishers require LiveRamp segment activation. It +must define: + +- ATS Direct subscription and approved-deal prerequisites; +- whether the integration calls the API or consumes existing browser storage; +- `_lr_atsDirect` and TTL ownership; +- refresh behavior and regional TTL rules; +- whether activation targets GAM (`atsd`), Prebid first-party data, a Prebid + real-time-data module, or more than one destination; +- consent and deletion behavior; and +- the exact evidence needed to confirm segment delivery. + +### 15.3 RTIS callback + +Do not add an RTIS callback unless a concrete non-Prebid use case demonstrates +that the browser module is insufficient and LiveRamp approves the endpoint +contract. + +## 16. Authoritative references + +- [LiveRamp: Implementing the Real-Time Identity Service Tag](https://docs.liveramp.com/identity/en/implementing-liveramp-s-real-time-identity-service-tag.html) +- [LiveRamp: Call the ATS Envelope API](https://developers.liveramp.com/authenticatedtraffic-api/docs/4-call-the-ats-envelope-api) +- [LiveRamp: Retrieving Envelope Endpoints](https://developers.liveramp.com/authenticatedtraffic-api/v1.0/docs/about-the-ats-api) +- [LiveRamp: ATS Direct](https://developers.liveramp.com/authenticatedtraffic-api/docs/implement-ats-direct-via-api) +- [Prebid: LiveRamp RampID User ID module](https://docs.prebid.org/dev-docs/modules/userid-submodules/ramp.html) +- [Prebid: User ID module](https://docs.prebid.org/dev-docs/modules/userId.html) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 5bbdfb857..660d7e9df 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -57,6 +57,19 @@ client_side_bidders = [] adapters = ["rubicon"] # user_id_modules = ["sharedIdSystem"] +# Optional managed LiveRamp RampID configuration. The external Prebid bundle +# must contain identityLinkIdSystem. Obtain the Placement ID and approve the +# publisher origin with LiveRamp before enabling. Persisting the resulting +# envelope into the EC identity graph additionally needs a matching partner +# under [[ec.partners]] with source_domain = "liveramp.com"; without one the +# envelope still reaches the auction but is never written to KV. +# [integrations.prebid.liveramp] +# placement_id = "999" +# not_use_3p = false +# storage_type = "cookie" +# expires_days = 15 +# refresh_in_seconds = 1800 + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"]