From 9296242a6614635efb285970c6fbe171be5db106 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 16:08:15 -0500 Subject: [PATCH 1/5] Use typed module map for Prebid bundles --- .../trusted-server-cli/src/prebid_bundle.rs | 668 +++++++++++----- .../lib/build-prebid-external.mjs | 681 +++++++++++----- .../lib/src/integrations/prebid/index.ts | 79 +- .../integrations/prebid/user_id_modules.json | 31 +- .../integrations/prebid/user_id_modules.ts | 1 - .../lib/test/build-prebid-external.test.mjs | 743 +++++++++++++++++- .../test/integrations/prebid/index.test.ts | 201 ++++- .../test/prebid-artifact-integration.test.mjs | 513 ++++++++---- 8 files changed, 2278 insertions(+), 639 deletions(-) diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 802d854ce..bdf449e65 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -4,7 +4,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer, Serialize}; use toml_edit::{DocumentMut, Item, table, value}; pub(crate) type CliResult = Result; @@ -29,10 +29,58 @@ fn cli_error(message: impl Into) -> CliResult { Err(message.into()) } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub(crate) struct PrebidModuleName(String); + +impl PrebidModuleName { + fn new(value: String) -> CliResult { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return cli_error(format!( + "invalid Prebid module stem {value:?}; use the exact upstream filename without .js" + )); + } + Ok(Self(value)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for PrebidModuleName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct PrebidBundleModules { + pub bidder: Vec, + #[serde(default)] + pub user_id: Option>, + #[serde(default)] + pub analytics: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrebidBundleSection { + modules: PrebidBundleModules, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PrebidBundleConfig { - pub adapters: Vec, - pub user_id_modules: Option>, + pub modules: PrebidBundleModules, pub external_bundle_url: Option, } @@ -40,8 +88,17 @@ pub(crate) struct PrebidBundleConfig { pub(crate) struct PrebidBundleGenerateRequest { pub js_lib_dir: PathBuf, pub out_dir: PathBuf, - pub adapters: Vec, - pub user_id_modules: Option>, + pub modules: PrebidBundleModules, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PrebidBundleModuleRequest<'a> { + bidder: &'a [PrebidModuleName], + #[serde(skip_serializing_if = "Option::is_none")] + user_id: Option<&'a [PrebidModuleName]>, + #[serde(skip_serializing_if = "Option::is_none")] + analytics: Option<&'a [PrebidModuleName]>, } pub(crate) trait PrebidBundleGenerator { @@ -65,7 +122,7 @@ impl PrebidBundleGenerator for NpmPrebidBundleGenerator { ) -> CliResult<()> { ensure_local_build_prerequisites(&request.js_lib_dir)?; - let args = npm_prebid_bundle_args(request); + let args = npm_prebid_bundle_args(request)?; let output = Command::new("npm") .args(&args) @@ -101,25 +158,33 @@ impl PrebidBundleGenerator for NpmPrebidBundleGenerator { } } -fn npm_prebid_bundle_args(request: &PrebidBundleGenerateRequest) -> Vec { - let mut args = vec![ +fn npm_prebid_bundle_args(request: &PrebidBundleGenerateRequest) -> CliResult> { + let modules = PrebidBundleModuleRequest { + bidder: &request.modules.bidder, + user_id: request.modules.user_id.as_deref(), + analytics: request.modules.analytics.as_deref(), + }; + let modules_json = serde_json::to_string(&modules).map_err(|error| { + report_error(format!( + "failed to serialize Prebid module request: {error}" + )) + })?; + + Ok(vec![ "run".to_string(), "build:prebid-external".to_string(), "--".to_string(), - "--adapters".to_string(), - request.adapters.join(","), - ]; - if let Some(user_id_modules) = &request.user_id_modules { - args.push("--user-id-modules".to_string()); - args.push(user_id_modules.join(",")); - } - args.push("--out".to_string()); - args.push(request.out_dir.display().to_string()); - args + "--modules-json".to_string(), + modules_json, + "--out".to_string(), + request.out_dir.display().to_string(), + ]) } #[derive(Debug, Deserialize)] struct PrebidBundleManifest { + #[serde(rename = "schemaVersion")] + schema_version: u64, sha256: String, sri: String, filename: String, @@ -141,8 +206,7 @@ pub(crate) fn run_bundle( let request = PrebidBundleGenerateRequest { js_lib_dir, out_dir: out_dir.clone(), - adapters: config.adapters, - user_id_modules: config.user_id_modules, + modules: config.modules, }; generator.generate(&request, out, err)?; @@ -209,31 +273,37 @@ pub(crate) fn load_bundle_config(config_path: &Path) -> CliResult CliResult CliResult> { - let value = table.get(key).ok_or_else(|| { - report_error(format!( - "{} is missing required {field_name}", - config_path.display() - )) - })?; - read_string_array(value, field_name, config_path) -} - -fn read_optional_string_array( - table: &toml::Value, - key: &str, - field_name: &str, - config_path: &Path, -) -> CliResult>> { - table - .get(key) - .map(|value| read_string_array(value, field_name, config_path)) - .transpose() -} - -fn read_string_array( - value: &toml::Value, - field_name: &str, - config_path: &Path, -) -> CliResult> { - let Some(items) = value.as_array() else { +fn validate_bundle_modules(modules: &PrebidBundleModules, config_path: &Path) -> CliResult<()> { + if modules.bidder.is_empty() { return cli_error(format!( - "{} {field_name} must be an array of non-empty strings", + "{} integrations.prebid.bundle.modules.bidder must contain at least one module stem", config_path.display() )); - }; + } - let mut strings = Vec::with_capacity(items.len()); - for item in items { - let Some(raw) = item.as_str() else { - return cli_error(format!( - "{} {field_name} must be an array of non-empty strings", - config_path.display() - )); - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return cli_error(format!( - "{} {field_name} must not contain empty strings", - config_path.display() - )); + let selections = [ + ( + "integrations.prebid.bundle.modules.bidder", + Some(modules.bidder.as_slice()), + ), + ( + "integrations.prebid.bundle.modules.user_id", + modules.user_id.as_deref(), + ), + ( + "integrations.prebid.bundle.modules.analytics", + modules.analytics.as_deref(), + ), + ]; + let mut owners: Vec<(&str, &str)> = Vec::new(); + for (field, names) in selections { + for name in names.unwrap_or_default() { + if let Some((_, previous_field)) = owners + .iter() + .find(|(previous_name, _)| *previous_name == name.as_str()) + { + return cli_error(format!( + "{} {field} repeats module stem {:?} already selected by {previous_field}", + config_path.display(), + name.as_str() + )); + } + owners.push((name.as_str(), field)); } - strings.push(trimmed.to_string()); } - Ok(strings) + Ok(()) } fn ensure_local_build_prerequisites(js_lib_dir: &Path) -> CliResult<()> { @@ -430,6 +481,13 @@ fn load_manifest(path: &Path) -> CliResult { )) })?; + if manifest.schema_version != 1 { + return cli_error(format!( + "generated Prebid manifest {} uses unsupported schemaVersion {}; expected 1", + path.display(), + manifest.schema_version + )); + } if manifest.filename.trim().is_empty() { return cli_error(format!( "generated Prebid manifest {} is missing filename", @@ -562,26 +620,54 @@ enabled = true server_url = "https://prebid.example.com/openrtb2/auction" external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-old.js" -[integrations.prebid.bundle] -adapters = ["rubicon", "kargo"] -user_id_modules = ["sharedIdSystem", "uid2IdSystem"] +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter", "kargoBidAdapter"] +user_id = ["sharedIdSystem", "uid2IdSystem"] +analytics = ["atsAnalyticsAdapter"] "# .to_string() } + fn module_names(names: &[&str]) -> Vec { + names + .iter() + .map(|name| PrebidModuleName::new((*name).to_string()).expect("should be valid module")) + .collect() + } + + fn names(modules: &[PrebidModuleName]) -> Vec<&str> { + modules.iter().map(PrebidModuleName::as_str).collect() + } + #[test] fn bundle_config_loader_accepts_valid_settings() { let (_temp, path) = write_config(&valid_config()); let config = load_bundle_config(&path).expect("should load bundle config"); - assert_eq!(config.adapters, ["rubicon", "kargo"]); assert_eq!( - config.user_id_modules, - Some(vec![ - "sharedIdSystem".to_string(), - "uid2IdSystem".to_string() - ]) + names(&config.modules.bidder), + ["rubiconBidAdapter", "kargoBidAdapter"] + ); + assert_eq!( + names( + config + .modules + .user_id + .as_deref() + .expect("should have User ID modules") + ), + ["sharedIdSystem", "uid2IdSystem"] + ); + assert_eq!( + names( + config + .modules + .analytics + .as_deref() + .expect("should have analytics modules") + ), + ["atsAnalyticsAdapter"] ); assert_eq!( config.external_bundle_url.as_deref(), @@ -590,22 +676,28 @@ user_id_modules = ["sharedIdSystem", "uid2IdSystem"] } #[test] - fn bundle_config_loader_allows_missing_user_id_modules() { - let (_temp, path) = write_config( + fn bundle_config_loader_preserves_omitted_and_empty_optional_lists() { + let (_omitted_temp, omitted_path) = write_config( r#" -[integrations.prebid] -enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" - -[integrations.prebid.bundle] -adapters = ["rubicon"] +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] "#, ); + let omitted = load_bundle_config(&omitted_path).expect("should load omitted lists"); + assert_eq!(omitted.modules.user_id, None); + assert_eq!(omitted.modules.analytics, None); - let config = load_bundle_config(&path).expect("should load bundle config"); - - assert_eq!(config.adapters, ["rubicon"]); - assert_eq!(config.user_id_modules, None); + let (_empty_temp, empty_path) = write_config( + r#" +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +user_id = [] +analytics = [] +"#, + ); + let empty = load_bundle_config(&empty_path).expect("should load empty lists"); + assert_eq!(empty.modules.user_id, Some(Vec::new())); + assert_eq!(empty.modules.analytics, Some(Vec::new())); } #[test] @@ -615,71 +707,164 @@ adapters = ["rubicon"] let error = load_bundle_config(&path).expect_err("should reject missing prebid block"); assert!( - error.to_string().contains("missing [integrations.prebid]"), + error.contains("missing [integrations.prebid]"), "error should explain missing prebid block: {error:?}" ); } #[test] - fn bundle_config_loader_rejects_missing_bundle_block() { - let (_temp, path) = write_config( - r#" -[integrations.prebid] -enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" -"#, - ); + fn bundle_config_loader_rejects_missing_bundle_or_modules() { + for (contents, expected) in [ + ( + "[integrations.prebid]\nenabled = true\n", + "missing [integrations.prebid.bundle]", + ), + ("[integrations.prebid.bundle]\n", "missing field `modules`"), + ] { + let (_temp, path) = write_config(contents); + let error = load_bundle_config(&path).expect_err("should reject missing table"); + assert!( + error.contains(expected), + "error should contain {expected:?}: {error:?}" + ); + } + } + + #[test] + fn bundle_config_loader_rejects_empty_or_malformed_bidder_lists() { + for (contents, expected) in [ + ( + "[integrations.prebid.bundle.modules]\nbidder = []\n", + "must contain at least one", + ), + ( + "[integrations.prebid.bundle.modules]\nbidder = [\"rubiconBidAdapter\", 123]\n", + "invalid type", + ), + ( + "[integrations.prebid.bundle.modules]\nbidder = \"rubiconBidAdapter\"\n", + "invalid type", + ), + ] { + let (_temp, path) = write_config(contents); + let error = load_bundle_config(&path).expect_err("should reject bidder list"); + assert!( + error.contains(expected), + "error should contain {expected:?}: {error:?}" + ); + } + } - let error = load_bundle_config(&path).expect_err("should reject missing bundle block"); + #[test] + fn bundle_config_loader_rejects_invalid_module_stems() { + for stem in [ + "", + " ", + "rubiconBidAdapter.js", + "../rubiconBidAdapter", + "group/rubiconBidAdapter", + "group\\rubiconBidAdapter", + "https://example.com/adapter", + "rubiconBidAdapter'", + "rubicon\nBidAdapter", + ] { + let contents = format!("[integrations.prebid.bundle.modules]\nbidder = [{stem:?}]\n"); + let (_temp, path) = write_config(&contents); + let error = load_bundle_config(&path).expect_err("should reject invalid stem"); + assert!( + error.contains("invalid Prebid module stem"), + "error should reject {stem:?}: {error:?}" + ); + } + } - assert!( - error - .to_string() - .contains("missing [integrations.prebid.bundle]"), - "error should explain missing bundle block: {error:?}" - ); + #[test] + fn bundle_config_loader_rejects_duplicates_within_and_across_kinds() { + for contents in [ + r#" +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter", "rubiconBidAdapter"] +"#, + r#" +[integrations.prebid.bundle.modules] +bidder = ["exampleModule"] +analytics = ["exampleModule"] +"#, + ] { + let (_temp, path) = write_config(contents); + let error = load_bundle_config(&path).expect_err("should reject duplicate module"); + assert!( + error.contains("repeats module stem"), + "error should identify duplicate: {error:?}" + ); + } } #[test] - fn bundle_config_loader_rejects_empty_adapters() { + fn bundle_config_loader_rejects_removed_fields_in_fixed_order() { let (_temp, path) = write_config( r#" -[integrations.prebid] -enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" - [integrations.prebid.bundle] -adapters = [] +adapters = ["rubicon"] +user_id_modules = ["sharedIdSystem"] +analytics_adapters = ["atsAnalyticsAdapter"] + +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] "#, ); - let error = load_bundle_config(&path).expect_err("should reject empty adapters"); + let error = load_bundle_config(&path).expect_err("should reject removed field"); - assert!( - error.to_string().contains("at least one"), - "error should explain empty adapters: {error:?}" - ); + assert!(error.contains("bundle.adapters is no longer supported")); + assert!(error.contains("bundle.modules.bidder")); } #[test] - fn bundle_config_loader_rejects_malformed_adapters() { + fn bundle_config_loader_reports_each_removed_field_replacement() { + for (field, replacement) in [ + ("adapters", "bundle.modules.bidder"), + ("user_id_modules", "bundle.modules.user_id"), + ("analytics_adapters", "bundle.modules.analytics"), + ] { + let contents = format!( + r#" +[integrations.prebid.bundle] +{field} = ["exampleModule"] + +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +"# + ); + let (_temp, path) = write_config(&contents); + + let error = load_bundle_config(&path).expect_err("should reject removed field"); + + assert!( + error.contains(&format!("bundle.{field} is no longer supported")), + "error should name removed field: {error:?}" + ); + assert!( + error.contains(replacement), + "error should name {replacement}: {error:?}" + ); + } + } + + #[test] + fn bundle_config_loader_rejects_unknown_module_kinds() { let (_temp, path) = write_config( r#" -[integrations.prebid] -enabled = true -server_url = "https://prebid.example.com/openrtb2/auction" - -[integrations.prebid.bundle] -adapters = ["rubicon", 123] +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +real_time_data = ["exampleRtdProvider"] "#, ); - let error = load_bundle_config(&path).expect_err("should reject malformed adapters"); + let error = load_bundle_config(&path).expect_err("should reject unknown kind"); - assert!( - error.to_string().contains("array of non-empty strings"), - "error should explain malformed adapters: {error:?}" - ); + assert!(error.contains("unknown field `real_time_data`")); + assert!(error.contains("integrations.prebid.bundle")); } #[test] @@ -708,53 +893,58 @@ adapters = ["rubicon", 123] } #[test] - fn npm_prebid_bundle_args_include_user_id_modules_when_configured() { + fn npm_prebid_bundle_args_serialize_one_typed_module_request() { let request = PrebidBundleGenerateRequest { js_lib_dir: PathBuf::from("crates/trusted-server-js/lib"), out_dir: PathBuf::from("/tmp/prebid"), - adapters: vec!["rubicon".to_string(), "kargo".to_string()], - user_id_modules: Some(vec!["sharedIdSystem".to_string()]), + modules: PrebidBundleModules { + bidder: module_names(&["rubiconBidAdapter", "kargoBidAdapter"]), + user_id: Some(module_names(&["sharedIdSystem"])), + analytics: Some(module_names(&["atsAnalyticsAdapter"])), + }, }; assert_eq!( - npm_prebid_bundle_args(&request), + npm_prebid_bundle_args(&request).expect("should serialize module request"), [ "run", "build:prebid-external", "--", - "--adapters", - "rubicon,kargo", - "--user-id-modules", - "sharedIdSystem", + "--modules-json", + r#"{"bidder":["rubiconBidAdapter","kargoBidAdapter"],"userId":["sharedIdSystem"],"analytics":["atsAnalyticsAdapter"]}"#, "--out", "/tmp/prebid", ], - "should pass configured adapters, user ID modules, and output path" + "should pass one JSON argument and the output path" ); } #[test] - fn npm_prebid_bundle_args_omit_user_id_modules_when_not_configured() { - let request = PrebidBundleGenerateRequest { - js_lib_dir: PathBuf::from("crates/trusted-server-js/lib"), - out_dir: PathBuf::from("/tmp/prebid"), - adapters: vec!["rubicon".to_string()], - user_id_modules: None, - }; - - assert_eq!( - npm_prebid_bundle_args(&request), - [ - "run", - "build:prebid-external", - "--", - "--adapters", - "rubicon", - "--out", - "/tmp/prebid", - ], - "should omit user ID module flag so the JS generator uses its default preset" - ); + fn npm_prebid_bundle_args_distinguish_omitted_and_empty_lists() { + for (user_id, analytics, expected_json) in [ + (None, None, r#"{"bidder":["rubiconBidAdapter"]}"#), + ( + Some(Vec::new()), + Some(Vec::new()), + r#"{"bidder":["rubiconBidAdapter"],"userId":[],"analytics":[]}"#, + ), + ] { + let request = PrebidBundleGenerateRequest { + js_lib_dir: PathBuf::from("crates/trusted-server-js/lib"), + out_dir: PathBuf::from("/tmp/prebid"), + modules: PrebidBundleModules { + bidder: module_names(&["rubiconBidAdapter"]), + user_id, + analytics, + }, + }; + let args = npm_prebid_bundle_args(&request).expect("should serialize module request"); + + assert_eq!(args[3], "--modules-json"); + assert_eq!(args[4], expected_json); + assert!(!args.iter().any(|arg| arg == "--adapters")); + assert!(!args.iter().any(|arg| arg == "--user-id-modules")); + } } #[test] @@ -798,6 +988,7 @@ adapters = ["rubicon", 123] generate_error: Option, generate_calls: Vec, write_manifest: bool, + manifest_schema: Option, } impl PrebidBundleGenerator for FakeGenerator { @@ -816,19 +1007,29 @@ adapters = ["rubicon", 123] if self.write_manifest { fs::create_dir_all(&request.out_dir).expect("should create output dir"); - fs::write( - request.out_dir.join("manifest.json"), - serde_json::json!({ - "prebidVersion": "10.26.0", - "adapters": request.adapters, - "userIdModules": request.user_id_modules.clone().unwrap_or_default(), - "sha256": "b".repeat(64), - "sri": "sha384-test", - "filename": format!("trusted-prebid-{}.js", "b".repeat(64)) - }) - .to_string(), - ) - .expect("should write fake manifest"); + let mut manifest = serde_json::json!({ + "prebidVersion": "10.26.0", + "modules": { + "bidder": request.modules.bidder, + "userId": request.modules.user_id, + "analytics": request.modules.analytics, + }, + "runtimeCodes": { + "bidder": ["rubicon"], + "analytics": ["atsAnalytics"], + }, + "sha256": "b".repeat(64), + "sri": "sha384-test", + "filename": format!("trusted-prebid-{}.js", "b".repeat(64)) + }); + if let Some(schema) = &self.manifest_schema { + manifest + .as_object_mut() + .expect("should be manifest object") + .insert("schemaVersion".to_string(), schema.clone()); + } + fs::write(request.out_dir.join("manifest.json"), manifest.to_string()) + .expect("should write fake manifest"); } if let Some(error) = &self.generate_error { @@ -849,6 +1050,7 @@ adapters = ["rubicon", 123] generate_error: None, generate_calls: Vec::new(), write_manifest: true, + manifest_schema: Some(serde_json::json!(1)), }; let mut out = Vec::new(); let mut err = Vec::new(); @@ -872,7 +1074,10 @@ adapters = ["rubicon", 123] assert!(stderr.contains("generator stderr")); assert_eq!(generator.generate_calls.len(), 1); - assert_eq!(generator.generate_calls[0].adapters, ["rubicon", "kargo"]); + assert_eq!( + names(&generator.generate_calls[0].modules.bidder), + ["rubiconBidAdapter", "kargoBidAdapter"] + ); let patched = fs::read_to_string(&args.config).expect("should read patched config"); assert!(patched.contains(&format!("external_bundle_sha256 = \"{}\"", "b".repeat(64)))); @@ -891,6 +1096,7 @@ adapters = ["rubicon", 123] generate_error: Some("builder failed".to_string()), generate_calls: Vec::new(), write_manifest: false, + manifest_schema: None, }; let mut out = Vec::new(); let mut err = Vec::new(); @@ -906,6 +1112,72 @@ adapters = ["rubicon", 123] assert!(fs::read_to_string(&args.config).expect("should read config") == original_config); } + #[test] + fn load_manifest_rejects_missing_or_unsupported_schema_versions() { + for (schema, expected) in [ + (None, "schemaVersion"), + (Some(serde_json::json!(0)), "unsupported schemaVersion 0"), + (Some(serde_json::json!(2)), "unsupported schemaVersion 2"), + (Some(serde_json::json!("1")), "invalid type"), + ] { + let temp = tempfile::tempdir().expect("should create temp dir"); + let path = temp.path().join("manifest.json"); + let mut manifest = serde_json::json!({ + "sha256": "b".repeat(64), + "sri": "sha384-test", + "filename": format!("trusted-prebid-{}.js", "b".repeat(64)) + }); + if let Some(schema) = schema { + manifest + .as_object_mut() + .expect("should be manifest object") + .insert("schemaVersion".to_string(), schema); + } + fs::write(&path, manifest.to_string()).expect("should write manifest"); + + let error = load_manifest(&path).expect_err("should reject manifest schema"); + + assert!( + error.contains(expected), + "error should contain {expected:?}: {error:?}" + ); + } + } + + #[test] + fn run_bundle_does_not_patch_config_when_manifest_schema_is_invalid() { + for schema in [ + None, + Some(serde_json::json!(0)), + Some(serde_json::json!(2)), + Some(serde_json::json!("1")), + ] { + let (_temp, config_path) = write_config(&valid_config()); + let original_config = + fs::read_to_string(&config_path).expect("should read baseline config"); + let out_root = tempfile::tempdir().expect("should create temp dir"); + let mut generator = FakeGenerator { + generate_error: None, + generate_calls: Vec::new(), + write_manifest: true, + manifest_schema: schema, + }; + let args = PrebidBundleArgs { + config: config_path, + out: out_root.path().join("prebid"), + }; + + run_bundle(&args, &mut generator, &mut Vec::new(), &mut Vec::new()) + .expect_err("should reject invalid manifest schema"); + + assert_eq!( + fs::read_to_string(&args.config).expect("should read unchanged config"), + original_config, + "manifest failure should leave config unchanged" + ); + } + } + #[test] fn missing_node_modules_fails_with_npm_ci_instruction() { let temp = tempfile::TempDir::new().expect("should create temp dir"); diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index eb6e42826..2157901d5 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -1,3 +1,5 @@ +#!/usr/bin/env node + /** * Build a publisher-specific external Prebid bundle. * @@ -18,12 +20,13 @@ const srcDir = path.resolve(__dirname, 'src'); const integrationsDir = path.join(srcDir, 'integrations'); const prebidDir = path.join(integrationsDir, 'prebid'); -const DEFAULT_PREBID_ADAPTERS = 'rubicon'; -const ADAPTERS_GENERATED_SPECIFIER = './_adapters.generated'; -const USER_IDS_GENERATED_SPECIFIER = './_user_ids.generated'; +const MODULES_GENERATED_SPECIFIER = './_modules.generated'; const USER_ID_REGISTRY_FILE = path.join(prebidDir, 'user_id_modules.json'); -const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; +const PREBID_LOCK_FILE = path.join(__dirname, 'package-lock.json'); const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); +const PREBID_PACKAGE_JSON = path.join(PREBID_PACKAGE_DIR, 'package.json'); +const PREBID_METADATA_DIR = path.join(PREBID_PACKAGE_DIR, 'metadata', 'modules'); +const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; const PREBID_LIVE_INTENT_STANDARD = path.join( PREBID_PACKAGE_DIR, 'dist', @@ -34,238 +37,508 @@ const PREBID_LIVE_INTENT_STANDARD = path.join( ); const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); const LIVE_INTENT_SHIM = path.join(prebidDir, 'prebid_modules', 'liveIntentIdSystem.ts'); +const SHIM_WATCHDOG_DELAY_MS = 5000; +const MODULE_STEM_PATTERN = /^[A-Za-z0-9_-]+$/; +const MODULE_KIND_DEFINITIONS = Object.freeze([ + { + requestKey: 'bidder', + tomlKey: 'bidder', + fieldPath: 'integrations.prebid.bundle.modules.bidder', + metadataType: 'bidder', + }, + { + requestKey: 'userId', + tomlKey: 'user_id', + fieldPath: 'integrations.prebid.bundle.modules.user_id', + metadataType: 'userId', + }, + { + requestKey: 'analytics', + tomlKey: 'analytics', + fieldPath: 'integrations.prebid.bundle.modules.analytics', + metadataType: 'analytics', + }, +]); +const MODULE_KIND_BY_REQUEST_KEY = new Map( + MODULE_KIND_DEFINITIONS.map((definition) => [definition.requestKey, definition]) +); + +function buildError(message) { + return new Error(`[build-prebid-external] ${message}`); +} + +function readJsonFile(filePath, description) { + let contents; + try { + contents = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + throw buildError(`could not read ${description} ${filePath}: ${error.message}`); + } + + try { + return JSON.parse(contents); + } catch (error) { + throw buildError(`could not parse ${description} ${filePath}: ${error.message}`); + } +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function moduleListError(definition, message) { + throw buildError(`${definition.fieldPath} ${message}`); +} + +function validateModuleList(value, definition, { required = false } = {}) { + if (value === undefined) { + if (required) { + moduleListError(definition, 'is required and must contain at least one module stem'); + } + return undefined; + } + if (!Array.isArray(value)) { + moduleListError(definition, 'must be an array of module stems'); + } + if (required && value.length === 0) { + moduleListError(definition, 'must contain at least one module stem'); + } + + const seen = new Set(); + return value.map((stem) => { + if (typeof stem !== 'string' || !MODULE_STEM_PATTERN.test(stem)) { + moduleListError( + definition, + `contains invalid module stem ${JSON.stringify(stem)}; use the exact upstream filename without .js` + ); + } + if (seen.has(stem)) { + moduleListError(definition, `contains duplicate module stem "${stem}"`); + } + seen.add(stem); + return stem; + }); +} + +function validateCrossKindDuplicates(selection) { + const ownerByStem = new Map(); + for (const definition of MODULE_KIND_DEFINITIONS) { + for (const stem of selection[definition.requestKey] ?? []) { + const previous = ownerByStem.get(stem); + if (previous) { + throw buildError( + `${definition.fieldPath} repeats module stem "${stem}" already selected by ${previous.fieldPath}` + ); + } + ownerByStem.set(stem, definition); + } + } +} + +export function parseModuleRequest(rawJson) { + let request; + try { + request = JSON.parse(rawJson); + } catch (error) { + throw buildError(`--modules-json must contain valid JSON: ${error.message}`); + } + + if (!isRecord(request)) { + throw buildError('--modules-json must contain a JSON object'); + } + + const supportedKeys = new Set(MODULE_KIND_DEFINITIONS.map(({ requestKey }) => requestKey)); + const unknownKeys = Object.keys(request).filter((key) => !supportedKeys.has(key)); + if (unknownKeys.length > 0) { + throw buildError(`--modules-json contains unsupported field "${unknownKeys[0]}"`); + } + + const selection = { + bidder: validateModuleList(request.bidder, MODULE_KIND_BY_REQUEST_KEY.get('bidder'), { + required: true, + }), + userId: validateModuleList(request.userId, MODULE_KIND_BY_REQUEST_KEY.get('userId')), + analytics: validateModuleList(request.analytics, MODULE_KIND_BY_REQUEST_KEY.get('analytics')), + }; + validateCrossKindDuplicates(selection); + return selection; +} + +export function normalizeModuleRequest(request, defaultUserIdModules) { + const normalized = { + bidder: [...request.bidder], + userId: [...(request.userId ?? defaultUserIdModules)], + analytics: [...(request.analytics ?? [])], + }; + + for (const definition of MODULE_KIND_DEFINITIONS) { + validateModuleList(normalized[definition.requestKey], definition, { + required: definition.requestKey === 'bidder', + }); + } + validateCrossKindDuplicates(normalized); + return normalized; +} export function parseArgs(argv) { const options = new Map(); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) { - throw new Error(`[build-prebid-external] Unexpected positional argument: ${arg}`); + const supportedOptions = new Set(['modules-json', 'out']); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument.startsWith('--')) { + throw buildError(`Unexpected positional argument: ${argument}`); + } + + const equalsIndex = argument.indexOf('='); + const key = equalsIndex === -1 ? argument.slice(2) : argument.slice(2, equalsIndex); + if (!supportedOptions.has(key)) { + throw buildError(`Unknown option --${key}`); + } + if (options.has(key)) { + throw buildError(`Option --${key} may only be specified once`); } - const equalsIndex = arg.indexOf('='); - const rawKey = equalsIndex === -1 ? arg.slice(2) : arg.slice(2, equalsIndex); - const inlineValue = equalsIndex === -1 ? undefined : arg.slice(equalsIndex + 1); - const value = inlineValue ?? argv[i + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`[build-prebid-external] Missing value for --${rawKey}`); + const inlineValue = equalsIndex === -1 ? undefined : argument.slice(equalsIndex + 1); + const value = inlineValue ?? argv[index + 1]; + if (!value || (inlineValue === undefined && value.startsWith('--'))) { + throw buildError(`Missing value for --${key}`); } if (inlineValue === undefined) { - i += 1; + index += 1; } - options.set(rawKey, value); + options.set(key, value); + } + + const rawModules = options.get('modules-json'); + if (rawModules === undefined) { + throw buildError('Missing required --modules-json'); } return { - adapters: parseList(options.get('adapters') ?? DEFAULT_PREBID_ADAPTERS), - userIdModules: options.has('user-id-modules') - ? parseList(options.get('user-id-modules')) - : null, + moduleRequest: parseModuleRequest(rawModules), outDir: path.resolve(process.cwd(), options.get('out') ?? path.join('..', 'dist', 'prebid')), }; } -function parseList(raw) { - return raw - .split(',') - .map((value) => value.trim()) - .filter(Boolean); +export function verifyPrebidPackageVersion({ + lockFile = PREBID_LOCK_FILE, + packageJsonFile = PREBID_PACKAGE_JSON, +} = {}) { + const lock = readJsonFile(lockFile, 'npm lockfile'); + const installedPackage = readJsonFile(packageJsonFile, 'installed Prebid package manifest'); + const lockedVersion = lock?.packages?.['node_modules/prebid.js']?.version; + const installedVersion = installedPackage?.version; + + if (typeof lockedVersion !== 'string' || lockedVersion.length === 0) { + throw buildError( + `${lockFile} does not declare packages["node_modules/prebid.js"].version; run \`npm ci\` in crates/trusted-server-js/lib and retry` + ); + } + if (typeof installedVersion !== 'string' || installedVersion.length === 0) { + throw buildError( + `${packageJsonFile} does not declare a Prebid version; run \`npm ci\` in crates/trusted-server-js/lib and retry` + ); + } + if (lockedVersion !== installedVersion) { + throw buildError( + `installed prebid.js version ${installedVersion} does not match package-lock.json version ${lockedVersion}; run \`npm ci\` in crates/trusted-server-js/lib and retry` + ); + } + + return installedVersion; } -function requireExistingFile(filePath, description) { - if (!fs.existsSync(filePath)) { - throw new Error(`[build-prebid-external] Missing ${description}: ${filePath}`); +function readUserIdRegistry(registryFile = USER_ID_REGISTRY_FILE) { + const registry = readJsonFile(registryFile, 'Trusted Server User ID registry'); + if (!Array.isArray(registry?.defaultPreset) || !Array.isArray(registry?.modules)) { + throw buildError(`${registryFile} must contain defaultPreset and modules arrays`); } + return registry; +} + +function isContainedPath(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)) + ); } -function prebidPackageVersion() { - const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - return packageJson.version; +function requireRegularContainedFile(filePath, rootPath, description) { + let canonicalRoot; + let canonicalFile; + try { + if (!fs.lstatSync(filePath).isFile()) { + throw new Error('path is not a regular file'); + } + canonicalRoot = fs.realpathSync(rootPath); + canonicalFile = fs.realpathSync(filePath); + } catch (error) { + throw buildError(`${description} could not be resolved at ${filePath}: ${error.message}`); + } + + if (!isContainedPath(canonicalRoot, canonicalFile)) { + throw buildError(`${description} resolves outside ${canonicalRoot}: ${canonicalFile}`); + } + if (!fs.statSync(canonicalFile).isFile()) { + throw buildError(`${description} is not a regular file: ${canonicalFile}`); + } + return canonicalFile; } -function readUserIdRegistry() { - return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); +function unsupportedModuleError(definition, stem, prebidVersion, cause) { + const expectedPath = `modules/${stem}.js`; + const suffix = cause ? ` (${cause})` : ''; + const category = definition.requestKey === 'userId' ? 'User ID' : definition.tomlKey; + const article = definition.requestKey === 'analytics' ? 'an' : 'a'; + return buildError( + `${definition.fieldPath} requested "${stem}", but prebid.js ${prebidVersion} does not provide ${expectedPath}${suffix}. Choose ${article} ${category} module shipped by the pinned prebid.js package; local paths and URLs are unsupported.` + ); } -function validateUserIdImport(entry) { - requireExistingFile(LIVE_INTENT_SHIM, 'LiveIntent ESM shim'); - requireExistingFile(PREBID_LIVE_INTENT_STANDARD, 'Prebid LiveIntent standard ESM module'); - requireExistingFile(PREBID_GLOBAL_MODULE, 'Prebid global module'); +function resolveSpecifierFromInstalledPackage(specifier) { + return require.resolve(specifier, { paths: [__dirname] }); +} - if (entry.moduleName === 'liveIntentIdSystem') { - return; +function resolveOneModule(stem, definition, context) { + const metadataFilename = `${stem}.json`; + const metadataPath = path.join(context.metadataDir, metadataFilename); + const metadataEntries = fs.readdirSync(context.metadataDir); + if (!metadataEntries.includes(metadataFilename)) { + throw unsupportedModuleError(definition, stem, context.prebidVersion); } + let canonicalMetadataRoot; + let canonicalMetadataPath; try { - require.resolve(entry.importPath, { paths: [__dirname] }); + canonicalMetadataRoot = fs.realpathSync(context.metadataDir); + canonicalMetadataPath = fs.realpathSync(metadataPath); } catch (error) { - throw new Error( - `[build-prebid-external] Required Prebid user ID module "${entry.moduleName}" ` + - `could not be resolved from ${entry.importPath}: ${error.message}` + throw unsupportedModuleError(definition, stem, context.prebidVersion, error.message); + } + if (path.dirname(canonicalMetadataPath) !== canonicalMetadataRoot) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but metadata ${metadataPath} resolves outside the pinned metadata directory` + ); + } + if (!fs.statSync(canonicalMetadataPath).isFile()) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but metadata ${metadataPath} is not a regular file` ); } -} - -function writeGeneratedModule(filePath, title, moduleNames, imports, exports = []) { - const content = [ - '// Auto-generated by build-prebid-external.mjs.', - '//', - title, - `// Modules: ${moduleNames.join(', ')}`, - '', - ...imports, - ...(exports.length > 0 ? ['', ...exports] : []), - '', - ].join('\n'); - fs.writeFileSync(filePath, content); -} + const metadata = readJsonFile( + canonicalMetadataPath, + `Prebid metadata for ${definition.fieldPath} stem "${stem}"` + ); + if (!Array.isArray(metadata?.components)) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but metadata ${canonicalMetadataPath} has no components array` + ); + } -export function renderIncludedUserIdModulesExport(moduleNames) { - return `export const INCLUDED_PREBID_USER_ID_MODULES = ${JSON.stringify(moduleNames)};`; -} + const matchingComponents = metadata.components.filter( + (component) => isRecord(component) && component.componentType === definition.metadataType + ); + if (matchingComponents.length === 0) { + const declaredTypes = [ + ...new Set( + metadata.components + .map((component) => (isRecord(component) ? component.componentType : undefined)) + .filter((componentType) => typeof componentType === 'string') + ), + ].sort(); + const declaration = + declaredTypes.length === 0 ? 'no recognized component type' : declaredTypes.join(', '); + throw buildError( + `${definition.fieldPath} requested "${stem}", but metadata ${canonicalMetadataPath} declares ${declaration} rather than ${definition.metadataType}` + ); + } -/** - * Derive the registered Prebid bidder codes (including aliases) for the given - * adapter module names from prebid.js metadata. - * - * Module file stems and runtime bidder codes are not equivalent: the - * `adfBidAdapter.js` module registers `adf` plus the `adform` and - * `adformOpenRTB` aliases, and `a1MediaBidAdapter.js` registers `a1media`. - * The shim validates `client_side_bidders` (runtime codes) against this - * list, while the module-name list is retained separately for audit output. - */ -export function readAdapterBidderCodes(adapterNames) { - const metadataDir = path.join(PREBID_PACKAGE_DIR, 'metadata', 'modules'); - const bidderCodes = new Set(); - - for (const name of adapterNames) { - const metadataPath = path.join(metadataDir, `${name}BidAdapter.json`); - if (!fs.existsSync(metadataPath)) { - // No metadata shipped for this module — fall back to the module stem so - // the bundle still stamps something the shim can validate against. - bidderCodes.add(name); - continue; + const runtimeCodes = []; + for (const component of matchingComponents) { + if (typeof component.componentName !== 'string' || component.componentName.length === 0) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but metadata ${canonicalMetadataPath} has a ${definition.metadataType} component without a non-empty componentName` + ); } + runtimeCodes.push(component.componentName); + } - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); - const bidderComponents = (metadata.components ?? []).filter( - (component) => component.componentType === 'bidder' && component.componentName + const specifier = `prebid.js/modules/${stem}.js`; + let resolvedTarget; + try { + resolvedTarget = context.resolveSpecifier(specifier); + } catch (error) { + throw unsupportedModuleError(definition, stem, context.prebidVersion, error.message); + } + + let canonicalTarget; + try { + canonicalTarget = fs.realpathSync(resolvedTarget); + } catch (error) { + throw unsupportedModuleError(definition, stem, context.prebidVersion, error.message); + } + if (!isContainedPath(context.canonicalPackageDir, canonicalTarget)) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but ${specifier} resolves outside the pinned prebid.js package: ${canonicalTarget}` + ); + } + if (!fs.statSync(canonicalTarget).isFile()) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but ${specifier} does not resolve to a regular file: ${canonicalTarget}` ); - if (bidderComponents.length === 0) { - bidderCodes.add(name); - continue; - } - for (const component of bidderComponents) { - bidderCodes.add(component.componentName); - } } - return [...bidderCodes].sort(); + return { + kind: definition.requestKey, + stem, + specifier, + runtimeCodes: [...new Set(runtimeCodes)].sort(), + }; } -function generateAdapterImports(adapterNames, adaptersFile) { - const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); - const imports = []; - const validAdapters = []; - - for (const name of adapterNames) { - const moduleFile = `${name}BidAdapter.js`; - const modulePath = path.join(modulesDir, moduleFile); - if (!fs.existsSync(modulePath)) { - throw new Error( - `[build-prebid-external] Prebid adapter "${name}" not found (expected ${moduleFile})` - ); - } - imports.push(`import 'prebid.js/modules/${moduleFile}';`); - validAdapters.push(name); - } - - writeGeneratedModule( - adaptersFile, - '// External Prebid bundle adapter imports.', - validAdapters, - imports +function validateLiveIntentTargets({ + packageDir, + liveIntentShim, + liveIntentStandard, + prebidGlobal, +}) { + requireRegularContainedFile(liveIntentShim, prebidDir, 'LiveIntent ESM shim'); + requireRegularContainedFile( + liveIntentStandard, + packageDir, + 'Prebid LiveIntent standard ESM module' ); - return validAdapters; + requireRegularContainedFile(prebidGlobal, packageDir, 'Prebid global module'); } -function generateUserIdImports(requestedModules, userIdsFile) { - const registry = readUserIdRegistry(); - const entriesByModule = new Map(registry.modules.map((entry) => [entry.moduleName, entry])); - const moduleNames = requestedModules ?? registry.defaultPreset; - const selectedEntries = moduleNames.map((moduleName) => { - const entry = entriesByModule.get(moduleName); - if (!entry) { - throw new Error(`[build-prebid-external] Unknown Prebid user ID module: ${moduleName}`); +export function resolveBundleModules( + selection, + { + prebidVersion, + registry, + metadataDir = PREBID_METADATA_DIR, + packageDir = PREBID_PACKAGE_DIR, + resolveSpecifier = resolveSpecifierFromInstalledPackage, + liveIntentShim = LIVE_INTENT_SHIM, + liveIntentStandard = PREBID_LIVE_INTENT_STANDARD, + prebidGlobal = PREBID_GLOBAL_MODULE, + } +) { + let canonicalPackageDir; + let canonicalMetadataDir; + try { + canonicalPackageDir = fs.realpathSync(packageDir); + canonicalMetadataDir = fs.realpathSync(metadataDir); + } catch (error) { + throw buildError( + `could not resolve the pinned prebid.js package and metadata directories; run \`npm ci\` in crates/trusted-server-js/lib and retry: ${error.message}` + ); + } + if (!fs.statSync(canonicalPackageDir).isDirectory()) { + throw buildError(`pinned prebid.js package root is not a directory: ${canonicalPackageDir}`); + } + if ( + !isContainedPath(canonicalPackageDir, canonicalMetadataDir) || + !fs.statSync(canonicalMetadataDir).isDirectory() + ) { + throw buildError( + `Prebid metadata directory ${metadataDir} must be a directory contained by the pinned package root ${canonicalPackageDir}; run \`npm ci\` in crates/trusted-server-js/lib and retry` + ); + } + + const userIdModules = new Set(registry.modules.map((entry) => entry.moduleName)); + const resolved = { bidder: [], userId: [], analytics: [] }; + + for (const definition of MODULE_KIND_DEFINITIONS) { + for (const stem of selection[definition.requestKey]) { + if (definition.requestKey === 'userId' && !userIdModules.has(stem)) { + throw buildError( + `${definition.fieldPath} requested "${stem}", but Trusted Server has no User ID registry entry for it` + ); + } + resolved[definition.requestKey].push( + resolveOneModule(stem, definition, { + prebidVersion, + metadataDir: canonicalMetadataDir, + resolveSpecifier, + canonicalPackageDir, + }) + ); } - validateUserIdImport(entry); - return entry; - }); + } - const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); - writeGeneratedModule( - userIdsFile, - '// External Prebid bundle User ID module imports.', - moduleNames, - imports, - [renderIncludedUserIdModulesExport(moduleNames)] - ); - return moduleNames; + if (selection.userId.includes('liveIntentIdSystem')) { + validateLiveIntentTargets({ + packageDir, + liveIntentShim, + liveIntentStandard, + prebidGlobal, + }); + } + + return resolved; } -function createTemporaryModulePaths() { - const temporaryDir = fs.mkdtempSync(path.join(prebidDir, '.external-generated-')); +export function createSelectionManifest(resolvedModules) { + const runtimeCodes = (kind) => + [...new Set(resolvedModules[kind].flatMap((module) => module.runtimeCodes))].sort(); + return { - temporaryDir, - adaptersFile: path.join(temporaryDir, '_adapters.generated.ts'), - userIdsFile: path.join(temporaryDir, '_user_ids.generated.ts'), - entryFile: path.join(temporaryDir, '_external_entry.generated.ts'), + schemaVersion: 1, + modules: { + bidder: resolvedModules.bidder.map(({ stem }) => stem), + userId: resolvedModules.userId.map(({ stem }) => stem), + analytics: resolvedModules.analytics.map(({ stem }) => stem), + }, + runtimeCodes: { + bidder: runtimeCodes('bidder'), + analytics: runtimeCodes('analytics'), + }, }; } -const SHIM_WATCHDOG_DELAY_MS = 5000; +export function renderGeneratedModules(resolvedModules, selectionManifest) { + const imports = MODULE_KIND_DEFINITIONS.flatMap(({ requestKey }) => + resolvedModules[requestKey].map(({ specifier }) => `import '${specifier}';`) + ); + + return [ + '// Auto-generated by build-prebid-external.mjs.', + '// Selected module imports are validated against the pinned Prebid package.', + '', + ...imports, + '', + `export const PREBID_BUNDLE_SELECTION = ${JSON.stringify(selectionManifest)} as const;`, + '', + ].join('\n'); +} -function generateExternalEntry(entryFile, adapters, bidderCodes) { - const content = [ +export function renderExternalEntry({ includeUserIdModules }) { + return [ '// Auto-generated by build-prebid-external.mjs.', '//', - '// Pure Prebid.js external bundle: core, consent modules, user ID modules,', - '// and client-side bid adapters. The Trusted Server prebid shim', - '// (tsjs-prebid, served by the server) installs the trustedServer adapter', - '// onto the `window.pbjs` global this bundle populates and drives queue', - '// processing — this bundle intentionally does NOT call processQueue()', - '// itself, except through the watchdog below.', + '// Pure Prebid.js external bundle. The Trusted Server Prebid shim installs', + '// the trustedServer adapter and owns queue processing. This bundle only', + '// drains the queue through the watchdog when that shim does not install.', "import 'prebid.js';", "import 'prebid.js/modules/consentManagementTcf.js';", "import 'prebid.js/modules/consentManagementGpp.js';", "import 'prebid.js/modules/consentManagementUsp.js';", - "import 'prebid.js/modules/userId.js';", - "import './_adapters.generated';", - "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", + ...(includeUserIdModules ? ["import 'prebid.js/modules/userId.js';"] : []), + `import { PREBID_BUNDLE_SELECTION } from '${MODULES_GENERATED_SPECIFIER}';`, '', - '// Manifest consumed by the tsjs prebid shim to validate that every', - '// configured client_side_bidder has its adapter compiled in. adapters', - '// lists the module file stems for audit output; bidderCodes lists the', - '// registered runtime bidder codes, including aliases.', 'const bundleWindow = window as unknown as {', ' __tsjs_prebid_bundle?: unknown;', ' __tsjsPrebidShimInstalled?: boolean;', ' pbjs?: { processQueue?: () => void };', '};', - 'bundleWindow.__tsjs_prebid_bundle = Object.freeze({', - ` adapters: ${JSON.stringify(adapters)},`, - ` bidderCodes: ${JSON.stringify(bidderCodes)},`, - ' userIdModules: INCLUDED_PREBID_USER_ID_MODULES,', - '});', + 'bundleWindow.__tsjs_prebid_bundle = Object.freeze(PREBID_BUNDLE_SELECTION);', '', - '// Watchdog: the shim owns processQueue(), but it is a separate artifact', - '// that can fail to load independently (adblock filters, CSP, a', - '// /static/tsjs= error). If it has not installed within the grace period,', - '// drain the queue anyway so publisher pbjs.que callbacks still run', - '// against plain Prebid.js. processQueue() is safe to call again when the', - '// shim arrives late.', + '// The shim can fail independently because of CSP, blocking, or a separate', + '// asset error. Drain the publisher queue after the grace period in that case.', 'setTimeout(() => {', ' if (!bundleWindow.__tsjsPrebidShimInstalled) {', ' bundleWindow.pbjs?.processQueue?.();', @@ -273,8 +546,15 @@ function generateExternalEntry(entryFile, adapters, bidderCodes) { `}, ${SHIM_WATCHDOG_DELAY_MS});`, '', ].join('\n'); +} - fs.writeFileSync(entryFile, content); +function createTemporaryModulePaths() { + const temporaryDir = fs.mkdtempSync(path.join(prebidDir, '.external-generated-')); + return { + temporaryDir, + modulesFile: path.join(temporaryDir, '_modules.generated.ts'), + entryFile: path.join(temporaryDir, '_external_entry.generated.ts'), + }; } export function deriveBundleMetadata(bundleBytes) { @@ -300,8 +580,6 @@ async function buildExternalBundle(outDir, generatedModules) { root: __dirname, resolve: { alias: [ - { find: ADAPTERS_GENERATED_SPECIFIER, replacement: generatedModules.adaptersFile }, - { find: USER_IDS_GENERATED_SPECIFIER, replacement: generatedModules.userIdsFile }, { find: LIVE_INTENT_SHIM_ALIAS, replacement: LIVE_INTENT_SHIM }, { find: 'prebid.js/modules/liveIntentIdSystem', replacement: LIVE_INTENT_SHIM }, { @@ -362,26 +640,61 @@ async function buildExternalBundle(outDir, generatedModules) { } } -export async function main(argv = process.argv.slice(2)) { +export async function main( + argv = process.argv.slice(2), + { + lockFile = PREBID_LOCK_FILE, + packageJsonFile = PREBID_PACKAGE_JSON, + registryFile = USER_ID_REGISTRY_FILE, + metadataDir = PREBID_METADATA_DIR, + packageDir = PREBID_PACKAGE_DIR, + resolveSpecifier = resolveSpecifierFromInstalledPackage, + liveIntentShim = LIVE_INTENT_SHIM, + liveIntentStandard = PREBID_LIVE_INTENT_STANDARD, + prebidGlobal = PREBID_GLOBAL_MODULE, + createGeneratedPaths = createTemporaryModulePaths, + buildBundle = buildExternalBundle, + } = {} +) { const args = parseArgs(argv); - const generatedModules = createTemporaryModulePaths(); + const prebidVersion = verifyPrebidPackageVersion({ lockFile, packageJsonFile }); + const registry = readUserIdRegistry(registryFile); + const selection = normalizeModuleRequest(args.moduleRequest, registry.defaultPreset); + const resolvedModules = resolveBundleModules(selection, { + prebidVersion, + registry, + metadataDir, + packageDir, + resolveSpecifier, + liveIntentShim, + liveIntentStandard, + prebidGlobal, + }); + const selectionManifest = createSelectionManifest(resolvedModules); + const generatedModules = createGeneratedPaths(); try { - const adapters = generateAdapterImports(args.adapters, generatedModules.adaptersFile); - const bidderCodes = readAdapterBidderCodes(adapters); - const userIdModules = generateUserIdImports(args.userIdModules, generatedModules.userIdsFile); - generateExternalEntry(generatedModules.entryFile, adapters, bidderCodes); - const bundle = await buildExternalBundle(args.outDir, generatedModules); + fs.writeFileSync( + generatedModules.modulesFile, + renderGeneratedModules(resolvedModules, selectionManifest) + ); + fs.writeFileSync( + generatedModules.entryFile, + renderExternalEntry({ includeUserIdModules: selection.userId.length > 0 }) + ); + + const bundle = await buildBundle(args.outDir, generatedModules); const manifest = { - prebidVersion: prebidPackageVersion(), - adapters, - bidderCodes, - userIdModules, + schemaVersion: 1, + prebidVersion, + modules: selectionManifest.modules, + runtimeCodes: selectionManifest.runtimeCodes, sha256: bundle.sha256, sri: bundle.sri, filename: bundle.filename, }; + fs.mkdirSync(args.outDir, { recursive: true }); fs.writeFileSync( path.join(args.outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n` 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..32dc23c99 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -47,38 +47,58 @@ const pbjs: PbjsGlobal = ( /** * Manifest stamped on `window.__tsjs_prebid_bundle` by the external Prebid.js - * bundle (see build-prebid-external.mjs): which client-side bid adapters and - * user ID modules were compiled into it. + * bundle. Module stems and runtime codes remain separate because values such as + * `rubiconBidAdapter` and `rubicon` are not interchangeable. */ interface ExternalPrebidBundleManifest { - adapters?: string[]; - bidderCodes?: string[]; - userIdModules?: string[]; + schemaVersion: 1; + modules: { + bidder?: string[]; + userId?: string[]; + analytics?: string[]; + }; + runtimeCodes: { + bidder?: string[]; + analytics?: string[]; + }; } -function sanitizeManifestList(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { +function parseManifestList(value: unknown): string[] | undefined { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { return undefined; } - return value.filter((entry): entry is string => typeof entry === 'string'); + return [...value]; +} + +function isManifestContainer(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } function getExternalBundleManifest(): ExternalPrebidBundleManifest | undefined { if (typeof window === 'undefined') { return undefined; } - // The manifest is a plain window global any page script can overwrite, so - // validate its shape instead of trusting the declared type: a non-array - // field must degrade to "not stamped" diagnostics, not a TypeError. + + // Page code can replace this global. Validate each nested list instead of + // trusting the generated TypeScript shape. const raw = (window as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle; - if (raw === null || typeof raw !== 'object') { + if (!isManifestContainer(raw) || raw.schemaVersion !== 1) { return undefined; } - const manifest = raw as Record; + + const modules = isManifestContainer(raw.modules) ? raw.modules : {}; + const runtimeCodes = isManifestContainer(raw.runtimeCodes) ? raw.runtimeCodes : {}; return { - adapters: sanitizeManifestList(manifest.adapters), - bidderCodes: sanitizeManifestList(manifest.bidderCodes), - userIdModules: sanitizeManifestList(manifest.userIdModules), + schemaVersion: 1, + modules: { + bidder: parseManifestList(modules.bidder), + userId: parseManifestList(modules.userId), + analytics: parseManifestList(modules.analytics), + }, + runtimeCodes: { + bidder: parseManifestList(runtimeCodes.bidder), + analytics: parseManifestList(runtimeCodes.analytics), + }, }; } @@ -227,7 +247,7 @@ function readConfiguredUserIdNames(): string[] { let warnedMissingUserIdManifest = false; function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { - const manifestUserIdModules = getExternalBundleManifest()?.userIdModules; + const manifestUserIdModules = getExternalBundleManifest()?.modules.userId; const includedUserIdModules = manifestUserIdModules ?? []; const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort(); const coveredConfigNames = new Set( @@ -235,9 +255,8 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { includedUserIdModules.includes(entry.moduleName) ).flatMap((entry) => entry.configNames) ); - // An older or unstamped bundle must not make every configured module look - // absent: warn once about the missing manifest instead of once per module, - // mirroring the client-side adapter validation in installPrebidNpm. + // An absent, unsupported, or malformed manifest must not make every + // configured module look absent. Warn once instead of once per module. const missingConfiguredUserIdNames = manifestUserIdModules === undefined ? [] @@ -1312,19 +1331,14 @@ export function installPrebidNpm(config?: Partial): typeof pbjs pbjs.processQueue(); recordUserIdModuleDiagnostics(); - // Validate that every client-side bidder has its adapter compiled into the - // external Prebid.js bundle. The bundle stamps the registered bidder codes - // (including aliases such as adform/adformOpenRTB for the adf module) on - // window.__tsjs_prebid_bundle; a missing code means the bidder was listed - // in client_side_bidders but not included in the generated bundle, so it is - // silently dropped from both server-side and client-side auctions. Fall - // back to the module-name list for bundles stamped before bidderCodes. - const manifest = getExternalBundleManifest(); - const bundledBidderCodes = manifest?.bidderCodes ?? manifest?.adapters; + // Validate runtime bidder codes, including aliases such as adform and + // adformOpenRTB for the adf module. Module stems are retained separately and + // must never be used as bidder codes. + const bundledBidderCodes = getExternalBundleManifest()?.runtimeCodes.bidder; if (bundledBidderCodes === undefined) { if (clientSideBidders.size > 0) { log.warn( - '[tsjs-prebid] external Prebid bundle did not stamp an adapter manifest; ' + + '[tsjs-prebid] external Prebid bundle did not stamp a supported bidder runtime-code manifest; ' + 'cannot verify client_side_bidders adapters' ); } @@ -1333,8 +1347,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs if (!bundledBidderCodes.includes(bidder)) { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter in the external ` + - 'Prebid bundle. Add its adapter to [integrations.prebid.bundle].adapters in ' + - 'trusted-server.toml and rebuild it with `ts prebid bundle`.' + 'Prebid bundle. Add its exact Prebid module stem to ' + + '[integrations.prebid.bundle.modules].bidder in trusted-server.toml and ' + + 'rebuild it with `ts prebid bundle`.' ); } } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json index a4fd58dbe..38061761d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json @@ -14,26 +14,22 @@ { "moduleName": "connectIdSystem", "configNames": ["connectId"], - "eidSources": ["yahoo.com"], - "importPath": "prebid.js/modules/connectIdSystem.js" + "eidSources": ["yahoo.com"] }, { "moduleName": "criteoIdSystem", "configNames": ["criteo"], - "eidSources": ["criteo.com"], - "importPath": "prebid.js/modules/criteoIdSystem.js" + "eidSources": ["criteo.com"] }, { "moduleName": "id5IdSystem", "configNames": ["id5Id"], - "eidSources": ["id5-sync.com"], - "importPath": "prebid.js/modules/id5IdSystem.js" + "eidSources": ["id5-sync.com"] }, { "moduleName": "identityLinkIdSystem", "configNames": ["identityLink"], - "eidSources": ["liveramp.com"], - "importPath": "prebid.js/modules/identityLinkIdSystem.js" + "eidSources": ["liveramp.com"] }, { "moduleName": "liveIntentIdSystem", @@ -55,44 +51,37 @@ "liveintent.sonobi.com", "liveintent.vidazoo.com" ], - "importPath": "prebid.js/modules/liveIntentIdSystem.js", "notes": "Imported through a local ESM shim because the public Prebid wrapper contains CommonJS require()." }, { "moduleName": "lockrAIMIdSystem", "configNames": ["lockrAIMId"], - "eidSources": [], - "importPath": "prebid.js/modules/lockrAIMIdSystem.js" + "eidSources": [] }, { "moduleName": "pairIdSystem", "configNames": ["pairId"], - "eidSources": ["google.com"], - "importPath": "prebid.js/modules/pairIdSystem.js" + "eidSources": ["google.com"] }, { "moduleName": "pubProvidedIdSystem", "configNames": ["pubProvidedId"], - "eidSources": [], - "importPath": "prebid.js/modules/pubProvidedIdSystem.js" + "eidSources": [] }, { "moduleName": "sharedIdSystem", "configNames": ["sharedId", "pubCommonId"], - "eidSources": ["pubcid.org"], - "importPath": "prebid.js/modules/sharedIdSystem.js" + "eidSources": ["pubcid.org"] }, { "moduleName": "uid2IdSystem", "configNames": ["uid2"], - "eidSources": ["uidapi.com"], - "importPath": "prebid.js/modules/uid2IdSystem.js" + "eidSources": ["uidapi.com"] }, { "moduleName": "unifiedIdSystem", "configNames": ["unifiedId"], - "eidSources": ["adserver.org"], - "importPath": "prebid.js/modules/unifiedIdSystem.js" + "eidSources": ["adserver.org"] } ] } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts index 35901066e..5f1b147ec 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts @@ -4,7 +4,6 @@ export interface PrebidUserIdModuleRegistryEntry { moduleName: string; configNames: string[]; eidSources: string[]; - importPath: string; notes?: string; } 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..972fbbb74 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 @@ -2,20 +2,503 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; +import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + createSelectionManifest, deriveBundleMetadata, main, + normalizeModuleRequest, parseArgs, - readAdapterBidderCodes, - renderIncludedUserIdModulesExport, + parseModuleRequest, + renderExternalEntry, + renderGeneratedModules, + resolveBundleModules, + verifyPrebidPackageVersion, } from '../build-prebid-external.mjs'; -describe('build-prebid-external metadata', () => { +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const libDir = path.resolve(__dirname, '..'); +const prebidPackageDir = path.join(libDir, 'node_modules', 'prebid.js'); +const prebidMetadataDir = path.join(prebidPackageDir, 'metadata', 'modules'); +const registry = JSON.parse( + fs.readFileSync( + path.join(libDir, 'src', 'integrations', 'prebid', 'user_id_modules.json'), + 'utf8' + ) +); + +function completeRequest(overrides = {}) { + return { + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + ...overrides, + }; +} + +function parseRequest(value) { + return parseModuleRequest(JSON.stringify(value)); +} + +function actualResolveOptions(overrides = {}) { + return { + prebidVersion: '10.26.0', + registry, + metadataDir: prebidMetadataDir, + packageDir: prebidPackageDir, + ...overrides, + }; +} + +function createResolverFixture(metadata) { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-resolver-')); + const packageDir = path.join(temp, 'prebid.js'); + const metadataDir = path.join(packageDir, 'metadata', 'modules'); + const target = path.join(packageDir, 'dist', 'src', 'public', 'exampleModule.js'); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(path.join(metadataDir, 'exampleModule.json'), JSON.stringify(metadata)); + fs.writeFileSync(target, 'export {};\n'); + return { temp, packageDir, metadataDir, target }; +} + +function fakeBundleMetadata() { + return { + filename: `trusted-prebid-${'a'.repeat(64)}.js`, + sha256: 'a'.repeat(64), + sri: 'sha384-example', + }; +} + +describe('build-prebid-external request parsing', () => { + it('accepts the typed module request and preserves order', () => { + const request = parseRequest({ + bidder: ['rubiconBidAdapter', 'kargoBidAdapter'], + userId: ['sharedIdSystem', 'uid2IdSystem'], + analytics: ['atsAnalyticsAdapter'], + }); + + expect(request).toEqual({ + bidder: ['rubiconBidAdapter', 'kargoBidAdapter'], + userId: ['sharedIdSystem', 'uid2IdSystem'], + analytics: ['atsAnalyticsAdapter'], + }); + }); + + it('expands omitted User ID modules and normalizes omitted analytics', () => { + const request = parseRequest({ bidder: ['rubiconBidAdapter'] }); + + expect(normalizeModuleRequest(request, ['sharedIdSystem', 'uid2IdSystem'])).toEqual({ + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem', 'uid2IdSystem'], + analytics: [], + }); + }); + + it('preserves explicit empty optional selections', () => { + const request = parseRequest({ + bidder: ['rubiconBidAdapter'], + userId: [], + analytics: [], + }); + + expect(normalizeModuleRequest(request, ['sharedIdSystem'])).toEqual({ + bidder: ['rubiconBidAdapter'], + userId: [], + analytics: [], + }); + }); + + it.each([ + [{}, 'modules.bidder'], + [{ bidder: [] }, 'modules.bidder'], + [{ bidder: 'rubiconBidAdapter' }, 'modules.bidder'], + [{ bidder: ['rubiconBidAdapter'], userId: 'sharedIdSystem' }, 'modules.user_id'], + [{ bidder: ['rubiconBidAdapter'], analytics: [42] }, 'modules.analytics'], + [{ bidder: ['rubiconBidAdapter'], extra: [] }, 'unsupported field "extra"'], + [[], 'JSON object'], + [null, 'JSON object'], + ])('rejects malformed request %#', (request, expectedMessage) => { + expect(() => parseRequest(request)).toThrow(expectedMessage); + }); + + it.each([ + '', + ' ', + 'atsAnalyticsAdapter.js', + '../atsAnalyticsAdapter', + 'group/atsAnalyticsAdapter', + 'group\\atsAnalyticsAdapter', + 'https://example.com/adapter', + "atsAnalyticsAdapter';alert(1)//", + 'ats\nAnalyticsAdapter', + ])('rejects invalid analytics stem %j', (stem) => { + expect(() => parseRequest(completeRequest({ analytics: [stem] }))).toThrow( + 'integrations.prebid.bundle.modules.analytics' + ); + }); + + it('rejects duplicates within a kind', () => { + expect(() => + parseRequest(completeRequest({ analytics: ['atsAnalyticsAdapter', 'atsAnalyticsAdapter'] })) + ).toThrow('duplicate module stem "atsAnalyticsAdapter"'); + }); + + it('rejects a stem repeated across kinds', () => { + expect(() => parseRequest({ bidder: ['exampleModule'], analytics: ['exampleModule'] })).toThrow( + 'already selected by integrations.prebid.bundle.modules.bidder' + ); + }); + + it('rejects malformed JSON with the generator prefix', () => { + expect(() => parseModuleRequest('{')).toThrow( + '[build-prebid-external] --modules-json must contain valid JSON' + ); + }); + + it('parses both option forms and resolves relative output paths', () => { + const json = JSON.stringify({ bidder: ['rubiconBidAdapter'] }); + + expect(parseArgs([`--modules-json=${json}`, '--out=dist/prebid']).outDir).toBe( + path.resolve(process.cwd(), 'dist/prebid') + ); + expect( + parseArgs(['--modules-json', json, '--out', 'other/prebid']).moduleRequest.bidder + ).toEqual(['rubiconBidAdapter']); + }); + + it.each([ + [[], 'Missing required --modules-json'], + [['--adapters', 'rubicon'], 'Unknown option --adapters'], + [['--user-id-modules', 'sharedIdSystem'], 'Unknown option --user-id-modules'], + [['--unknown', 'value'], 'Unknown option --unknown'], + [['positional'], 'Unexpected positional argument'], + [['--modules-json'], 'Missing value for --modules-json'], + [ + [ + '--modules-json', + JSON.stringify({ bidder: ['rubiconBidAdapter'] }), + '--modules-json', + JSON.stringify({ bidder: ['kargoBidAdapter'] }), + ], + 'may only be specified once', + ], + ])('rejects invalid arguments %#', (argv, expectedMessage) => { + expect(() => parseArgs(argv)).toThrow(expectedMessage); + }); +}); + +describe('build-prebid-external dependency validation', () => { + function writeVersionFixtures(lockVersion, installedVersion) { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-version-')); + const lockFile = path.join(temp, 'package-lock.json'); + const packageJsonFile = path.join(temp, 'package.json'); + fs.writeFileSync( + lockFile, + JSON.stringify({ packages: { 'node_modules/prebid.js': { version: lockVersion } } }) + ); + fs.writeFileSync(packageJsonFile, JSON.stringify({ version: installedVersion })); + return { temp, lockFile, packageJsonFile }; + } + + it('returns the matching installed Prebid version', () => { + const fixture = writeVersionFixtures('10.26.0', '10.26.0'); + try { + expect(verifyPrebidPackageVersion(fixture)).toBe('10.26.0'); + } finally { + fs.rmSync(fixture.temp, { recursive: true, force: true }); + } + }); + + it('rejects a lockfile/install mismatch with recovery guidance', () => { + const fixture = writeVersionFixtures('10.26.0', '10.25.0'); + try { + expect(() => verifyPrebidPackageVersion(fixture)).toThrow( + 'installed prebid.js version 10.25.0 does not match package-lock.json version 10.26.0' + ); + expect(() => verifyPrebidPackageVersion(fixture)).toThrow('npm ci'); + } finally { + fs.rmSync(fixture.temp, { recursive: true, force: true }); + } + }); + + it.each([ + [{ packages: {} }, { version: '10.26.0' }, 'does not declare packages'], + [ + { packages: { 'node_modules/prebid.js': { version: '10.26.0' } } }, + {}, + 'does not declare a Prebid version', + ], + ])('rejects missing version data %#', (lock, installed, expectedMessage) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-version-')); + const lockFile = path.join(temp, 'package-lock.json'); + const packageJsonFile = path.join(temp, 'package.json'); + fs.writeFileSync(lockFile, JSON.stringify(lock)); + fs.writeFileSync(packageJsonFile, JSON.stringify(installed)); + try { + expect(() => verifyPrebidPackageVersion({ lockFile, packageJsonFile })).toThrow( + expectedMessage + ); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); + + it('rejects malformed dependency JSON', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-version-')); + const lockFile = path.join(temp, 'package-lock.json'); + const packageJsonFile = path.join(temp, 'package.json'); + fs.writeFileSync(lockFile, '{'); + fs.writeFileSync(packageJsonFile, JSON.stringify({ version: '10.26.0' })); + try { + expect(() => verifyPrebidPackageVersion({ lockFile, packageJsonFile })).toThrow( + 'could not parse npm lockfile' + ); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); +}); + +describe('build-prebid-external module resolution', () => { + it('resolves bidder, User ID, and analytics modules through package exports', () => { + const resolved = resolveBundleModules(completeRequest(), actualResolveOptions()); + const manifest = createSelectionManifest(resolved); + + expect(manifest).toEqual({ + schemaVersion: 1, + modules: { + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + }, + runtimeCodes: { + bidder: ['rubicon'], + analytics: ['atsAnalytics'], + }, + }); + expect(resolved.userId[0].specifier).toBe('prebid.js/modules/sharedIdSystem.js'); + expect(fs.existsSync(path.join(prebidPackageDir, 'modules', 'sharedIdSystem.js'))).toBe(false); + }); + + it('resolves every module in the curated default User ID preset', () => { + const selection = normalizeModuleRequest( + parseRequest({ bidder: ['rubiconBidAdapter'] }), + registry.defaultPreset + ); + + const resolved = resolveBundleModules(selection, actualResolveOptions()); + + expect(resolved.userId.map(({ stem }) => stem)).toEqual(registry.defaultPreset); + }); + + it('validates the fixed LiveIntent shim and package targets after upstream resolution', () => { + const resolved = resolveBundleModules( + completeRequest({ userId: ['liveIntentIdSystem'], analytics: [] }), + actualResolveOptions() + ); + + expect(resolved.userId[0].specifier).toBe('prebid.js/modules/liveIntentIdSystem.js'); + }); + + it('derives every bidder alias and sorts runtime codes', () => { + const resolved = resolveBundleModules( + completeRequest({ bidder: ['adfBidAdapter'], analytics: [] }), + actualResolveOptions() + ); + + expect(createSelectionManifest(resolved).runtimeCodes.bidder).toEqual([ + 'adf', + 'adform', + 'adformOpenRTB', + ]); + }); + + it.each([ + [completeRequest({ analytics: ['sharedIdSystem'] }), 'declares userId rather than analytics'], + [ + completeRequest({ userId: ['exampleIdSystem'], analytics: [] }), + 'Trusted Server has no User ID registry entry', + ], + [ + completeRequest({ analytics: ['ATSanAlyticsAdapter'] }), + 'does not provide modules/ATSanAlyticsAdapter.js', + ], + ])('rejects unsupported or wrong-kind selection %#', (selection, expectedMessage) => { + expect(() => resolveBundleModules(selection, actualResolveOptions())).toThrow(expectedMessage); + }); + + it('rejects an unresolved or non-file package export', () => { + expect(() => + resolveBundleModules( + completeRequest({ userId: [], analytics: [] }), + actualResolveOptions({ + resolveSpecifier: () => { + throw new Error('example missing export'); + }, + }) + ) + ).toThrow('does not provide modules/rubiconBidAdapter.js'); + + expect(() => + resolveBundleModules( + completeRequest({ userId: [], analytics: [] }), + actualResolveOptions({ resolveSpecifier: () => prebidPackageDir }) + ) + ).toThrow('does not resolve to a regular file'); + }); + + it('rejects a package-export target outside the pinned package', () => { + const fixture = createResolverFixture({ + components: [{ componentType: 'analytics', componentName: 'exampleAnalytics' }], + }); + const escapedTarget = path.join(fixture.temp, 'escaped.js'); + fs.writeFileSync(escapedTarget, 'export {};\n'); + try { + expect(() => + resolveBundleModules( + { bidder: [], userId: [], analytics: ['exampleModule'] }, + actualResolveOptions({ + registry: { modules: [] }, + packageDir: fixture.packageDir, + metadataDir: fixture.metadataDir, + resolveSpecifier: () => escapedTarget, + }) + ) + ).toThrow('resolves outside the pinned prebid.js package'); + } finally { + fs.rmSync(fixture.temp, { recursive: true, force: true }); + } + }); + + it('rejects a metadata symlink that escapes the metadata directory', () => { + const fixture = createResolverFixture({ components: [] }); + const externalMetadata = path.join(fixture.temp, 'outside.json'); + fs.writeFileSync( + externalMetadata, + JSON.stringify({ + components: [{ componentType: 'analytics', componentName: 'exampleAnalytics' }], + }) + ); + fs.rmSync(path.join(fixture.metadataDir, 'exampleModule.json')); + fs.symlinkSync(externalMetadata, path.join(fixture.metadataDir, 'exampleModule.json')); + try { + expect(() => + resolveBundleModules( + { bidder: [], userId: [], analytics: ['exampleModule'] }, + actualResolveOptions({ + registry: { modules: [] }, + packageDir: fixture.packageDir, + metadataDir: fixture.metadataDir, + resolveSpecifier: () => fixture.target, + }) + ) + ).toThrow('resolves outside the pinned metadata directory'); + } finally { + fs.rmSync(fixture.temp, { recursive: true, force: true }); + } + }); + + it('rejects malformed metadata JSON with field and path context', () => { + const fixture = createResolverFixture({ components: [] }); + fs.writeFileSync(path.join(fixture.metadataDir, 'exampleModule.json'), '{'); + try { + expect(() => + resolveBundleModules( + { bidder: [], userId: [], analytics: ['exampleModule'] }, + actualResolveOptions({ + registry: { modules: [] }, + packageDir: fixture.packageDir, + metadataDir: fixture.metadataDir, + resolveSpecifier: () => fixture.target, + }) + ) + ).toThrow('Prebid metadata for integrations.prebid.bundle.modules.analytics'); + } finally { + fs.rmSync(fixture.temp, { recursive: true, force: true }); + } + }); + + it.each([ + [{}, 'has no components array'], + [{ components: 'analytics' }, 'has no components array'], + [{ components: [{ componentType: 'analytics' }] }, 'without a non-empty componentName'], + [ + { + components: [ + { componentType: 'analytics', componentName: 'exampleAnalytics' }, + { componentType: 'analytics', componentName: 42 }, + ], + }, + 'without a non-empty componentName', + ], + ])('rejects malformed metadata %#', (metadata, expectedMessage) => { + const fixture = createResolverFixture(metadata); + try { + expect(() => + resolveBundleModules( + { bidder: [], userId: [], analytics: ['exampleModule'] }, + actualResolveOptions({ + registry: { modules: [] }, + packageDir: fixture.packageDir, + metadataDir: fixture.metadataDir, + resolveSpecifier: () => fixture.target, + }) + ) + ).toThrow(expectedMessage); + } finally { + fs.rmSync(fixture.temp, { recursive: true, force: true }); + } + }); +}); + +describe('build-prebid-external rendering and orchestration', () => { + it('renders imports in kind and configured order from one manifest', () => { + const resolved = resolveBundleModules( + { + bidder: ['kargoBidAdapter', 'rubiconBidAdapter'], + userId: ['uid2IdSystem', 'sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + }, + actualResolveOptions() + ); + const manifest = createSelectionManifest(resolved); + const source = renderGeneratedModules(resolved, manifest); + + const specifiers = [ + 'kargoBidAdapter.js', + 'rubiconBidAdapter.js', + 'uid2IdSystem.js', + 'sharedIdSystem.js', + 'atsAnalyticsAdapter.js', + ]; + const offsets = specifiers.map((specifier) => source.indexOf(specifier)); + expect(offsets.every((offset) => offset >= 0)).toBe(true); + expect(offsets).toEqual([...offsets].sort((left, right) => left - right)); + expect(source).toContain('"schemaVersion":1'); + expect(source).not.toContain('bidderCodes'); + expect(source).not.toContain('userIdModules'); + }); + + it('omits User ID base and analytics imports for empty selections', () => { + const resolved = resolveBundleModules( + { bidder: ['rubiconBidAdapter'], userId: [], analytics: [] }, + actualResolveOptions() + ); + const modulesSource = renderGeneratedModules(resolved, createSelectionManifest(resolved)); + const entrySource = renderExternalEntry({ includeUserIdModules: false }); + + expect(modulesSource).not.toContain('AnalyticsAdapter.js'); + expect(entrySource).not.toContain('prebid.js/modules/userId.js'); + }); + it('derives filename, sha256, and SRI from exact bundle bytes', () => { const bundleBytes = Buffer.from('console.log("trusted prebid");\n', 'utf8'); const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); @@ -28,39 +511,228 @@ describe('build-prebid-external metadata', () => { }); }); - it('renders the exact selected User ID modules for runtime diagnostics', () => { - expect(renderIncludedUserIdModulesExport(['liveIntentIdSystem', 'pairIdSystem'])).toBe( - 'export const INCLUDED_PREBID_USER_ID_MODULES = ["liveIntentIdSystem","pairIdSystem"];' + it('fails a dependency mismatch before creating generated paths or invoking Vite', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-mismatch-')); + const lockFile = path.join(temp, 'package-lock.json'); + const packageJsonFile = path.join(temp, 'package.json'); + fs.writeFileSync( + lockFile, + JSON.stringify({ packages: { 'node_modules/prebid.js': { version: '10.26.0' } } }) ); + fs.writeFileSync(packageJsonFile, JSON.stringify({ version: '10.25.0' })); + const createGeneratedPaths = vi.fn(); + const buildBundle = vi.fn(); + try { + await expect( + main( + [ + '--modules-json', + JSON.stringify({ bidder: ['rubiconBidAdapter'] }), + '--out', + path.join(temp, 'out'), + ], + { lockFile, packageJsonFile, createGeneratedPaths, buildBundle } + ) + ).rejects.toThrow('does not match package-lock.json version'); + expect(createGeneratedPaths).not.toHaveBeenCalled(); + expect(buildBundle).not.toHaveBeenCalled(); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); + + it('fails an escaping metadata root before creating generated paths or invoking Vite', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-metadata-root-')); + const packageDir = path.join(temp, 'prebid.js'); + const metadataParent = path.join(packageDir, 'metadata'); + const metadataDir = path.join(metadataParent, 'modules'); + const externalMetadataDir = path.join(temp, 'external-metadata'); + const target = path.join(packageDir, 'dist', 'src', 'public', 'rubiconBidAdapter.js'); + fs.mkdirSync(metadataParent, { recursive: true }); + fs.mkdirSync(externalMetadataDir, { recursive: true }); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync( + path.join(externalMetadataDir, 'rubiconBidAdapter.json'), + JSON.stringify({ + components: [{ componentType: 'bidder', componentName: 'rubicon' }], + }) + ); + fs.writeFileSync(target, 'export {};\n'); + fs.symlinkSync(externalMetadataDir, metadataDir); + const createGeneratedPaths = vi.fn(); + const buildBundle = vi.fn(); + try { + await expect( + main( + [ + '--modules-json', + JSON.stringify({ bidder: ['rubiconBidAdapter'], userId: [], analytics: [] }), + '--out', + path.join(temp, 'out'), + ], + { + packageDir, + metadataDir, + resolveSpecifier: () => target, + createGeneratedPaths, + buildBundle, + } + ) + ).rejects.toThrow('must be a directory contained by the pinned package root'); + expect(createGeneratedPaths).not.toHaveBeenCalled(); + expect(buildBundle).not.toHaveBeenCalled(); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } }); - it('derives registered bidder codes including aliases from prebid metadata', () => { - // adfBidAdapter.js registers adf plus the adform/adformOpenRTB aliases. - expect(readAdapterBidderCodes(['adf'])).toEqual(['adf', 'adform', 'adformOpenRTB']); + it('fails an escaping package export before creating generated paths or invoking Vite', async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-escape-')); + const escapedTarget = path.join(temp, 'escaped.js'); + fs.writeFileSync(escapedTarget, 'export {};\n'); + const createGeneratedPaths = vi.fn(); + const buildBundle = vi.fn(); + try { + await expect( + main( + [ + '--modules-json', + JSON.stringify({ bidder: ['rubiconBidAdapter'], userId: [], analytics: [] }), + '--out', + path.join(temp, 'out'), + ], + { + resolveSpecifier: () => escapedTarget, + createGeneratedPaths, + buildBundle, + } + ) + ).rejects.toThrow('resolves outside the pinned prebid.js package'); + expect(createGeneratedPaths).not.toHaveBeenCalled(); + expect(buildBundle).not.toHaveBeenCalled(); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } }); - it('maps a module file stem to its registered bidder code', () => { - // a1MediaBidAdapter.js registers a1media — the stem itself is not a code. - const bidderCodes = readAdapterBidderCodes(['a1Media']); - expect(bidderCodes).toContain('a1media'); - expect(bidderCodes).not.toContain('a1Media'); + it.each(['liveIntentShim', 'liveIntentStandard', 'prebidGlobal'])( + 'fails an invalid %s target before creating generated paths or invoking Vite', + async (targetName) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-liveintent-')); + const createGeneratedPaths = vi.fn(); + const buildBundle = vi.fn(); + try { + await expect( + main( + [ + '--modules-json', + JSON.stringify({ + bidder: ['rubiconBidAdapter'], + userId: ['liveIntentIdSystem'], + analytics: [], + }), + '--out', + path.join(temp, 'out'), + ], + { + [targetName]: path.join(temp, 'missing.js'), + resolveSpecifier: (specifier) => require.resolve(specifier), + createGeneratedPaths, + buildBundle, + } + ) + ).rejects.toThrow('could not be resolved'); + expect(createGeneratedPaths).not.toHaveBeenCalled(); + expect(buildBundle).not.toHaveBeenCalled(); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + } + ); + + it('fails unsupported analytics before creating generated paths or invoking Vite', async () => { + const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-fail-')); + const createGeneratedPaths = vi.fn(); + const buildBundle = vi.fn(); + try { + let error; + try { + await main( + [ + '--modules-json', + JSON.stringify({ + bidder: ['rubiconBidAdapter'], + userId: [], + analytics: ['mavenDistributionAnalyticsAdapter'], + }), + '--out', + outputDirectory, + ], + { createGeneratedPaths, buildBundle } + ); + } catch (cause) { + error = String(cause); + } + expect(error).toContain( + 'integrations.prebid.bundle.modules.analytics requested "mavenDistributionAnalyticsAdapter"' + ); + expect(error).toContain('prebid.js 10.26.0'); + expect(error).toContain('modules/mavenDistributionAnalyticsAdapter.js'); + expect(error).toContain('Choose an analytics module shipped by the pinned prebid.js package'); + expect(createGeneratedPaths).not.toHaveBeenCalled(); + expect(buildBundle).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(outputDirectory, 'manifest.json'))).toBe(false); + } finally { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } }); - it('falls back to the module stem when no metadata is shipped', () => { - expect(readAdapterBidderCodes(['noSuchAdapterEver'])).toEqual(['noSuchAdapterEver']); + it('cleans generated source after a build failure', async () => { + const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-fail-')); + const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-generated-')); + const generatedPaths = { + temporaryDir, + modulesFile: path.join(temporaryDir, '_modules.generated.ts'), + entryFile: path.join(temporaryDir, '_external_entry.generated.ts'), + }; + try { + await expect( + main( + [ + '--modules-json', + JSON.stringify({ bidder: ['rubiconBidAdapter'], userId: [], analytics: [] }), + '--out', + outputDirectory, + ], + { + createGeneratedPaths: () => generatedPaths, + buildBundle: async () => { + throw new Error('example Vite failure'); + }, + } + ) + ).rejects.toThrow('example Vite failure'); + expect(fs.existsSync(temporaryDir)).toBe(false); + expect(fs.existsSync(path.join(outputDirectory, 'manifest.json'))).toBe(false); + } finally { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + fs.rmSync(temporaryDir, { recursive: true, force: true }); + } }); - it('includes generated User ID metadata in the production external bundle', async () => { + it('writes schema version 1 with effective module and runtime-code lists', async () => { const outputDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'trusted-server-prebid-build-test-') ); try { await main([ - '--adapters', - 'rubicon', - '--user-id-modules', - 'pairIdSystem,lockrAIMIdSystem', + '--modules-json', + JSON.stringify({ + bidder: ['rubiconBidAdapter'], + userId: ['pairIdSystem', 'sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + }), '--out', outputDirectory, ]); @@ -70,18 +742,25 @@ describe('build-prebid-external metadata', () => { ); const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); - expect(manifest.bidderCodes).toEqual(['rubicon']); - expect(bundle).toContain('"pairIdSystem"'); - expect(bundle).toContain('"lockrAIMIdSystem"'); + expect(manifest).toMatchObject({ + schemaVersion: 1, + prebidVersion: '10.26.0', + modules: { + bidder: ['rubiconBidAdapter'], + userId: ['pairIdSystem', 'sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + }, + runtimeCodes: { + bidder: ['rubicon'], + analytics: ['atsAnalytics'], + }, + }); + expect(manifest).not.toHaveProperty('adapters'); + expect(manifest).not.toHaveProperty('bidderCodes'); + expect(manifest).not.toHaveProperty('userIdModules'); + expect(bundle).toContain('atsAnalyticsAdapter'); } 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']); - - expect(parsed.outDir).toBe(path.resolve(process.cwd(), '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..3152d1e29 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 @@ -22,9 +22,21 @@ function apsRenderer() { * build-prebid-external.mjs). Individual tests override and restore it. */ const DEFAULT_BUNDLE_MANIFEST = { - adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - userIdModules: ['sharedIdSystem'], + schemaVersion: 1, + modules: { + bidder: [ + 'rubiconBidAdapter', + 'openxBidAdapter', + 'exampleBrowserBidAdapter', + 'appnexusBidAdapter', + ], + userId: ['sharedIdSystem'], + analytics: [], + }, + runtimeCodes: { + bidder: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + analytics: [], + }, }; /** Loose bid shape used by the requestBids shim tests. */ @@ -174,9 +186,21 @@ const { }; w.pbjs = mockPbjs; w.__tsjs_prebid_bundle = { - adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - bidderCodes: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], - userIdModules: ['sharedIdSystem'], + schemaVersion: 1, + modules: { + bidder: [ + 'rubiconBidAdapter', + 'openxBidAdapter', + 'exampleBrowserBidAdapter', + 'appnexusBidAdapter', + ], + userId: ['sharedIdSystem'], + analytics: [], + }, + runtimeCodes: { + bidder: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + analytics: [], + }, }; return { @@ -410,6 +434,7 @@ describe('prebid/installPrebidNpm', () => { mockGetUserIdsAsEids.mockReturnValue([]); mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; delete testWindow.__tsjs_prebid; delete testWindow.__tsjs_prebid_diagnostics; delete testWindow.tsjs; @@ -795,6 +820,71 @@ describe('prebid/installPrebidNpm', () => { }); }); + it('keeps User ID diagnostics when runtimeCodes is malformed', () => { + testWindow.__tsjs_prebid_bundle = { + schemaVersion: 1, + modules: DEFAULT_BUNDLE_MANIFEST.modules, + runtimeCodes: null, + }; + + installPrebidNpm(); + + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: [], + missingConfiguredUserIdNames: [], + }); + }); + + it('treats a non-object modules container independently from runtime codes', () => { + testWindow.__tsjs_prebid_bundle = { + schemaVersion: 1, + modules: null, + runtimeCodes: DEFAULT_BUNDLE_MANIFEST.runtimeCodes, + }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [{ name: 'sharedId' }] : {} + ); + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + + installPrebidNpm(); + + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules.includedModules).toEqual([]); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('rejects the whole User ID list while preserving valid bidder runtime codes', () => { + testWindow.__tsjs_prebid_bundle = { + schemaVersion: 1, + modules: { + ...DEFAULT_BUNDLE_MANIFEST.modules, + userId: ['sharedIdSystem', 42], + }, + runtimeCodes: DEFAULT_BUNDLE_MANIFEST.runtimeCodes, + }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [{ name: 'sharedId' }] : {} + ); + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); + + installPrebidNpm(); + + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: [], + configuredUserIdNames: ['sharedId'], + missingConfiguredUserIdNames: [], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes('did not stamp a User ID module manifest') + ) + ).toBe(true); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it('refreshes late User ID config without repeating missing-module warnings', () => { installPrebidNpm(); mockGetConfig.mockImplementation((key?: string) => @@ -4234,8 +4324,14 @@ describe('prebid/client-side bidders', () => { // rubicon is compiled into the external bundle, but openx is not testWindow.__tsjs_prebid_bundle = { ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['rubicon'], - bidderCodes: ['rubicon'], + modules: { + ...DEFAULT_BUNDLE_MANIFEST.modules, + bidder: ['rubiconBidAdapter'], + }, + runtimeCodes: { + ...DEFAULT_BUNDLE_MANIFEST.runtimeCodes, + bidder: ['rubicon'], + }, }; testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; @@ -4259,7 +4355,9 @@ describe('prebid/client-side bidders', () => { // The error should point at the operator surface: the CLI config key, // not the internal build script. const pointsAtBundleConfig = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle].adapters')) + args.some( + (a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle.modules].bidder') + ) ); expect(pointsAtBundleConfig).toBe(true); @@ -4273,13 +4371,19 @@ describe('prebid/client-side bidders', () => { testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); - it('accepts alias bidder codes stamped in bidderCodes', () => { - // The adf module registers adf plus the adform/adformOpenRTB aliases; - // the module-name list alone would flag them as missing. + it('accepts alias bidder runtime codes', () => { + // The adfBidAdapter module registers adf plus the adform and + // adformOpenRTB aliases. The module stem alone would flag them as missing. testWindow.__tsjs_prebid_bundle = { ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['adf'], - bidderCodes: ['adf', 'adform', 'adformOpenRTB'], + modules: { + ...DEFAULT_BUNDLE_MANIFEST.modules, + bidder: ['adfBidAdapter'], + }, + runtimeCodes: { + ...DEFAULT_BUNDLE_MANIFEST.runtimeCodes, + bidder: ['adf', 'adform', 'adformOpenRTB'], + }, }; testWindow.__tsjs_prebid = { clientSideBidders: ['adform'] }; @@ -4303,8 +4407,14 @@ describe('prebid/client-side bidders', () => { // must be flagged even though the module itself is compiled in. testWindow.__tsjs_prebid_bundle = { ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['a1Media'], - bidderCodes: ['a1media'], + modules: { + ...DEFAULT_BUNDLE_MANIFEST.modules, + bidder: ['a1MediaBidAdapter'], + }, + runtimeCodes: { + ...DEFAULT_BUNDLE_MANIFEST.runtimeCodes, + bidder: ['a1media'], + }, }; testWindow.__tsjs_prebid = { clientSideBidders: ['a1Media'] }; @@ -4325,9 +4435,20 @@ describe('prebid/client-side bidders', () => { testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); - it('treats a malformed manifest as unstamped instead of throwing', () => { - // The manifest is a plain window global any page script can overwrite. - testWindow.__tsjs_prebid_bundle = { adapters: 'rubicon', userIdModules: 42 }; + it.each([ + null, + [], + {}, + { schemaVersion: 0 }, + { schemaVersion: 2 }, + { schemaVersion: '1' }, + { + adapters: ['rubicon'], + bidderCodes: ['rubicon'], + userIdModules: ['sharedIdSystem'], + }, + ])('treats unsupported manifest %# as unstamped instead of throwing', (manifest) => { + testWindow.__tsjs_prebid_bundle = manifest; testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -4335,7 +4456,11 @@ describe('prebid/client-side bidders', () => { expect(() => installPrebidNpm()).not.toThrow(); const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) + args.some( + (a) => + typeof a === 'string' && + a.includes('did not stamp a supported bidder runtime-code manifest') + ) ); expect(hasManifestWarn).toBe(true); @@ -4343,7 +4468,35 @@ describe('prebid/client-side bidders', () => { testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); - it('warns when the external bundle stamped no adapter manifest', () => { + it('rejects a whole mixed bidder list while preserving module lists', () => { + testWindow.__tsjs_prebid_bundle = { + schemaVersion: 1, + modules: DEFAULT_BUNDLE_MANIFEST.modules, + runtimeCodes: { + ...DEFAULT_BUNDLE_MANIFEST.runtimeCodes, + bidder: ['rubicon', 42], + }, + }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + installPrebidNpm(); + + expect( + warnSpy.mock.calls.some((args) => + args.some( + (value) => + typeof value === 'string' && + value.includes('did not stamp a supported bidder runtime-code manifest') + ) + ) + ).toBe(true); + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules.includedModules).toEqual([ + 'sharedIdSystem', + ]); + }); + + it('warns when the external bundle stamped no bidder runtime-code manifest', () => { delete testWindow.__tsjs_prebid_bundle; testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; @@ -4352,7 +4505,11 @@ describe('prebid/client-side bidders', () => { installPrebidNpm(); const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) + args.some( + (a) => + typeof a === 'string' && + a.includes('did not stamp a supported bidder runtime-code manifest') + ) ); expect(hasManifestWarn).toBe(true); 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..14f72c951 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 @@ -1,20 +1,16 @@ // @vitest-environment node -// Builds and evaluates both production Prebid artifacts together: the -// external Prebid.js bundle (build-prebid-external.mjs) and the server-served -// tsjs shim (the same vite invocation build-all.mjs uses). This is the only -// coverage that proves the generated bundle entry populates the public API -// the real shim consumes — unit suites mock window.pbjs entirely. -// -// Runs in the node environment (vite/esbuild cannot run under jsdom globals) -// and evaluates the artifacts in an explicit JSDOM window instead. +// Build and evaluate both production Prebid artifacts together. Unit tests mock +// window.pbjs; this suite proves registration and queue behavior in the actual +// generated IIFE and the server-served shim. +import crypto from 'node:crypto'; 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 { JSDOM, requestInterceptor } from 'jsdom'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { main } from '../build-prebid-external.mjs'; @@ -23,24 +19,33 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const libDir = path.resolve(__dirname, '..'); let outputDirectory; -let bundleCode; +let analyticsArtifact; +let noAnalyticsArtifact; let shimCode; -let prebidVersion; + +async function buildArtifact(modules) { + await main(['--modules-json', JSON.stringify(modules), '--out', outputDirectory]); + const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); + const bundlePath = path.join(outputDirectory, manifest.filename); + return { + manifest, + bundleCode: fs.readFileSync(bundlePath, 'utf8'), + bundleBytes: fs.readFileSync(bundlePath), + }; +} beforeAll(async () => { outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-artifacts-')); - await main([ - '--adapters', - 'adf', - '--user-id-modules', - 'sharedIdSystem', - '--out', - outputDirectory, - ]); - const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); - bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - prebidVersion = manifest.prebidVersion; + analyticsArtifact = await buildArtifact({ + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + }); + noAnalyticsArtifact = await buildArtifact({ + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + }); const { build } = await import('vite'); await build({ @@ -73,141 +78,351 @@ afterAll(() => { fs.rmSync(outputDirectory, { recursive: true, force: true }); }); -describe('tsjs-prebid shim artifact', () => { - it('stays Prebid-free and uses only the external bundle public API', () => { - // The embedded version string and `_pbjsGlobals` are core markers. Prove - // they appear in the external artifact first so this test fails loudly if - // either marker rots instead of silently passing. - expect(bundleCode).toContain(prebidVersion); - expect(bundleCode).toContain('_pbjsGlobals'); - expect(shimCode).not.toContain(prebidVersion); - expect(shimCode).not.toContain('_pbjsGlobals'); +function createPage() { + const blockedResourceRequests = []; + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + resources: { + interceptors: [ + requestInterceptor(async (request) => { + blockedResourceRequests.push(request.url); + return new Response('', { status: 204 }); + }), + ], + }, + }); + dom.window.__blockedResourceRequests = blockedResourceRequests; + return dom; +} + +function installNetworkAndConsoleStubs(pageWindow) { + const requests = []; + const unexpectedTransports = []; + const fetchSpy = vi.fn(async (resource, init) => { + requests.push({ transport: 'fetch', resource, init }); + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + pageWindow.fetch = fetchSpy; + 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; + + pageWindow.XMLHttpRequest = class StubXmlHttpRequest { + readyState = 0; + status = 0; + responseText = ''; + onreadystatechange; + onload; + onerror; + + open(method, url) { + this.method = method; + this.url = new URL(url, 'https://pub.example.com').href; + this.readyState = 1; + } + + setRequestHeader() {} + + send(body) { + requests.push({ transport: 'xhr', method: this.method, url: this.url, body }); + this.readyState = 4; + this.status = 200; + this.responseText = '{}'; + queueMicrotask(() => { + this.onreadystatechange?.(); + this.onload?.(); + }); + } + + abort() {} + }; + + Object.defineProperty(pageWindow.navigator, 'sendBeacon', { + configurable: true, + value: vi.fn((url, body) => { + requests.push({ transport: 'beacon', url, body }); + return true; + }), + }); + + pageWindow.Image = class StubImage { + set src(url) { + requests.push({ transport: 'image', url }); + } + }; + pageWindow.WebSocket = class BlockedWebSocket { + constructor(url) { + unexpectedTransports.push({ transport: 'websocket', url: String(url) }); + throw new Error('WebSocket is blocked in the Prebid artifact test'); + } + }; + pageWindow.EventSource = class BlockedEventSource { + constructor(url) { + unexpectedTransports.push({ transport: 'eventsource', url: String(url) }); + throw new Error('EventSource is blocked in the Prebid artifact test'); + } + }; + + if (!('isSecureContext' in pageWindow)) { + pageWindow.isSecureContext = true; + } + + const consoleErrors = []; + const consoleWarnings = []; + pageWindow.console.error = vi.fn((...args) => consoleErrors.push(args.map(String).join(' '))); + pageWindow.console.warn = vi.fn((...args) => consoleWarnings.push(args.map(String).join(' '))); + + return { + fetchSpy, + requests, + consoleErrors, + consoleWarnings, + unexpectedTransports, + blockedResourceRequests: pageWindow.__blockedResourceRequests, + }; +} + +function installServerState(pageWindow, { analytics = false } = {}) { + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.__tsjs_prebid = { clientSideBidders: [] }; + + if (analytics) { + pageWindow.__analyticsLifecycle = { + started: false, + completed: false, + error: undefined, + }; + pageWindow.eval(` + window.pbjs.que.push(function () { + const state = window.__analyticsLifecycle; + state.started = true; + try { + window.pbjs.enableAnalytics({ + provider: 'atsAnalytics', + options: { pid: 'example-publisher-id' }, + }); + state.completed = true; + } catch (error) { + state.error = String(error && error.message ? error.message : error); + } + }); + `); + } +} + +function requestUrl(resource) { + return typeof resource === 'string' ? resource : String(resource?.url ?? resource); +} + +async function runAuction(pageWindow, fetchSpy) { + const slot = pageWindow.document.createElement('div'); + slot.id = 'ad-slot-1'; + pageWindow.document.body.appendChild(slot); + + pageWindow.pbjs.requestBids({ + adUnits: [ + { + code: 'ad-slot-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + }, + ], + timeout: 1000, + }); + + await vi.waitFor( + () => { + expect( + fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) + ).toBe(true); + }, + { timeout: 10_000 } + ); - // A value-import of Prebid or a private rendering helper would multiply - // the shim size; retain a margin above the normal compact shim output. - expect(bundleCode.length).toBeGreaterThan(200_000); + const [resource, init] = fetchSpy.mock.calls.find(([target]) => + requestUrl(target).includes('/auction') + ); + const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); + const method = init?.method ?? resource?.method; + expect(method).toBe('POST'); + const payload = JSON.parse(body); + const adUnit = payload.adUnits[0]; + expect(adUnit.code).toBe('ad-slot-1'); + const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); + expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); +} + +function expectManifest(manifest, analytics) { + expect(manifest).toMatchObject({ + schemaVersion: 1, + prebidVersion: '10.26.0', + modules: { + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + analytics: analytics ? ['atsAnalyticsAdapter'] : [], + }, + runtimeCodes: { + bidder: ['rubicon'], + analytics: analytics ? ['atsAnalytics'] : [], + }, + }); +} + +describe('tsjs-prebid production artifacts', () => { + it('keeps the served shim Prebid-free', () => { + expect(analyticsArtifact.bundleCode).toContain(analyticsArtifact.manifest.prebidVersion); + expect(analyticsArtifact.bundleCode).toContain('_pbjsGlobals'); + expect(shimCode).not.toContain(analyticsArtifact.manifest.prebidVersion); + expect(shimCode).not.toContain('_pbjsGlobals'); + expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); expect(shimCode.length).toBeLessThan(30_000); expect(shimCode).toContain('markWinningBidAsUsed'); }); -}); -describe('external bundle + served shim evaluated together', () => { - it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { - const dom = new JSDOM('', { - url: 'https://pub.example.com/article', - runScripts: 'outside-only', - pretendToBeVisual: true, - }); - const pageWindow = dom.window; - - // Stub the network before any artifact runs: Prebid's ajax module - // captures window.fetch at evaluation time and builds Request objects. - // jsdom ships none of the fetch API, so lend it Node's — with relative - // URLs resolved against the page, as a browser Request would. - const fetchSpy = vi.fn( - async () => - new Response('{}', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ); - pageWindow.fetch = fetchSpy; - 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; - } + it('registers ATS before the shim processes publisher callbacks', async () => { + const dom = createPage(); + try { + const pageWindow = dom.window; + const stubs = installNetworkAndConsoleStubs(pageWindow); + installServerState(pageWindow, { analytics: 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: [] }; - - pageWindow.eval(bundleCode); - - expect(typeof pageWindow.pbjs.requestBids).toBe('function'); - expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); - expect(pageWindow.__tsjs_prebid_bundle.adapters).toEqual(['adf']); - expect([...pageWindow.__tsjs_prebid_bundle.bidderCodes]).toEqual([ - 'adf', - 'adform', - 'adformOpenRTB', - ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); - - // Count trustedServer registrations across repeated shim evaluations. - const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); - const registerSpy = vi.fn(originalRegisterBidAdapter); - pageWindow.pbjs.registerBidAdapter = registerSpy; - - pageWindow.eval(shimCode); - const wrappedRequestBids = pageWindow.pbjs.requestBids; - - // 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); - - const trustedServerRegistrations = registerSpy.mock.calls.filter( - ([, bidderCode]) => bidderCode === 'trustedServer' - ); - expect(trustedServerRegistrations).toHaveLength(1); - expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); - expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); - - // Drive one real auction through the wrapped requestBids and assert the - // transformed request reaches /auction. - const slot = pageWindow.document.createElement('div'); - slot.id = 'ad-slot-1'; - pageWindow.document.body.appendChild(slot); - - pageWindow.pbjs.requestBids({ - adUnits: [ - { - code: 'ad-slot-1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], + pageWindow.eval(analyticsArtifact.bundleCode); + expect(pageWindow.__tsjs_prebid_bundle).toEqual({ + schemaVersion: 1, + modules: { + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], }, - ], - timeout: 1000, - }); + runtimeCodes: { + bidder: ['rubicon'], + analytics: ['atsAnalytics'], + }, + }); - const requestUrl = (resource) => - typeof resource === 'string' ? resource : String(resource?.url ?? resource); + const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); + const registerSpy = vi.fn(originalRegisterBidAdapter); + pageWindow.pbjs.registerBidAdapter = registerSpy; - await vi.waitFor( - () => { - expect( - fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) - ).toBe(true); - }, - { timeout: 10_000 } - ); - - const [resource, init] = fetchSpy.mock.calls.find(([target]) => - requestUrl(target).includes('/auction') - ); - const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); - const method = init?.method ?? resource?.method; - expect(method).toBe('POST'); - const payload = JSON.parse(body); - const adUnit = payload.adUnits[0]; - expect(adUnit.code).toBe('ad-slot-1'); - // The server-side bidder was folded into the trustedServer request - // instead of running client-side. - const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); - - dom.window.close(); + pageWindow.eval(shimCode); + const wrappedRequestBids = pageWindow.pbjs.requestBids; + pageWindow.eval(shimCode); + + await vi.waitFor(() => { + const state = pageWindow.__analyticsLifecycle; + expect(state.error).toBeUndefined(); + expect(state.completed).toBe(true); + }); + + expect(pageWindow.__analyticsLifecycle.started).toBe(true); + expect( + stubs.consoleErrors.some((message) => + message.includes("no analytics adapter found in registry for 'atsAnalytics'") + ) + ).toBe(false); + expect( + [...stubs.consoleErrors, ...stubs.consoleWarnings].some((message) => + message.includes('Error processing command') + ) + ).toBe(false); + + const trustedServerRegistrations = registerSpy.mock.calls.filter( + ([, bidderCode]) => bidderCode === 'trustedServer' + ); + expect(trustedServerRegistrations).toHaveLength(1); + expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); + expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); + + await runAuction(pageWindow, stubs.fetchSpy); + expect(stubs.unexpectedTransports).toEqual([]); + expect(stubs.blockedResourceRequests).toEqual([]); + } finally { + dom.window.close(); + } + }, 60_000); + + it('preserves auction behavior when analytics is omitted', async () => { + const dom = createPage(); + try { + const pageWindow = dom.window; + const stubs = installNetworkAndConsoleStubs(pageWindow); + installServerState(pageWindow); + + pageWindow.eval(noAnalyticsArtifact.bundleCode); + pageWindow.eval(shimCode); + + expect(pageWindow.__tsjs_prebid_bundle.modules.analytics).toEqual([]); + expect(pageWindow.__tsjs_prebid_bundle.runtimeCodes.analytics).toEqual([]); + await runAuction(pageWindow, stubs.fetchSpy); + expect(stubs.unexpectedTransports).toEqual([]); + expect(stubs.blockedResourceRequests).toEqual([]); + } finally { + dom.window.close(); + } }, 60_000); + + it('drains the publisher queue through the watchdog when the shim is absent', async () => { + vi.useFakeTimers(); + const dom = createPage(); + try { + const pageWindow = dom.window; + const stubs = installNetworkAndConsoleStubs(pageWindow); + pageWindow.setTimeout = globalThis.setTimeout; + pageWindow.clearTimeout = globalThis.clearTimeout; + installServerState(pageWindow); + pageWindow.__watchdogCallbackRan = false; + pageWindow.eval( + 'window.pbjs.que.push(function () { window.__watchdogCallbackRan = true; });' + ); + + pageWindow.eval(noAnalyticsArtifact.bundleCode); + const originalProcessQueue = pageWindow.pbjs.processQueue.bind(pageWindow.pbjs); + const processQueueSpy = vi.fn(originalProcessQueue); + pageWindow.pbjs.processQueue = processQueueSpy; + + await vi.advanceTimersByTimeAsync(4999); + expect(pageWindow.__watchdogCallbackRan).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(processQueueSpy).toHaveBeenCalledTimes(1); + expect(pageWindow.__watchdogCallbackRan).toBe(true); + expect(pageWindow.__tsjs_prebid_bundle.modules.analytics).toEqual([]); + expect(pageWindow.__tsjs_prebid_bundle.runtimeCodes.analytics).toEqual([]); + expect(stubs.unexpectedTransports).toEqual([]); + expect(stubs.blockedResourceRequests).toEqual([]); + } finally { + vi.useRealTimers(); + dom.window.close(); + } + }); + + it('records hashes and SRI for the no-analytics artifact', () => { + expectManifest(analyticsArtifact.manifest, true); + expectManifest(noAnalyticsArtifact.manifest, false); + + const sha256 = crypto + .createHash('sha256') + .update(noAnalyticsArtifact.bundleBytes) + .digest('hex'); + const sri = `sha384-${crypto + .createHash('sha384') + .update(noAnalyticsArtifact.bundleBytes) + .digest('base64')}`; + expect(noAnalyticsArtifact.manifest.sha256).toBe(sha256); + expect(noAnalyticsArtifact.manifest.filename).toBe(`trusted-prebid-${sha256}.js`); + expect(noAnalyticsArtifact.manifest.sri).toBe(sri); + }); }); From 73da6fe41ef0138dbdecd5ae76c83762fb3bdf6f Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 16:08:15 -0500 Subject: [PATCH 2/5] Document typed Prebid bundle modules --- docs/guide/cli.md | 25 +- docs/guide/integrations/prebid.md | 128 +- .../2026-08-28-prebid-bundle-module-map.md | 1143 +++++++++++++++++ ...6-08-28-prebid-bundle-module-map-design.md | 874 +++++++++++++ trusted-server.example.toml | 12 +- 5 files changed, 2135 insertions(+), 47 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-28-prebid-bundle-module-map.md create mode 100644 docs/superpowers/specs/2026-08-28-prebid-bundle-module-map-design.md diff --git a/docs/guide/cli.md b/docs/guide/cli.md index b6829895e..ca5979043 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -145,11 +145,17 @@ APIs. `trusted-server.toml`. ```toml -[integrations.prebid.bundle] -adapters = ["rubicon", "kargo"] -user_id_modules = ["sharedIdSystem"] +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter", "kargoBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] ``` +Module values are exact upstream Prebid filename stems without `.js`. The +`bidder` list is required and cannot be empty. Omit `user_id` to use the curated +default preset, or set it to `[]` to select none. Omitted and empty `analytics` +lists both select no analytics adapters. + Run the command after installing JS dependencies: ```bash @@ -158,11 +164,14 @@ cd ../../.. ts prebid bundle ``` -By default, generated artifacts are written to `dist/prebid/`, and the command -updates `integrations.prebid.external_bundle_sha256` and -`integrations.prebid.external_bundle_sri` in `trusted-server.toml`. Upload the -generated JavaScript file yourself, set `external_bundle_url` to its HTTPS -asset URL, and include that host (plus any redirect targets) in +By default, generated artifacts are written to `dist/prebid/`. The versioned +manifest records effective module selections, bidder and analytics runtime +codes, the content-addressed filename, SHA-256, and SRI. The command copies the +hash and SRI into `integrations.prebid` only after the generator and manifest +both pass validation. + +Upload the generated JavaScript file yourself, set `external_bundle_url` to its +HTTPS asset URL, and include that host plus any redirect targets in `proxy.allowed_domains` before running `ts config validate` or `ts config push`. Use custom paths when needed: diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 32f2827fb..c2c737168 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -24,7 +24,7 @@ debug = false # test_mode = false # Generated external Prebid bundle served through /integrations/prebid/bundle.js. -external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" # external_bundle_sha256 = "..." # external_bundle_sri = "sha384-..." @@ -42,12 +42,14 @@ script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.mi # Required when external_bundle_url is configured. Include the bundle host and # any HTTPS redirect targets used by that host. [proxy] -allowed_domains = ["assets.example"] +allowed_domains = ["assets.example.com"] # External bundle generation inputs used by `ts prebid bundle`. -[integrations.prebid.bundle] -adapters = ["rubicon"] -user_id_modules = ["sharedIdSystem"] +# Values are exact Prebid module stems without `.js`. +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] # Optional static per-bidder param overrides (shallow merge) [integrations.prebid.bid_param_overrides.criteo] @@ -88,13 +90,14 @@ set = { placementId = "_s2sHeaderPlacement" } | `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side. See [Client-Side Bidders](#client-side-bidders) | | `excluded_gam_ad_unit_path_suffixes` | Array[String] | `[]` | Exact, case-sensitive GAM ad-unit-path suffixes excluded from Trusted Server's Prebid refresh auction; matching slots still refresh through GAM | | `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 | +| `bundle.modules.bidder` | Array[String] | Required and non-empty | Exact Prebid bidder module stems imported into the external bundle, such as `rubiconBidAdapter` | +| `bundle.modules.user_id` | Array[String] | Curated preset when omitted | Curated User ID module stems; an empty array selects none | +| `bundle.modules.analytics` | Array[String] | `[]` | Analytics adapter module stems; omission or an empty array selects none | ## External Bundle Generation Use `ts prebid bundle` to build the publisher-specific browser bundle from -`[integrations.prebid.bundle]` selections: +`[integrations.prebid.bundle.modules]` selections: ```bash ts prebid bundle @@ -107,16 +110,33 @@ the generated manifest. Upload the generated JavaScript file manually, set any redirect targets) in `proxy.allowed_domains` before running `ts config validate` or `ts config push`. -The generated bundle is pure Prebid.js — core, consent modules, User ID -modules, and the selected bid adapters. The Trusted Server shim -(`tsjs-prebid`) is served separately by the server as a deferred script and -installs itself onto the `window.pbjs` global the bundle populates. The two -artifacts ship in lockstep: a bundle generated before the shim was split out -still carries a baked-in copy of the shim, so upgrading the server requires -regenerating and re-uploading the bundle (and pushing the updated -`external_bundle_sha256`/`external_bundle_sri` config) as part of the same -rollout. The shim refuses to install twice on one page via the -`window.__tsjsPrebidShimInstalled` sentinel. +Each configured value is the exact filename stem from the pinned Prebid.js +package. Do not add `.js`. Trusted Server checks the package lock, installed +version, exact-case metadata type, and resolved package export before Vite runs. +Local paths, URLs, package specifiers, and modules that are absent from the +pinned package are rejected. + +Module stems are build-time names. Runtime APIs use the codes registered by +those modules: + +| Module stem | Runtime setting | +| --------------------- | --------------------------------------------------------- | +| `rubiconBidAdapter` | `client_side_bidders = ["rubicon"]` | +| `atsAnalyticsAdapter` | `pbjs.enableAnalytics({ provider: "atsAnalytics", ... })` | + +Omitting `user_id` selects the curated default preset. Set `user_id = []` to +exclude all User ID modules. Omitted and empty `analytics` lists both select no +analytics adapters. The generated schema-versioned manifest records the +effective module lists and the bidder and analytics runtime codes. Regenerating +a bundle changes its content-addressed filename, SHA-256, and SRI when its +contents change. + +The external artifact contains Prebid core, consent modules, and the selected +modules. The separate deferred `tsjs-prebid` shim installs the `trustedServer` +adapter on the same `window.pbjs` object and processes the publisher queue. A +bundle generated before the shim split still carries a baked-in shim, so upgrade +that bundle with the server and push its new hash and SRI. The sentinel +`window.__tsjsPrebidShimInstalled` prevents duplicate shim installation. ## Debug Mode @@ -374,7 +394,7 @@ suffix list into the same page. Deploy the updated Trusted Server application an configuration together; this option does not require regenerating the external Prebid bundle. Follow the [External Bundle Generation](#external-bundle-generation) migration note only when upgrading a bundle generated before the shim split, or when changing -external Prebid adapters or User ID modules. +external Prebid bidder, User ID, or analytics modules. ## Client-Side Bidders @@ -400,20 +420,28 @@ The two lists are independent — the operator manages both explicitly. If a bid ### External bundle adapter selection -Client-side bidders need their Prebid.js adapter modules included in the generated external bundle: +Client-side bidders need their exact Prebid.js module stems in the generated +bundle: -```bash -cd crates/trusted-server-js/lib -npm run build:prebid-external -- \ - --adapters=rubicon,appnexus,openx \ - --user-id-modules=sharedIdSystem,uid2IdSystem \ - --out=dist/prebid +```toml +[integrations.prebid] +client_side_bidders = ["rubicon", "appnexus", "openx"] + +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter", "appnexusBidAdapter", "openxBidAdapter"] +user_id = ["sharedIdSystem", "uid2IdSystem"] ``` -The generator validates that each adapter exists in `prebid.js/modules/{name}BidAdapter.js`, writes a content-addressed bundle plus `manifest.json`, and reports the SHA-256 and SRI values to copy into `integrations.prebid` config. At runtime, TSJS validates that every bidder in `client_side_bidders` has a registered adapter and logs an error if one is missing. +Run `ts prebid bundle` after changing the module list. The generator resolves +`prebid.js/modules/.js` through the pinned package and records both stems +and registered bidder codes in `manifest.json`. At runtime, TSJS checks each +`client_side_bidders` runtime code against that manifest. ::: warning -Adding a new client-side bidder requires both a config change (`client_side_bidders`) **and** a regenerated external bundle with the adapter included in `--adapters`. Without the adapter in the bundle, the bidder is silently dropped from both server-side and client-side auctions. +A new client-side bidder requires its runtime code in `client_side_bidders` and +its exact module stem in `bundle.modules.bidder`. Rebuild and upload the bundle +after either change. Without the module, the bidder is dropped from both auction +paths. ::: ## User ID Modules @@ -422,11 +450,11 @@ Prebid.js can expose publisher-configured User ID Module output via `pbjs.getUserIdsAsEids()`. The TSJS Prebid shim reads those current-request EIDs after auctions and forwards them to Trusted Server when they are available. -User ID submodule inclusion is selected by the external bundle generator. The -available modules and default preset are checked in at -`crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json`. Pass -`--user-id-modules` to `build-prebid-external.mjs` when a publisher needs a -specific subset; omit it to use the default preset. +User ID submodule inclusion comes from `bundle.modules.user_id`. The available +modules and default preset are checked in at +`crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json`. +Omit `user_id` to use that preset, provide an explicit list for a publisher +subset, or use `user_id = []` to include none. This is deliberate: the external bundle is pure Prebid.js (core, consent and User ID modules, and client-side bid adapters) while the server-served TSJS @@ -455,8 +483,38 @@ Example EID source mapping: | `id5-sync.com` | `id5IdSystem` | | `liveramp.com` | `identityLinkIdSystem` | -User ID module selection is separate from `--adapters`, which controls -client-side bidder adapter modules. +User ID and bidder selections are separate typed lists in the same `modules` +table. + +## Analytics adapters + +Add analytics modules by exact stem. For the pinned Prebid.js 10.26.0 package, +ATS uses this build selection: + +```toml +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +analytics = ["atsAnalyticsAdapter"] +``` + +Publisher JavaScript still owns provider options and enablement: + +```js +pbjs.que.push(() => { + pbjs.enableAnalytics({ + provider: 'atsAnalytics', + options: { pid: 'example-publisher-id' }, + }) +}) +``` + +`atsAnalyticsAdapter` is the module stem, while `atsAnalytics` is the registered +runtime provider. Trusted Server imports the module but does not call +`pbjs.enableAnalytics`. + +Only analytics modules shipped by the pinned Prebid package can be selected. +Prebid.js 10.26.0 does not include `mavenDistributionAnalyticsAdapter`. Custom +files, local paths, URLs, and automatic downloads are not supported. ## Identity Forwarding diff --git a/docs/superpowers/plans/2026-08-28-prebid-bundle-module-map.md b/docs/superpowers/plans/2026-08-28-prebid-bundle-module-map.md new file mode 100644 index 000000000..24717841a --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-prebid-bundle-module-map.md @@ -0,0 +1,1143 @@ +# Prebid bundle module map implementation plan + +> **For agentic workers:** Execute this plan task by task. Keep the design in +> `docs/superpowers/specs/2026-08-28-prebid-bundle-module-map-design.md` open as +> the authority for schema, validation, manifest, and lifecycle decisions. Use +> red-green tests for every behavioral step and do not weaken a spec requirement +> to make an existing test pass. + +**Goal:** Replace the old Prebid bidder/User ID bundle fields with one typed +module map, add pinned-package analytics adapters, and prove that a generated +bundle registers `atsAnalytics` before publisher queue code enables it. + +**Architecture:** The Rust CLI owns focused TOML parsing and serializes one typed +module request. The Node generator independently validates that request, the +lockfile/install version pair, exact-case Prebid metadata, package-export target +containment, and module kind before it writes generated source or invokes Vite. +The generated artifact stamps one versioned nested manifest consumed strictly by +the TSJS shim. Publisher code remains responsible for calling +`pbjs.enableAnalytics` with provider options. + +**Tech stack:** Rust 2024, `serde`, `serde_json`, `toml`, `toml_edit`, Node 24, +Prebid.js 10.26.0, Vite, Vitest, and JSDOM. + +**Issue:** [#1085](https://github.com/IABTechLab/trusted-server/issues/1085) + +--- + +## Implementation preconditions + +- Work on a feature branch based on the latest `origin/main`. The checkout used + to write this plan was four commits behind, and `trusted-server.example.toml` + has relevant upstream edits. +- Preserve the approved spec and this plan when moving to the implementation + branch. +- Install JavaScript dependencies with the repository's pinned tooling before + running JS or docs gates: + + ```bash + REPO_ROOT=$(git rev-parse --show-toplevel) + (cd "$REPO_ROOT/crates/trusted-server-js/lib" && npm ci) + (cd "$REPO_ROOT/docs" && npm ci) + ``` + +- Treat `docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md` and + `docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md` + as historical records. Do not rewrite them. +- Do not add compatibility parsing for `adapters`, `user_id_modules`, old + generator flags, or flat manifest fields. +- Use only fictional/example values in tests and docs. + +## File map + +| File | Responsibility in this change | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `crates/trusted-server-cli/src/prebid_bundle.rs` | Typed focused TOML schema, removed-field diagnostics, module-name validation, generator JSON request, manifest schema validation, and CLI tests | +| `crates/trusted-server-js/lib/build-prebid-external.mjs` | Request parsing, dependency/version checks, package resolution, metadata validation, generated imports, Vite orchestration, and disk/browser manifest construction | +| `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` | Generator request, resolver, rendering, failure-order, manifest, and no-analytics coverage | +| `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` | Production external bundle plus production shim evaluation, ATS registration, queue processing, network isolation, and auction regression coverage | +| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Strict nested browser-manifest parser and bidder/User ID diagnostic consumers | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Manifest version, container, list, bidder, and User ID diagnostic coverage | +| `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` | Curated User ID membership/default/diagnostic data with import paths removed | +| `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts` | Registry typing after import paths stop being registry authority | +| `trusted-server.example.toml` | Canonical module-map example | +| `docs/guide/integrations/prebid.md` | Full operator semantics, module/runtime-code mapping, limitations, and bundle workflow | +| `docs/guide/cli.md` | `ts prebid bundle` example and output behavior | + +## Commit boundaries + +Task 1 may be committed independently because it adds tested resolver +foundations without changing the active generator protocol. Tasks 2 through 5 +form one breaking cutover and should remain uncommitted until the generator, +shim, artifact test, and Rust CLI all agree on the new contract. Task 6 is a +documentation commit. Verification fixes in Task 7 should be folded into the +commit that introduced the affected behavior. + +--- + +## Task 1: Establish the generator's typed validation foundation + +**Files:** + +- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` + +### Step 1.1: Record the baseline + +Run the existing focused suites before editing: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/build-prebid-external.test.mjs +npx vitest run test/integrations/prebid/index.test.ts +npx vitest run test/prebid-artifact-integration.test.mjs +``` + +Expected: all pass. Record any unrelated baseline failure before proceeding. + +Also verify the package-export behavior that the resolver must preserve: + +```bash +node --input-type=module <<'NODE' +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +console.log(require.resolve('prebid.js/modules/sharedIdSystem.js')) +console.log(require.resolve('prebid.js/modules/atsAnalyticsAdapter.js')) +NODE +``` + +Expected: both resolve under `node_modules/prebid.js/dist/src/public/`. Do not +require a physical `node_modules/prebid.js/modules/sharedIdSystem.js` source +file. + +### Step 1.2: Add failing request-schema and normalization tests + +Add table-driven tests for a pure parser/normalizer that accepts the JSON shape: + +```json +{ + "bidder": ["rubiconBidAdapter"], + "userId": ["sharedIdSystem"], + "analytics": ["atsAnalyticsAdapter"] +} +``` + +Cover: + +- required, non-empty `bidder`; +- omitted `userId` expands to `user_id_modules.json`'s default preset; +- explicit `userId: []` remains empty; +- omitted or explicit empty `analytics` normalizes to an empty list; +- unknown request properties; +- non-array lists and non-string entries; +- empty/whitespace values; +- `.js`, `/`, `\\`, `..`, quotes, URL-like strings, and control characters; +- duplicate names within one kind; and +- a stem repeated across kinds. + +Every error assertion must include the TOML-facing field path, such as +`integrations.prebid.bundle.modules.analytics`, rather than only the internal +JSON key. + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/build-prebid-external.test.mjs +``` + +Expected: the new tests fail because the typed parser and normalizer do not yet +exist. + +### Step 1.3: Implement the pure request model + +In `build-prebid-external.mjs`, add one normalized model with kind order: + +1. `bidder`; +2. `userId`; and +3. `analytics`. + +Use one module-stem validator implementing `^[A-Za-z0-9_-]+$`. Preserve +configured list order. Reject duplicates rather than deduplicating selections. +Keep a fixed mapping from internal JSON kind to TOML error path and Prebid +metadata component type: + +| JSON kind | TOML kind | Metadata type | +| ----------- | ----------- | ------------- | +| `bidder` | `bidder` | `bidder` | +| `userId` | `user_id` | `userId` | +| `analytics` | `analytics` | `analytics` | + +Export only the small pure functions needed by tests. Do not route `main()` +through the new model yet. + +### Step 1.4: Add failing lockfile/install version tests + +Add an injectable helper that reads: + +- `package-lock.json` at `packages["node_modules/prebid.js"].version`; and +- `node_modules/prebid.js/package.json` at `version`. + +Use temporary fixture files to cover: + +- matching versions; +- mismatched versions; +- missing lockfile package entry; +- missing installed version; and +- malformed JSON. + +The mismatch error must report both values and instruct the operator to run +`npm ci` in `crates/trusted-server-js/lib`. + +### Step 1.5: Implement dependency-version validation + +Implement the helper without changing `prebidPackageVersion()` call order yet. +Return the verified installed version so later tasks use one value for the +manifest. Keep all errors prefixed with `[build-prebid-external]`. + +### Step 1.6: Add failing exact-case metadata and package-target tests + +Create a resolver seam whose filesystem roots and package-specifier resolver can +be injected. Tests must cover: + +- `rubiconBidAdapter` as `bidder`; +- `sharedIdSystem` as `userId` without a physical source module file; +- `atsAnalyticsAdapter` as `analytics`; +- wrong-case metadata stems; +- missing metadata; +- metadata whose matching component type is absent; +- malformed metadata JSON; +- non-array `components`; +- matching components with missing, empty, or non-string `componentName`; +- mixed valid and malformed matching components; +- a metadata symlink escaping the metadata directory; +- a package specifier that cannot resolve; +- a resolved target outside the canonical Prebid package root; +- a non-regular resolved target; +- bidder aliases from `adfBidAdapter` metadata; and +- analytics runtime code `atsAnalytics` from ATS metadata. + +Use dependency injection for `resolveSpecifier` rather than modifying the real +installed package. Metadata-shape errors must include the +`[build-prebid-external]` prefix, TOML field path, requested stem, and metadata +path. These foundation tests prove resolution fails; Task 2 adds the +orchestration spy that proves Vite is not invoked. + +### Step 1.7: Implement module resolution + +For each normalized selection: + +1. Read the metadata directory and require an exact-case `.json` entry. +2. Canonicalize the metadata root and file. +3. Require the metadata file to be a regular direct child of that root. +4. Parse metadata and require `components` to be an array. +5. Require at least one component of the configured kind. Every matching + component must have a non-empty string `componentName`; reject the entire + module if valid and malformed matching components are mixed. +6. Derive matching component names, deduplicate them, and sort them. +7. Derive `prebid.js/modules/.js` from the validated stem. +8. Resolve that exact specifier with the production `require.resolve`. +9. Canonicalize the installed package root and resolved target. +10. Require a regular resolved target contained by the package root. + +For `userId`, also require membership in the checked-in User ID registry. Do not +read an import path from that registry. + +For LiveIntent, validate the ordinary upstream metadata and package export first. +Then validate all three fixed alias targets before Vite: + +- canonicalize the checked-in shim and require the exact expected regular + repository file; +- canonicalize `PREBID_LIVE_INTENT_STANDARD` and require a regular file contained + by the canonical Prebid package root; and +- canonicalize `PREBID_GLOBAL_MODULE` and require a regular file contained by the + canonical Prebid package root. + +Add an injected failure test for each target and prove the build runner is not +called. Keep these generator-owned aliases as the only local import overrides. + +### Step 1.8: Run the foundation tests and commit + +Format the changed `.mjs` files explicitly because the package's `npm run +format` glob does not include that extension: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/build-prebid-external.test.mjs +npx prettier --write build-prebid-external.mjs test/build-prebid-external.test.mjs +npx prettier --check build-prebid-external.mjs test/build-prebid-external.test.mjs +npm run format +``` + +Expected: all existing tests and the new pure foundation tests pass while the +active generator still uses the old protocol. + +Review the diff, then commit from the repository root: + +```bash +cd "$(git rev-parse --show-toplevel)" +git add crates/trusted-server-js/lib/build-prebid-external.mjs \ + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +git commit -m "Add typed Prebid module resolution" +``` + +--- + +## Task 2: Cut the JavaScript generator over to the module map + +**Files:** + +- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` if registry-shape assertions require it + +Do not commit this task by itself. The generator protocol and flat browser +manifest become incompatible with the old CLI and shim until Tasks 3 through 5 +finish. + +### Step 2.1: Replace argument-parser tests + +Write failing tests proving `parseArgs`: + +- requires exactly one `--modules-json` value; +- accepts `--modules-json=` and the two-argument form; +- preserves relative `--out` resolution against `process.cwd()`; +- rejects duplicate options; +- rejects positional arguments; +- rejects unknown options; +- rejects removed `--adapters` and `--user-id-modules`; and +- surfaces malformed module JSON through the typed request parser. + +Remove tests for the `rubicon` default and comma-list parsing. There is no default +bidder selection in the new schema. + +### Step 2.2: Remove registry import-path authority + +Delete every `importPath` property from `user_id_modules.json` and from +`PrebidUserIdModuleRegistryEntry` in `user_id_modules.ts`. + +Update registry tests if needed. The generator must derive every User ID package +specifier from the validated stem. `notes` remains valid for LiveIntent +diagnostics/documentation. + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/user_id_modules.test.ts +``` + +Expected: pass after the registry typing is updated. + +### Step 2.3: Add failing generated-source tests + +Extract pure rendering functions and test exact source properties: + +- imports appear in bidder, User ID, analytics kind order; +- configured order is preserved within each kind; +- `userId.js` is imported only when the effective User ID list is non-empty; +- package specifiers are derived from validated stems; +- an empty analytics list emits no analytics import; +- the generated export contains effective module arrays; +- runtime bidder and analytics code arrays are sorted/deduplicated; and +- the generated browser manifest uses `schemaVersion`, `modules`, and + `runtimeCodes` only. + +Do not inspect a minified IIFE to prove import presence. Assert against the pure +rendered source. + +### Step 2.4: Replace category-specific generated files + +Refactor the temporary generation model to use one normalized module selection +and one manifest source of truth. A single `_modules.generated.ts` is preferred, +but separate files are acceptable only if they all consume the same normalized +resolved-module array. + +Remove: + +- `DEFAULT_PREBID_ADAPTERS`; +- `parseList`; +- old adapter name suffixing; +- `generateAdapterImports`; +- registry-provided User ID import paths; +- the adapter-specific generated export; and +- the flat `adapters`, `bidderCodes`, and `userIdModules` browser-manifest + rendering. + +Retain the existing fixed core/consent imports, watchdog, Vite aliases, bundle +hashing, SRI, temporary output filename, final atomic rename, and cleanup +behavior. + +### Step 2.5: Make validation precede temporary generation and Vite + +Change orchestration order to: + +1. parse CLI arguments and module JSON; +2. verify lockfile and installed Prebid versions; +3. normalize defaults; +4. validate every metadata entry and package-export target; +5. validate the fixed LiveIntent shim if selected; +6. create temporary generated paths; +7. render generated source; +8. invoke Vite; +9. hash and rename the bundle; and +10. write `manifest.json`. + +Expose or inject the Vite build runner so tests can prove steps 1 through 5 fail +before temporary generation and before Vite. Preserve `finally` cleanup for every +failure after temporary paths exist. + +### Step 2.6: Emit disk manifest schema version 1 + +Write exactly this shape, with effective lists even when TOML omitted optional +categories: + +```json +{ + "schemaVersion": 1, + "prebidVersion": "10.26.0", + "modules": { + "bidder": ["rubiconBidAdapter"], + "userId": ["sharedIdSystem"], + "analytics": ["atsAnalyticsAdapter"] + }, + "runtimeCodes": { + "bidder": ["rubicon"], + "analytics": ["atsAnalytics"] + }, + "sha256": "...", + "sri": "sha384-...", + "filename": "trusted-prebid-.js" +} +``` + +Remove the flat manifest fields with no fallback. + +### Step 2.7: Add generator orchestration failures + +Add focused tests for: + +- lock/install mismatch before temporary generation; +- guaranteed-fictional missing stems; +- `mavenDistributionAnalyticsAdapter` missing from pinned Prebid 10.26.0; +- wrong-kind `sharedIdSystem` under analytics; +- an unknown curated User ID module; +- package-target escape through the injected resolver; +- cleanup after a rendered-source or Vite failure; and +- no new bundle, manifest, or generated-source artifact written after validation + failure. + +The Maven Distribution error must contain: + +- `integrations.prebid.bundle.modules.analytics`; +- `mavenDistributionAnalyticsAdapter`; +- the verified pinned version; +- the expected package specifier; and +- guidance that only pinned upstream modules are supported. + +### Step 2.8: Run focused generator tests + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/build-prebid-external.test.mjs +npx prettier --write build-prebid-external.mjs test/build-prebid-external.test.mjs +npx prettier --check build-prebid-external.mjs test/build-prebid-external.test.mjs +npm run format +``` + +Expected: pass. Do not run or claim the full JS suite yet; the shim and artifact +fixture still expect the removed flat manifest. + +--- + +## Task 3: Migrate the TSJS shim to the versioned browser manifest + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +Do not commit this task separately from Tasks 2, 4, and 5. + +### Step 3.1: Add strict parser tests + +Add focused tests for a pure or directly observable browser-manifest parser: + +- valid `schemaVersion: 1` with all nested arrays; +- non-object root; +- absent version; +- numeric versions `0` and `2`; +- string version `"1"`; +- absent/non-object `modules`; +- absent/non-object `runtimeCodes`; +- mixed string/non-string `modules.userId`; +- mixed string/non-string `runtimeCodes.bidder`; +- one malformed list leaving a valid sibling list usable; and +- removed flat fields being ignored even when present. + +The list parser rejects a whole invalid list. It does not filter invalid entries. +Unsupported versions follow the same one-time diagnostic path as an absent +manifest. + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts +``` + +Expected: new tests fail against the flat, filtering parser. + +### Step 3.2: Implement the nested parser + +Replace `ExternalPrebidBundleManifest`, `sanitizeManifestList`, and +`getExternalBundleManifest` with a schema-versioned nested model matching the +spec. + +Parsing rules: + +- invalid root/version makes the whole manifest unavailable; +- invalid/missing container makes fields in that container unavailable; +- invalid list makes only that list unavailable; +- sibling lists survive independently; and +- no old flat-field fallback exists. + +Keep the page global untrusted. Do not rely on TypeScript casting without runtime +checks. + +### Step 3.3: Move diagnostic consumers + +Update: + +- client-side bidder validation to read `runtimeCodes.bidder`; +- User ID diagnostics to read `modules.userId`; and +- diagnostic comments/error messages to name + `[integrations.prebid.bundle.modules].bidder` with exact module stems. + +Do not use `modules.analytics` as provider codes and do not make the TSJS shim +call `pbjs.enableAnalytics`. + +### Step 3.4: Rewrite flat-manifest fixtures + +Update all `__tsjs_prebid_bundle` fixtures in `index.test.ts` to schema version 1. +Replace alias tests with exact bidder module stems plus derived runtime codes. +Keep tests proving: + +- `adfBidAdapter` includes `adf`, `adform`, and `adformOpenRTB` runtime codes; +- `a1MediaBidAdapter` maps to `a1media`; and +- missing runtime bidder codes still produce the current actionable diagnostic. + +### Step 3.5: Run shim tests + +Run: + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/prebid/index.test.ts +npm run format +``` + +Expected: pass. + +--- + +## Task 4: Prove the production ATS registration and artifact lifecycle + +**Files:** + +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` +- Modify: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` if production orchestration needs an additional seam assertion + +Do not commit this task separately from Tasks 2, 3, and 5. + +### Step 4.1: Update the artifact build request + +Replace old generator flags with one `--modules-json` argument. Use the +specification's required production fixture: + +```json +{ + "bidder": ["rubiconBidAdapter"], + "userId": ["sharedIdSystem"], + "analytics": ["atsAnalyticsAdapter"] +} +``` + +Update manifest assertions to: + +- `schemaVersion === 1`; +- `modules.bidder === ["rubiconBidAdapter"]`; +- `modules.userId === ["sharedIdSystem"]`; +- `modules.analytics === ["atsAnalyticsAdapter"]`; +- `runtimeCodes.bidder === ["rubicon"]`; and +- `runtimeCodes.analytics === ["atsAnalytics"]`. + +Keep `adfBidAdapter` alias coverage in the focused resolver and shim tests from +Tasks 1 and 3. Keep the existing bundle-size, shim-size, public API, single +Trusted Server registration, and `/auction` assertions in the production test. + +### Step 4.2: Enqueue analytics before artifact evaluation + +Before evaluating either artifact: + +1. Replace `window.console.error` and any Prebid error-log path with spies that + retain messages for assertion. +2. Stub `fetch`, `Request`, `Headers`, `Response`, `AbortController`, + `XMLHttpRequest`, `navigator.sendBeacon`, and image/network primitives used by + the adapter. Reuse existing stubs where adequate. +3. Create the same `{ que: [], cmd: [] }` stub emitted by the server. +4. Push one queue callback that records `started`, calls + `pbjs.enableAnalytics` with provider `atsAnalytics` and fictional + `options.pid`, catches/stores any error, and records `completed` only after + the call returns. + +The test must prevent real traffic while allowing calls into spies. It need not +assert that ATS sends no request to the spies. + +### Step 4.3: Evaluate both production artifacts and wait for the callback + +Evaluate the external bundle first and the production TSJS shim second. Use +`vi.waitFor` until the callback either completes or stores an error. + +Assert: + +- the callback started and completed; +- it stored no error; +- no console message contains the exact missing-registry diagnostic for + `atsAnalytics`; +- no console message contains `Error processing command`; +- every attempted analytics request went through a stub; and +- the existing Trusted Server auction still reaches `/auction`. + +This proves module evaluation happened before the shim called +`pbjs.processQueue()`. Do not trigger queue processing directly in the test. + +### Step 4.4: Add normal and watchdog no-analytics production cases + +Build a second production artifact with `rubiconBidAdapter`, `sharedIdSystem`, +and analytics omitted. Reuse that bundle across two isolated JSDOM tests. + +In the normal shim lifecycle test, evaluate the no-analytics external bundle and +production shim, then retain the bidder, User ID, `/auction`, hash, and SRI +assertions. Assert `modules.analytics` and `runtimeCodes.analytics` are empty. + +In a distinct watchdog test: + +1. Install a JSDOM-compatible fake clock before bundle evaluation. +2. Create the server-style `pbjs` queue and enqueue a sentinel callback. +3. Evaluate the no-analytics external bundle without evaluating the shim. +4. Assert the watchdog timer was scheduled for 5,000 ms. +5. Advance the fake clock by 5,000 ms and wait for queued work. +6. Assert the watchdog called `pbjs.processQueue()` and the sentinel callback + ran. +7. Assert the nested analytics module and runtime-code arrays are empty. +8. Restore timers and close the JSDOM window in `finally` cleanup. + +Keep the exact "no generated analytics import" assertion in the pure renderer +unit test from Task 2, not against the minified IIFE. + +### Step 4.5: Run production artifact tests + +Run: + +```bash +cd crates/trusted-server-js/lib +npx prettier --write test/prebid-artifact-integration.test.mjs +npx prettier --check build-prebid-external.mjs \ + test/build-prebid-external.test.mjs \ + test/prebid-artifact-integration.test.mjs +npx vitest run test/prebid-artifact-integration.test.mjs +npx vitest run test/build-prebid-external.test.mjs test/integrations/prebid/index.test.ts +``` + +Expected: pass without external network access. + +--- + +## Task 5: Cut `ts prebid bundle` over to the typed TOML map + +**Files:** + +- Modify: `crates/trusted-server-cli/src/prebid_bundle.rs` + +Do not commit until every step in this task and the cross-language smoke test +passes. + +### Step 5.1: Add failing focused configuration tests + +Replace old bundle config fixtures with: + +```toml +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter", "kargoBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] +``` + +Add table-driven tests covering: + +- valid complete map; +- omitted `user_id` and `analytics` represented as `None`; +- explicit empty `user_id` and analytics represented as `Some(Vec::new())`; +- missing `bundle`; +- missing `bundle.modules`; +- missing or empty bidder list; +- unknown module kind; +- malformed list/value types; +- every invalid stem class from the spec; +- duplicates within a list; +- duplicates across kinds; and +- configured order preservation. + +### Step 5.2: Add deterministic removed-field diagnostics + +Before typed deserialization, inspect the focused bundle table for these keys in +fixed order: + +1. `adapters`; +2. `user_id_modules`; and +3. `analytics_adapters`. + +Test each key by itself and mixed with a valid new `modules` table. Each error +must name the removed field and its exact replacement path. After this preflight, +unknown fields should flow through `deny_unknown_fields` and retain focused +config-path context. + +### Step 5.3: Implement the typed Rust model + +Add private structures equivalent to: + +```rust +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrebidBundleSection { + modules: PrebidBundleModules, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +struct PrebidBundleModules { + bidder: Vec, + #[serde(default)] + user_id: Option>, + #[serde(default)] + analytics: Option>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PrebidBundleModuleRequest<'a> { + bidder: &'a [PrebidModuleName], + #[serde(skip_serializing_if = "Option::is_none")] + user_id: Option<&'a [PrebidModuleName]>, + #[serde(skip_serializing_if = "Option::is_none")] + analytics: Option<&'a [PrebidModuleName]>, +} +``` + +Use a validated `PrebidModuleName` newtype that also implements `Serialize`. +Keep fields private unless an existing test seam requires `pub(crate)`. The +separate request struct makes TOML `user_id` serialize as JSON `userId` without +mixing the two wire formats. + +Focused loading must continue reading `external_bundle_url` independently from +`[integrations.prebid]` and must not require full runtime config validity. + +### Step 5.4: Add failing npm argument tests + +Update `PrebidBundleGenerateRequest` to carry the typed module map. Test that +`npm_prebid_bundle_args` produces: + +```text +run +build:prebid-external +-- +--modules-json +{"bidder":["rubiconBidAdapter"],"userId":["sharedIdSystem"],"analytics":["atsAnalyticsAdapter"]} +--out + +``` + +Also test: + +- omitted `user_id` and analytics properties are absent from JSON; +- explicit empty arrays remain present; +- JSON is passed as one `Command` argument; and +- no old generator flag remains. + +Use `serde_json` serialization. Do not hand-build JSON strings. + +### Step 5.5: Require manifest schema version 1 + +Add `schemaVersion` to `PrebidBundleManifest` with Serde rename. Test rejection of: + +- missing schema version; +- `0`; +- `2`; and +- string `"1"`. + +Cover each shape at both levels: + +- a focused `load_manifest` rejection assertion; and +- a `run_bundle` test whose fake generator writes that manifest, then proves the + command fails and the config bytes remain exactly unchanged. + +Keep existing filename, SHA-256, SRI, atomic TOML patching, output, and generator +failure no-patch tests. Update the valid fake generator manifest to emit the +nested shape and schema version 1. + +### Step 5.6: Run focused and full CLI tests + +Run from the repository root: + +```bash +cd "$(git rev-parse --show-toplevel)" +HOST_TARGET=$(rustc -vV | awk '/host:/ { print $2 }') +cargo test --package trusted-server-cli --target "$HOST_TARGET" prebid_bundle::tests +./scripts/test-cli.sh +``` + +Expected: pass. + +### Step 5.7: Run a real cross-language CLI smoke test + +From the repository root, create a temporary config using only example domains: + +```bash +cd "$(git rev-parse --show-toplevel)" +HOST_TARGET=$(rustc -vV | awk '/host:/ { print $2 }') +TMP_DIR=$(mktemp -d) +cat > "$TMP_DIR/trusted-server.toml" <<'TOML' +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] +TOML + +cargo run --package trusted-server-cli --target "$HOST_TARGET" -- \ + prebid bundle \ + --config "$TMP_DIR/trusted-server.toml" \ + --out "$TMP_DIR/prebid" +``` + +Inspect rather than merely listing output: + +```bash +node - "$TMP_DIR/prebid/manifest.json" <<'NODE' +const fs = require('node:fs') +const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) +if (manifest.schemaVersion !== 1) throw new Error('unexpected manifest schema') +if (!manifest.modules.analytics.includes('atsAnalyticsAdapter')) { + throw new Error('ATS module missing from manifest') +} +if (!manifest.runtimeCodes.analytics.includes('atsAnalytics')) { + throw new Error('ATS provider missing from manifest') +} +NODE +rg 'external_bundle_sha256|external_bundle_sri' "$TMP_DIR/trusted-server.toml" +rm -rf "$TMP_DIR" +``` + +Expected: generation succeeds, the nested manifest contains module and runtime +code data, and the temporary config contains updated hash/SRI fields. + +### Step 5.8: Run the complete JS cutover suite + +Run: + +```bash +cd crates/trusted-server-js/lib +npx prettier --check build-prebid-external.mjs \ + test/build-prebid-external.test.mjs \ + test/prebid-artifact-integration.test.mjs +npx vitest run +node build-all.mjs +npm run format +cd "$(git rev-parse --show-toplevel)" +``` + +Expected: every JS test and build passes with no flat-manifest fallback and no +old generator flags. + +### Step 5.9: Review and commit the functional cutover + +Search for stale active contracts from the repository root: + +```bash +cd "$(git rev-parse --show-toplevel)" +rg -n --glob '!node_modules/**' --glob '!dist/**' \ + --glob '!docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md' \ + --glob '!docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md' \ + -- '--adapters|--user-id-modules|bidderCodes|userIdModules|bundle\.adapters|bundle\.user_id_modules' +``` + +Expected: only intentionally retained diagnostic property names such as +`__tsjs_prebid_diagnostics.userIdModules`, curated registry filenames, the new +spec/plan's discussion of removed fields, or documentation still scheduled for +Task 6. There must be no stale executable protocol or flat bundle-manifest +consumer. + +Review `git diff --check` and commit Tasks 2 through 5 together: + +```bash +git add crates/trusted-server-cli/src/prebid_bundle.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/src/integrations/prebid/user_id_modules.json \ + crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts \ + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ + crates/trusted-server-js/lib/test/prebid-artifact-integration.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 +git commit -m "Use typed module map for Prebid bundles" +``` + +If `user_id_modules.test.ts` did not change, omit it from `git add` rather than +creating a no-op edit. + +--- + +## Task 6: Update examples and operator documentation + +**Files:** + +- Modify: `trusted-server.example.toml` +- Modify: `docs/guide/integrations/prebid.md` +- Modify: `docs/guide/cli.md` + +### Step 6.1: Update the example configuration + +Replace the old bundle table with: + +```toml +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +# user_id = ["sharedIdSystem"] +# analytics = ["atsAnalyticsAdapter"] +``` + +Comments must explain: + +- values are exact upstream module stems without `.js`; +- `user_id` omission uses the curated default preset; +- explicit `user_id = []` selects none; +- analytics omission selects none; and +- the table is consumed by `ts prebid bundle`, not the edge runtime. + +Apply the edit to the latest `origin/main` template shape. Do not restore the +older compact template around it. + +### Step 6.2: Rewrite the integration guide's bundle section + +Update the initial Prebid example and configuration table to use: + +- `bundle.modules.bidder`; +- `bundle.modules.user_id`; and +- `bundle.modules.analytics`. + +Replace direct `--adapters` instructions with the supported `ts prebid bundle` +workflow. If a direct Node invocation remains for developer troubleshooting, use +`--modules-json` and label it internal tooling rather than the operator +interface. + +Document these distinctions with examples: + +| Module stem | Runtime setting | +| --------------------- | --------------------------------------------------------- | +| `rubiconBidAdapter` | `client_side_bidders = ["rubicon"]` | +| `atsAnalyticsAdapter` | `pbjs.enableAnalytics({ provider: "atsAnalytics", ... })` | + +Also document: + +- bidder modules are exact package stems; +- selected User ID modules remain limited to the curated registry; +- analytics adapters must exist in the pinned Prebid package; +- Maven Distribution is unsupported by pinned 10.26.0; +- local files and URLs are rejected; +- publisher JavaScript still owns analytics options and enablement; +- manifests contain effective module lists and runtime codes; and +- bundle regeneration changes hash, SRI, and content-addressed filename. + +Update warnings that currently tell operators to add a bidder to +`bundle.adapters` or `--adapters`. + +### Step 6.3: Update the CLI guide + +Replace the old TOML snippet with the canonical module table. Explain omitted +versus empty User ID and analytics selections. Keep the existing local-only, +manual-upload, hash/SRI patching, custom path, and no-`--adapter` behavior. + +### Step 6.4: Check for stale user-facing terminology + +Run: + +```bash +rg -n \ + --glob '!docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md' \ + --glob '!docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md' \ + --glob '!docs/superpowers/specs/2026-08-28-prebid-bundle-module-map-design.md' \ + --glob '!docs/superpowers/plans/2026-08-28-prebid-bundle-module-map.md' \ + 'bundle\.adapters|bundle\.user_id_modules|--adapters|--user-id-modules|\[integrations\.prebid\.bundle\]' +``` + +Expected: no active guide, template, executable, or test uses the removed schema +or generator flags. Historical design records may retain them. + +### Step 6.5: Format, build, and commit docs + +Run: + +```bash +cd docs +npm run format +npm run build +cd .. +git diff --check +git add trusted-server.example.toml docs/guide/integrations/prebid.md docs/guide/cli.md +git commit -m "Document typed Prebid bundle modules" +``` + +Expected: docs format/build pass and the commit contains only examples and +operator documentation. + +--- + +## Task 7: Run full verification and inspect the final contract + +**Files:** None expected beyond in-scope fixes discovered by verification. + +### Step 7.1: Verify formatting and generated JS + +Run from the repository root: + +```bash +cd "$(git rev-parse --show-toplevel)" +cargo fmt --all -- --check +cd crates/trusted-server-js/lib +npx prettier --check build-prebid-external.mjs \ + test/build-prebid-external.test.mjs \ + test/prebid-artifact-integration.test.mjs +npm run format +node build-all.mjs +cd "$(git rev-parse --show-toplevel)/docs" +npm run format +npm run build +cd "$(git rev-parse --show-toplevel)" +git diff --check +``` + +Expected: all pass. If a formatter changes a file, inspect and commit the change +into the commit that owns that file. + +### Step 7.2: Run all Rust test gates + +Run from the repository root: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: all pass. Do not replace these target-matched aliases with bare +`cargo test --workspace`. + +### Step 7.3: Run all 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 pass with warnings denied. + +### Step 7.4: Run the complete JS suite again + +Run: + +```bash +cd crates/trusted-server-js/lib +npx prettier --check build-prebid-external.mjs \ + test/build-prebid-external.test.mjs \ + test/prebid-artifact-integration.test.mjs +npx vitest run +node build-all.mjs +npm run format +``` + +Expected: all pass, including the production ATS queue test. + +### Step 7.5: Perform final negative and manifest checks + +Run a real generator failure with a temporary output directory and the +unsupported analytics module. Confirm: + +- exit status is non-zero; +- stderr names the TOML field and requested stem; +- stderr reports the verified Prebid version and pinned-only guidance; +- Vite does not start; +- no temporary generated directory remains; and +- no `manifest.json` is written. + +Then inspect one successful manifest and exact bundle bytes: + +```bash +shasum -a 256 +cat +``` + +Expected: the computed SHA-256 equals `manifest.sha256`, the filename embeds the +same hash, SRI begins with `sha384-`, and schema/module/runtime-code fields match +the request. + +### Step 7.6: Review the complete diff + +Run: + +```bash +git status --short --branch +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +git log --oneline origin/main..HEAD +``` + +Review every changed file against the spec acceptance criteria. Confirm: + +- no legacy parser or old generator flag remains; +- no flat bundle-manifest fallback remains; +- no config-controlled path or package specifier exists; +- lockfile/install mismatch fails before generation; +- ATS registration is proven through the real queue lifecycle; +- omitted analytics is covered through rendered source and production behavior; +- hash/SRI/TOML patching behavior is unchanged; and +- only the planned files changed. + +### Step 7.7: Report completion evidence + +Summarize: + +- changed files and commit hashes; +- focused CLI/generator/shim/artifact test results; +- full Rust, JS, docs, parity, format, and clippy results; +- the real successful CLI smoke result; +- the unsupported-module negative result; and +- any environment blocker with its exact command and error. + +Do not claim completion without terminal evidence for every required gate. diff --git a/docs/superpowers/specs/2026-08-28-prebid-bundle-module-map-design.md b/docs/superpowers/specs/2026-08-28-prebid-bundle-module-map-design.md new file mode 100644 index 000000000..884e999bf --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-prebid-bundle-module-map-design.md @@ -0,0 +1,874 @@ +# Prebid bundle module map and analytics adapter design + +**Date:** 2026-08-28 +**Status:** Implemented +**Scope:** Typed Prebid module selection for `ts prebid bundle` +**Issue:** [#1085](https://github.com/IABTechLab/trusted-server/issues/1085) +**Supersedes:** The bundle selection and manifest sections of +`2026-06-17-prebid-bundle-cli-design.md` + +## 1. Decision summary + +Replace the category-specific `[integrations.prebid.bundle]` fields with one +typed module map: + +```toml +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter", "kargoBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] +``` + +Configured values are exact Prebid module file stems. The generator appends +`.js`, constructs `prebid.js/modules/.js`, requires exact-case Prebid +metadata, resolves the specifier through the repository's pinned package export +map, verifies the metadata type, and emits a static import. + +The first supported module kinds are: + +- `bidder`; +- `user_id`; and +- `analytics`. + +Trusted Server continues to choose Prebid core and consent modules. Real-time +data modules may be added as a typed category in a later change. + +This is a breaking pre-production change. The old `adapters` and +`user_id_modules` fields are removed without a compatibility parser or a +deprecation period. The issue's proposed intermediate `analytics_adapters` +field will not be added. + +## 2. Problem + +`ts prebid bundle` currently understands two unrelated lists: + +```toml +[integrations.prebid.bundle] +adapters = ["rubicon"] +user_id_modules = ["sharedIdSystem"] +``` + +The Rust CLI turns these lists into separate npm flags. The JavaScript generator +then uses separate resolution and generated-file paths for bidder adapters and +User ID modules. Adding analytics as another top-level field would repeat that +pattern and make each future Prebid module kind another CLI and manifest change. + +The immediate user-visible failure occurs when Trusted Server replaces a +publisher's existing Prebid bundle. Publisher code may still call: + +```js +pbjs.enableAnalytics({ + provider: 'atsAnalytics', + options: { + pid: 'example-publisher-id', + }, +}) +``` + +The replacement bundle does not import `atsAnalyticsAdapter.js`, so Prebid logs: + +```text +Prebid Error: no analytics adapter found in registry for 'atsAnalytics'. +``` + +The module file and runtime provider use different names. In pinned Prebid.js +10.26.0: + +- the module stem is `atsAnalyticsAdapter`; +- the package specifier is `prebid.js/modules/atsAnalyticsAdapter.js`; and +- the registered `pbjs.enableAnalytics` provider is `atsAnalytics`. + +The new model must retain that distinction in configuration, validation, +documentation, and manifest data. + +## 3. Goals + +- Express publisher-selected Prebid modules in one typed TOML table. +- Use exact upstream module stems instead of category-specific suffix guessing. +- Add analytics adapter imports, including `atsAnalyticsAdapter`. +- Keep the list of accepted module kinds closed and typo-safe. +- Resolve selected modules only from the pinned Prebid.js package. +- Reject path traversal, import injection, URLs, missing modules, and module-kind + mismatches before Vite runs. +- Preserve the existing curated User ID default preset and LiveIntent shim. +- Emit selected module stems and registered runtime codes in a structured, + versioned manifest. +- Prove that the production bundle registers `atsAnalytics` before publisher + code enables it. +- Preserve the existing content hash, SRI, content-addressed filename, and TOML + metadata update flow. +- Keep bundle generation local and reproducible from the repository lockfile. + +## 4. Non-goals + +- Backward compatibility for `bundle.adapters` or + `bundle.user_id_modules`. +- Adding `bundle.analytics_adapters` as an intermediate field. +- Accepting local paths, package specifiers, arbitrary import strings, or remote + URLs. +- Supporting publisher-private or custom analytics adapters. +- Automatically downloading a module missing from the pinned Prebid package. +- Letting publishers select or remove Prebid core and consent modules. +- Adding RTD modules in this change. +- Moving Vite or Prebid bundle generation into Rust. +- Uploading the generated bundle or changing `external_bundle_url`. +- Changing the first-party bundle proxy, cache policy, or browser SRI behavior. +- Inferring bundle selections from `bidders`, `client_side_bidders`, or + publisher JavaScript. +- Validating analytics provider options passed to `pbjs.enableAnalytics`. + +## 5. Configuration contract + +### 5.1 Canonical form + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid-server.example.com/openrtb2/auction" +client_side_bidders = ["rubicon"] + +[integrations.prebid.bundle.modules] +bidder = ["rubiconBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] +``` + +`integrations.prebid.bundle` remains build-only configuration consumed by +`ts prebid bundle`. It is not part of `PrebidIntegrationConfig` and does not +change edge runtime behavior by itself. + +### 5.2 Module fields + +| Field | Required | Omission | Value | +| ------------------- | -------- | -------------------------------------- | ------------------------------------------------------------------ | +| `modules.bidder` | Yes | Configuration error | Non-empty array of bidder module stems | +| `modules.user_id` | No | Use the curated default User ID preset | Array of curated User ID module stems; an empty array selects none | +| `modules.analytics` | No | Select no analytics adapters | Array of analytics module stems; an empty array selects none | + +The `modules` table rejects unknown keys. A future module kind requires a schema, +resolver, manifest, documentation, and test change before operators can select +it. + +### 5.3 Exact module stems + +Every configured value names the exact module file stem used in the package +specifier, without `.js`: + +| TOML kind | Configured stem | Package specifier | Runtime code | +| ----------- | --------------------- | ------------------------------------------ | --------------------------------------------------------------- | +| `bidder` | `rubiconBidAdapter` | `prebid.js/modules/rubiconBidAdapter.js` | `rubicon` | +| `user_id` | `sharedIdSystem` | `prebid.js/modules/sharedIdSystem.js` | User ID submodule config names from the Trusted Server registry | +| `analytics` | `atsAnalyticsAdapter` | `prebid.js/modules/atsAnalyticsAdapter.js` | `atsAnalytics` | + +The generator does not turn `rubicon` into `rubiconBidAdapter`. This removes +category-specific filename guessing and matches the names used by upstream +Prebid custom builds. + +Runtime configuration continues to use runtime codes. For example: + +```toml +[integrations.prebid] +client_side_bidders = ["rubicon"] +``` + +```js +pbjs.enableAnalytics({ + provider: 'atsAnalytics', + options: { + pid: 'example-publisher-id', + }, +}) +``` + +Neither runtime value is a module stem or package specifier. + +### 5.4 List rules + +Each configured list must satisfy all of these rules: + +- it is an array; +- every value is a string; +- every value matches `^[A-Za-z0-9_-]+$`; +- values do not include `.js`; +- values preserve exact case; +- values are unique within the list; and +- one module stem does not appear under more than one kind. + +The CLI preserves configured order in imports and manifest module lists. It +rejects duplicates rather than silently deduplicating them. Runtime code lists +derived from metadata are deduplicated and sorted for stable diagnostics. + +### 5.5 Removed fields + +These forms are invalid: + +```toml +[integrations.prebid.bundle] +adapters = ["rubicon"] +user_id_modules = ["sharedIdSystem"] +analytics_adapters = ["atsAnalyticsAdapter"] +``` + +Focused CLI validation should report the replacement table instead of passing +these values through as ignored fields: + +```text +integrations.prebid.bundle.adapters is no longer supported; configure exact module stems under integrations.prebid.bundle.modules.bidder +``` + +No code translates legacy bidder names such as `rubicon` into module stems. +Repository examples, fixtures, and docs move to the new form in the same change. + +## 6. Typed CLI model + +The Rust CLI should deserialize the focused bundle section into private typed +structures equivalent to: + +```rust +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrebidBundleConfig { + modules: PrebidBundleModules, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrebidBundleModules { + bidder: Vec, + user_id: Option>, + analytics: Option>, +} +``` + +`PrebidModuleName` is a validated newtype for the exact module stem. Focused +loading must still allow an otherwise incomplete deployment config so operators +can generate a bundle before `ts config validate` succeeds. + +The CLI validates TOML shape, required fields, names, empty-list semantics, and +duplicates. Before typed deserialization, it checks `adapters`, +`user_id_modules`, and `analytics_adapters` in that fixed order so removed fields +receive the migration messages defined in section 5.5 even when old and new +forms are mixed. It then uses `deny_unknown_fields` for every other unsupported +key. + +The JavaScript generator repeats all security-relevant name and type validation +because it remains directly executable outside the Rust CLI. + +## 7. Build flow + +```mermaid +flowchart LR + C["bundle.modules TOML"] --> R["Rust focused parser"] + R --> N["Typed module selection"] + N --> J["Serialized generator request"] + J --> V["JS name, path, and metadata validation"] + V --> G["Generated static imports and module manifest"] + G --> B["Vite IIFE bundle"] + B --> H["SHA-256, SRI, content-addressed filename"] + H --> M["manifest.json"] + M --> P["Patch external_bundle_sha256 and external_bundle_sri"] +``` + +The existing order around generation remains transactional: + +1. Load and validate focused bundle configuration. +2. Verify local npm prerequisites. +3. Ensure the output directory is writable. +4. Run the JavaScript generator. +5. Read and validate the generated manifest. +6. Update hash and SRI metadata only after successful generation. +7. Print the generated filename and upload/configuration next step. + +A generator or manifest failure leaves the Trusted Server TOML unchanged. + +## 8. CLI-to-generator protocol + +Replace the category-specific `--adapters` and `--user-id-modules` flags with one +serialized module request plus `--out`: + +```bash +npm run build:prebid-external -- \ + --modules-json '{"bidder":["rubiconBidAdapter"],"userId":["sharedIdSystem"],"analytics":["atsAnalyticsAdapter"]}' \ + --out /absolute/path/to/dist/prebid +``` + +The Rust CLI constructs the JSON with `serde_json` and passes it as one argument +to `Command`; it does not construct shell-quoted text. Omitted `user_id` is +omitted from the JSON so the JavaScript generator can apply the curated default +preset. A present empty array remains an empty array. The generator normalizes an +omitted or empty `analytics` selection to an empty array. + +The request schema accepts only: + +```ts +interface PrebidBundleModuleRequest { + bidder: string[] + userId?: string[] + analytics?: string[] +} +``` + +Unknown properties and malformed arrays fail before temporary generated files +are created. The old generator flags are removed rather than translated. + +The supported operator interface remains `ts prebid bundle`. The JSON argument +is an internal CLI/tooling protocol, though direct generator tests must cover it. + +## 9. Module resolution and trust boundary + +### 9.1 Pinned package and actual bundle target + +The exact-case metadata catalogue for upstream selections is: + +```text +crates/trusted-server-js/lib/node_modules/prebid.js/metadata/modules/ +``` + +Prebid's package exports map `prebid.js/modules/.js` to the file Vite +bundles, currently `prebid.js/dist/src/public/.js`. A valid package export +does not always have a physical source entry under `prebid.js/modules/` in the +published package. For example, pinned Prebid 10.26.0 exports +`sharedIdSystem.js` without shipping `modules/sharedIdSystem.js`. Validation +therefore uses the exact-case metadata entry and resolved package-export target, +not the optional source-tree layout. + +Before processing selections, the generator reads the expected Prebid version +from `package-lock.json` at `packages["node_modules/prebid.js"].version` and the +installed version from `node_modules/prebid.js/package.json`. A missing value or +version mismatch fails before temporary generation or Vite, reports both values, +and instructs the operator to run `npm ci`. The current expected version is +10.26.0. The generated manifest records the verified installed version. + +For each selected stem, the generator: + +1. Revalidates the module-stem grammar. +2. Reads the metadata directory and requires an exact-case filename match for + `.json`, independent of host filesystem case behavior. +3. Canonicalizes the metadata file, confirms it is a regular file, and confirms + it remains a direct child of the canonical metadata directory. +4. Confirms at least one metadata component has the expected component type and + derives runtime component codes from matching entries. +5. Constructs the exact package specifier `prebid.js/modules/.js` and + resolves it with `createRequire(import.meta.url).resolve(...)`. +6. Canonicalizes the resolved package-export target and installed Prebid package + root, then requires the target to be a regular file contained within that + root. +7. Emits the same validated package specifier as a static import only after all + checks succeed. + +Containment checks remain mandatory even though the module-stem grammar excludes +path separators. The grammar prevents code-generation injection; canonical path +containment protects the metadata and package-export boundaries from filesystem +surprises such as symlinks. A test seam around package resolution must prove that +a valid metadata entry with a missing or escaping export target fails before the +Vite build function runs. + +### 9.2 Kind mapping + +| TOML kind | Prebid metadata `componentType` | Additional rule | +| ----------- | ------------------------------- | ------------------------------------------------------ | +| `bidder` | `bidder` | Collect every bidder component name, including aliases | +| `user_id` | `userId` | Module must also exist in `user_id_modules.json` | +| `analytics` | `analytics` | Collect every analytics provider component name | + +A real upstream file with the wrong metadata type is rejected. For example, +placing `sharedIdSystem` under `analytics` must not produce a valid import. + +### 9.3 User ID registry and trusted shim + +The existing `user_id_modules.json` remains the source of truth for: + +- the default User ID preset; +- publisher configuration names; +- EID source diagnostics; and +- the LiveIntent ESM compatibility note. + +It is not an import-path authority. Every User ID import is derived as +`prebid.js/modules/.js`. The registry's existing `importPath` +property must either be removed or validated as exactly equal to that derived +specifier so it cannot create a second configurable resolution path. + +User ID selection remains limited to registry entries in this change. Expanding +selection to every upstream User ID module requires the corresponding diagnostic +metadata and tests. + +`liveIntentIdSystem` first passes the same metadata and ordinary package-export +validation as every other upstream selection. Vite may then apply the fixed +generator-owned alias to the checked-in ESM shim. The generator separately +canonicalizes that exact shim destination and requires it to be the expected +regular repository file. This is a fixed trusted build override, not a +configurable source path. + +### 9.4 Unsupported custom modules + +A missing module fails closed. The generator does not search the repository, +current working directory, npm registry, or network for a substitute. Supporting +publisher-owned modules requires a separate design covering source trust, +versioning, dependency installation, review, and reproducible builds. + +## 10. Generated entry and import ordering + +The generated entry continues to import Prebid core and Trusted Server-required +consent modules. It then imports selected modules through generated static +imports. + +Conceptually, the generated source is ordered as follows: + +```ts +import 'prebid.js' +import 'prebid.js/modules/consentManagementTcf.js' +import 'prebid.js/modules/consentManagementGpp.js' +import 'prebid.js/modules/consentManagementUsp.js' + +// Present when the effective User ID selection is non-empty. +import 'prebid.js/modules/userId.js' + +// Generated, validated imports grouped in this order. +import 'prebid.js/modules/rubiconBidAdapter.js' +import 'prebid.js/modules/sharedIdSystem.js' +import 'prebid.js/modules/atsAnalyticsAdapter.js' +``` + +Generated imports use this kind order: + +1. bidder; +2. User ID; and +3. analytics. + +Within a kind, imports preserve TOML order. Core and required base modules execute +before selected submodules. The generator may use one temporary generated module +or separate temporary files, but there must be one normalized selection model +and one manifest source of truth. + +An omitted or empty analytics selection emits no analytics imports. It must not +register an analytics adapter or change auction routing. The structured manifest +change will alter bundle bytes and hashes once this design lands; the issue's +"preserves current output behavior" criterion means functional behavior when no +analytics modules are selected, not preservation of an old content hash. + +## 11. Manifest contract + +### 11.1 Disk manifest + +Replace the category-specific manifest fields with a versioned module structure: + +```json +{ + "schemaVersion": 1, + "prebidVersion": "10.26.0", + "modules": { + "bidder": ["rubiconBidAdapter"], + "userId": ["sharedIdSystem"], + "analytics": ["atsAnalyticsAdapter"] + }, + "runtimeCodes": { + "bidder": ["rubicon"], + "analytics": ["atsAnalytics"] + }, + "sha256": "abc123...", + "sri": "sha384-...", + "filename": "trusted-prebid-abc123.js" +} +``` + +Manifest module arrays always contain the effective selection. If `user_id` is +omitted in TOML, `modules.userId` contains the expanded default preset. Omitted +analytics produces `modules.analytics: []`. + +The old `adapters`, `bidderCodes`, and `userIdModules` properties are removed. +The Rust CLI requires `schemaVersion: 1` before applying hash and SRI metadata. +It continues to validate `filename`, `sha256`, and `sri` as it does today. + +### 11.2 Browser selection manifest + +The external bundle stamps selection data used for browser diagnostics by the +TSJS Prebid shim: + +```js +window.__tsjs_prebid_bundle = Object.freeze({ + schemaVersion: 1, + modules: { + bidder: ['rubiconBidAdapter'], + userId: ['sharedIdSystem'], + analytics: ['atsAnalyticsAdapter'], + }, + runtimeCodes: { + bidder: ['rubicon'], + analytics: ['atsAnalytics'], + }, +}) +``` + +The shim treats this page-owned global as untrusted input. Its parser follows +this contract: + +- a non-object root or `schemaVersion !== 1` makes the entire manifest + unavailable; +- an unsupported version uses the same one-time diagnostic as an absent + manifest; +- with a valid version, a missing or non-object `modules` or `runtimeCodes` + container makes only fields under that container unavailable; +- each consumed list must be an array containing only strings; +- one invalid list makes that list unavailable rather than filtering entries or + invalidating valid sibling lists; and +- flat fields from the removed manifest are never consulted. + +The shim reads `runtimeCodes.bidder` when checking `client_side_bidders` and +`modules.userId` for User ID diagnostics. Analytics selections are present for +browser debugging but are not an audit record because page code can replace the +global. The disk manifest is the durable audit artifact. + +The Trusted Server shim does not call `pbjs.enableAnalytics`, because publisher +code owns provider options and enablement timing. + +## 12. Analytics registration behavior + +Analytics modules are imported for their registration side effect. For +`atsAnalyticsAdapter`, evaluation executes upstream registration equivalent to: + +```js +adapterManager.registerAnalyticsAdapter({ + adapter: atsAnalyticsAdapter, + code: 'atsAnalytics', +}) +``` + +All selected modules finish evaluating before the external entry stamps its +manifest and schedules the watchdog. The external bundle intentionally leaves +publisher queue processing to the TSJS shim. Queue callbacks run later when the +shim calls `pbjs.processQueue()`, or through the five-second watchdog if the shim +does not install. Publisher code can use: + +```js +pbjs.que.push(() => { + pbjs.enableAnalytics({ + provider: 'atsAnalytics', + options: { + pid: 'example-publisher-id', + }, + }) +}) +``` + +The runtime acceptance test must enqueue and execute this callback with +`options.pid`. The callback records that it started, catches and stores any error +from `pbjs.enableAnalytics`, and records completion only after the call returns. +Prebid processes queued callbacks asynchronously and catches callback exceptions, +so the test waits until the callback either completes or records an error. It +must assert completion, no stored error, no missing-registry diagnostic, and no +`Error processing command` diagnostic. Console and network spies must be +installed before either production artifact is evaluated. Every browser network +primitive used by Prebid must remain stubbed so the adapter cannot contact its +real analytics endpoints. + +## 13. Error contract + +Errors must identify the failing config field, requested stem, and recovery path. +Examples use the installed Prebid version at runtime rather than hard-coding it. + +Installed dependency mismatch: + +```text +[build-prebid-external] installed prebid.js version 10.x does not match package-lock.json version 10.26.0; run `npm ci` in crates/trusted-server-js/lib and retry +``` + +Missing upstream module: + +```text +[build-prebid-external] integrations.prebid.bundle.modules.analytics requested "mavenDistributionAnalyticsAdapter", but prebid.js 10.26.0 does not provide modules/mavenDistributionAnalyticsAdapter.js. Choose an analytics module shipped by the pinned prebid.js package; local paths and URLs are unsupported. +``` + +Invalid stem: + +```text +[build-prebid-external] integrations.prebid.bundle.modules.analytics contains invalid module stem "../atsAnalyticsAdapter"; use the exact upstream filename without .js +``` + +Kind mismatch: + +```text +[build-prebid-external] integrations.prebid.bundle.modules.analytics requested "sharedIdSystem", but its pinned Prebid metadata declares userId rather than analytics +``` + +Unknown User ID module: + +```text +[build-prebid-external] integrations.prebid.bundle.modules.user_id requested "exampleIdSystem", but Trusted Server has no User ID registry entry for it +``` + +The Rust command forwards generator stdout and stderr. Generator failure does not +patch `trusted-server.toml`, and temporary generated files are removed in a +`finally` path. + +## 14. Required code changes + +### Rust CLI + +Update `crates/trusted-server-cli/src/prebid_bundle.rs` to: + +- replace `adapters` and `user_id_modules` with typed module selections; +- deserialize the focused bundle table with unknown-field rejection; +- validate names, required/empty semantics, and duplicates; +- serialize the generator's module request as JSON; +- replace old npm arguments with `--modules-json`; +- require manifest schema version 1; and +- keep the current output-directory, process, atomic config patch, and error + behavior. + +Update unit tests and the fake generator manifest to the new request and +manifest structures. + +### JavaScript generator + +Update `crates/trusted-server-js/lib/build-prebid-external.mjs` to: + +- parse and validate `--modules-json`; +- remove `--adapters` and `--user-id-modules`; +- normalize omitted User ID and analytics selections; +- compare the lockfile Prebid version with the installed package version; +- validate exact-case metadata entries, package-export targets, canonical paths, + regular files, and kind; +- use the curated User ID registry for membership and diagnostics, derive import + specifiers from validated stems, and retain only fixed generator-owned aliases; +- generate static imports from one normalized module model; +- derive bidder and analytics runtime codes from Prebid metadata; +- stamp the structured browser manifest; +- emit manifest schema version 1; and +- retain the current temporary-file cleanup, Vite build, hashing, SRI, and atomic + bundle rename behavior. + +### TSJS Prebid shim + +Update +`crates/trusted-server-js/lib/src/integrations/prebid/index.ts` to parse the +versioned nested browser manifest using the deterministic whole-manifest and +per-list failure rules in section 11.2. Bidder and User ID diagnostics move to +the new paths with no fallback to the removed flat fields. + +### Documentation and examples + +Update: + +- `trusted-server.example.toml`; +- `docs/guide/integrations/prebid.md`; +- `docs/guide/cli.md`; and +- direct generator examples and relevant fixtures. + +Documentation must show exact module stems and the filename-to-runtime-code +distinction for both bidder and analytics modules. It must state that only +modules from the pinned Prebid package are accepted and that custom adapters are +outside this design. + +The implemented 2026-06-17 design remains historical. This spec supersedes its +bundle selection, generator argument, validation, and manifest sections rather +than rewriting that record. + +## 15. Test plan + +### 15.1 Rust CLI tests + +- Accept a complete `bundle.modules` table. +- Require a non-empty `modules.bidder` array. +- Use `None` for omitted `user_id` and analytics selections. +- Accept explicit empty `user_id` and `analytics` arrays. +- Reject missing `bundle.modules`. +- Reject each old `adapters`, `user_id_modules`, and `analytics_adapters` field + with its migration guidance, including configs that mix old and new forms. +- Reject unknown module kinds. +- Reject non-array values, non-string entries, empty strings, whitespace, + `.js`, separators, traversal, quotes, URLs, and control characters. +- Reject duplicates within one kind and across kinds. +- Preserve configured module order in the generator request. +- Serialize the expected `--modules-json` argument. +- Require manifest schema version 1. +- Forward generator errors and leave config unchanged on failure. +- Preserve `external_bundle_url` while updating SHA-256 and SRI after success. + +### 15.2 Generator unit tests + +- Parse the valid module request schema. +- Reject malformed JSON, unknown properties, missing bidder modules, and invalid + list values. +- Reject a lockfile/installed Prebid version mismatch before creating temporary + files or invoking Vite. +- Expand an omitted User ID selection to the checked-in default preset. +- Preserve an explicit empty User ID selection. +- Normalize omitted analytics to an empty list. +- Resolve `rubiconBidAdapter`, `sharedIdSystem`, and `atsAnalyticsAdapter` + through exact-case metadata entries and their package-export targets. +- Prove that `sharedIdSystem` resolves successfully without requiring a physical + `prebid.js/modules/sharedIdSystem.js` source entry. +- Reject wrong-case stems on case-insensitive and case-sensitive hosts. +- Reject traversal, absolute paths, URL-like values, import-string injection, + `.js` suffixes, metadata symlink escapes, and package-export target escapes. +- With an injected resolver/build seam, reject a module whose metadata entry + exists but whose package-export target is missing or outside the package; + prove Vite was not called. +- Reject missing files with the field, requested stem, pinned version, and + upstream-only guidance in the error. +- Reject metadata kind mismatches. +- Reject User ID modules absent from the Trusted Server registry. +- Derive bidder aliases from metadata. +- Derive `atsAnalytics` from `atsAnalyticsAdapter` metadata. +- Preserve configured import order and sort/deduplicate runtime codes. +- Test a pure import/entry renderer, or capture generated source through an + injected build runner, to prove an empty analytics selection emits no + analytics import. +- Remove temporary generated files after success and failure. + +### 15.3 Production bundle tests + +Build a bundle containing: + +```toml +bidder = ["rubiconBidAdapter"] +user_id = ["sharedIdSystem"] +analytics = ["atsAnalyticsAdapter"] +``` + +Before evaluating either production artifact, install console/error spies and +stub every browser network primitive used by Prebid. Create the server-style +`{ que: [], cmd: [] }` global and enqueue a callback that: + +- records that it started; +- calls + `pbjs.enableAnalytics({ provider: "atsAnalytics", options: { pid: "example-publisher-id" } })` + inside `try`/`catch`; +- records completion only after `pbjs.enableAnalytics` returns; and +- stores any thrown error. + +Evaluate the production external bundle followed by the production TSJS shim. +Use `vi.waitFor` to wait until the callback either completes or stores an error. + +Assert that: + +- `manifest.json` uses schema version 1; +- all three effective module arrays are exact; +- `runtimeCodes.bidder` contains `rubicon`; +- `runtimeCodes.analytics` contains `atsAnalytics`; +- the browser selection manifest has the same data; +- the queued callback started and completed; +- the callback stored no error; +- no exact missing-registry diagnostic was emitted; +- no `Error processing command` diagnostic was emitted; and +- every analytics endpoint remained behind a test stub and received no real + network traffic. + +Build without `analytics` and assert that: + +- `modules.analytics` and `runtimeCodes.analytics` are empty; and +- bidder, User ID, auction, watchdog, hash, and SRI behavior still works. + +Generated-source unit coverage, rather than the minified IIFE, proves that no +analytics import was emitted. + +Request `mavenDistributionAnalyticsAdapter` and assert that generation fails +before Vite runs because the pinned package does not contain the file. This test +should read the installed Prebid version dynamically. If a future Prebid upgrade +adds that module, replace the fixture with a guaranteed fictional missing stem +while retaining a focused assertion for the issue's observed unsupported module +where version-appropriate. + +### 15.4 TSJS shim tests + +- Parse a valid nested browser manifest. +- Treat root versions `0`, `2`, `"1"`, and a missing version as unavailable. +- Treat absent or non-object `modules` and `runtimeCodes` containers + independently. +- Reject a whole consumed list when it contains mixed string and non-string + entries while preserving valid sibling lists. +- Use `runtimeCodes.bidder` for client-side bidder diagnostics. +- Use `modules.userId` for User ID diagnostics. +- Do not consult removed flat manifest fields. +- Do not treat analytics module stems as provider codes. +- Emit the defined one-time diagnostic when the versioned selection manifest is + absent or unsupported. + +### 15.5 Verification commands + +Run the narrow suites while implementing, then complete: + +```bash +./scripts/test-cli.sh +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && node build-all.mjs +cd crates/trusted-server-js/lib && npm run format +cd docs && npm run format +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +## 16. Acceptance criteria + +The design is complete when all of these statements are true: + +1. `ts prebid bundle` accepts only the typed + `[integrations.prebid.bundle.modules]` schema. +2. Configuration uses exact upstream module stems for bidder, User ID, and + analytics selections. +3. Legacy category fields fail with clear replacement guidance. +4. The installed Prebid version matches `package-lock.json`, and selected + modules resolve through exact-case metadata entries and contained + package-export targets without requiring optional package source files. +5. Fixed generator-owned aliases such as the LiveIntent shim apply only after + ordinary upstream validation and separate destination validation. +6. Invalid, escaping, missing, or wrong-kind module names fail before Vite runs. +7. A bundle selecting `atsAnalyticsAdapter` registers the runtime provider + `atsAnalytics`. +8. Enabling `atsAnalytics` does not produce Prebid's missing-registry error. +9. An unavailable analytics module fails with the config field, requested stem, + pinned package version, and upstream-only guidance. +10. The disk and browser manifests record exact selected modules and derived + bidder/analytics runtime codes under schema version 1. +11. Omitted analytics produces no analytics imports and preserves existing + auction behavior. +12. Hash, SRI, content-addressed filename, config patching, and first-party + bundle delivery remain unchanged. +13. CLI, generator, production artifact, shim, configuration example, and guide + coverage all use the new schema. + +## 17. Rejected alternatives + +### Add `analytics_adapters` beside the existing fields + +This solves issue #1085 but repeats the category-specific design and leaves the +next Prebid module kind with the same problem. + +### Map module names to inline type objects + +```toml +[integrations.prebid.bundle.modules] +atsAnalyticsAdapter = { type = "analytics" } +``` + +This is more verbose, makes type grouping harder to scan, and reserves per-module +options that bundle inclusion does not currently need. Runtime adapter options +belong in publisher Prebid configuration, not the Trusted Server build list. + +### Accept arbitrary module import strings + +This turns local config into code generation and bypasses the pinned dependency +and trust policy. Exact package stems provide the needed flexibility without +opening filesystem or network sources. + +### Infer module kind from filename alone + +Suffixes are conventions, while Prebid metadata is the package's structured +record of component type and runtime code. Filename grammar protects the import +boundary; metadata validates semantics. + +### Infer bundle modules from runtime configuration + +`client_side_bidders` contains bidder runtime codes, and publisher analytics +configuration may live outside Trusted Server. Neither is a complete or reliable +source for exact package module selections. Bundle inputs remain explicit. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b0e359cb4..2d0ef194e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -422,10 +422,14 @@ client_side_bidders = [] # bidders running via native Prebid.js adapter # when.zone = "header" # set = { placementId = "_abc" } # -# Bundle build inputs consumed by the `ts prebid bundle` CLI (not the runtime): -# [integrations.prebid.bundle] -# adapters = ["rubicon"] -# user_id_modules = ["sharedIdSystem"] +# Bundle build inputs consumed by `ts prebid bundle`, not by the edge runtime. +# Values are exact upstream module stems without `.js`. +# [integrations.prebid.bundle.modules] +# bidder = ["rubiconBidAdapter"] +# Omit user_id for the curated default preset; use [] to select none. +# user_id = ["sharedIdSystem"] +# Omitted or empty analytics selects no analytics adapters. +# analytics = ["atsAnalyticsAdapter"] # Next.js first-party rewriting for App Router / RSC payloads. # [integrations.nextjs] From 31700a6a1c61478e1a0758e796aa24168d1472e5 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 16:20:08 -0500 Subject: [PATCH 3/5] Deduplicate Prebid artifact network assertions --- .../lib/test/prebid-artifact-integration.test.mjs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 14f72c951..d9ad9d5a5 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 @@ -195,6 +195,11 @@ function installNetworkAndConsoleStubs(pageWindow) { }; } +function expectNoUnexpectedNetworkActivity(stubs) { + expect(stubs.unexpectedTransports).toEqual([]); + expect(stubs.blockedResourceRequests).toEqual([]); +} + function installServerState(pageWindow, { analytics = false } = {}) { pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); pageWindow.__tsjs_prebid = { clientSideBidders: [] }; @@ -347,8 +352,7 @@ describe('tsjs-prebid production artifacts', () => { expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); await runAuction(pageWindow, stubs.fetchSpy); - expect(stubs.unexpectedTransports).toEqual([]); - expect(stubs.blockedResourceRequests).toEqual([]); + expectNoUnexpectedNetworkActivity(stubs); } finally { dom.window.close(); } @@ -367,8 +371,7 @@ describe('tsjs-prebid production artifacts', () => { expect(pageWindow.__tsjs_prebid_bundle.modules.analytics).toEqual([]); expect(pageWindow.__tsjs_prebid_bundle.runtimeCodes.analytics).toEqual([]); await runAuction(pageWindow, stubs.fetchSpy); - expect(stubs.unexpectedTransports).toEqual([]); - expect(stubs.blockedResourceRequests).toEqual([]); + expectNoUnexpectedNetworkActivity(stubs); } finally { dom.window.close(); } @@ -401,8 +404,7 @@ describe('tsjs-prebid production artifacts', () => { expect(pageWindow.__watchdogCallbackRan).toBe(true); expect(pageWindow.__tsjs_prebid_bundle.modules.analytics).toEqual([]); expect(pageWindow.__tsjs_prebid_bundle.runtimeCodes.analytics).toEqual([]); - expect(stubs.unexpectedTransports).toEqual([]); - expect(stubs.blockedResourceRequests).toEqual([]); + expectNoUnexpectedNetworkActivity(stubs); } finally { vi.useRealTimers(); dom.window.close(); From f655b6649aa28fce43dc2cbe0426aaa1e03455bb Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 20:09:59 -0500 Subject: [PATCH 4/5] Fix Prebid browser fixture generation --- .github/workflows/integration-tests.yml | 2 +- .../lib/test/build-prebid-external.test.mjs | 8 -------- scripts/integration-tests-browser.sh | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4973afe44..a865ea27e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -192,7 +192,7 @@ jobs: run: | npm ci npm run build - npm run build:prebid-external + npm run build:prebid-external -- --modules-json '{"bidder":["rubiconBidAdapter"]}' - name: Install Playwright working-directory: crates/trusted-server-integration-tests/browser 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 972fbbb74..7f507e6dd 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 @@ -69,14 +69,6 @@ function createResolverFixture(metadata) { return { temp, packageDir, metadataDir, target }; } -function fakeBundleMetadata() { - return { - filename: `trusted-prebid-${'a'.repeat(64)}.js`, - sha256: 'a'.repeat(64), - sri: 'sha384-example', - }; -} - describe('build-prebid-external request parsing', () => { it('accepts the typed module request and preserves order', () => { const request = parseRequest({ diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 714de510b..e81767be3 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -61,7 +61,7 @@ echo "==> Building TSJS browser fixtures..." cd "$REPO_ROOT/$TSJS_LIB_DIR" npm ci npm run build -npm run build:prebid-external +npm run build:prebid-external -- --modules-json '{"bidder":["rubiconBidAdapter"]}' cd "$REPO_ROOT/$BROWSER_DIR" # --- Export env vars for global-setup.ts --- From 07aa6176bb694c269b40950b6922599e0ee63421 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 20:25:41 -0500 Subject: [PATCH 5/5] Remove unused Prebid module kind field --- crates/trusted-server-js/lib/build-prebid-external.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 2157901d5..dc20dd3e6 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -392,7 +392,6 @@ function resolveOneModule(stem, definition, context) { } return { - kind: definition.requestKey, stem, specifier, runtimeCodes: [...new Set(runtimeCodes)].sort(),